{
  "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": 681,
  "tools": [
    {
      "name": "enable_tools",
      "title": "Turn on more Hermoso tools",
      "description": "Switch on a group of tools that is not in this session's roster — no reconnect, no config edit. The default roster is every group EXCEPT `ads` and `analytics`, which are held out purely on SIZE: paid-campaign management is by far the largest group, most of the total schema weight across eleven ad platforms, and measurement is a third again on top of everything else. Most sessions need neither. Nothing in either is unfinished or unsafe — they are one call away. CALL THIS THE MOMENT YOU NEED ONE. If the user asks to build, budget, target, report on or change an ad campaign on any platform, call enable_tools({groups:['ads']}) first and the tools appear. If they ask about their own site or product analytics, a tag/tracking container, or how a search engine crawls, indexes or ranks their site, call enable_tools({groups:['analytics']}). Groups: core, research, create, channels, analytics, ads, files, workspace — or 'all'. Free, instant, and it never turns anything off.",
      "group": "workspace",
      "section": "read-only / discovery",
      "annotations": {
        "title": "Turn on more Hermoso tools",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "groups": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Groups to switch on, e.g. ['ads']. Unknown names are refused by name rather than ignored."
          }
        },
        "required": [
          "groups"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_to_bluesky",
      "title": "Post to Bluesky",
      "description": "Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. MEDIA: either up to 4 images (imageUrls + altText) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings ▸ Connectors ▸ Bluesky with a handle and an APP PASSWORD.",
      "group": "publish",
      "section": "THE INBOX",
      "annotations": {
        "title": "Post to Bluesky",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "text": {
            "type": "string",
            "description": "The post, up to 300 characters / 3000 UTF-8 bytes."
          },
          "imageUrls": {
            "description": "Up to 4 public image URLs to attach. Cannot be combined with videoUrl.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "altText": {
            "description": "Alt text — an ARRAY, one per image in the same order, or a single STRING to describe every image with it. WRITE ONE: Bluesky’s own lexicon makes `alt` a REQUIRED property of every image, so a post without it is undescribed by design rather than by omission, and Bluesky users expect it. No maximum length is published, so nothing is truncated.",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            ]
          },
          "videoUrl": {
            "description": "One public MP4 URL. Cannot be combined with imageUrls. Bluesky transcodes it, which takes a minute or two.",
            "type": "string"
          },
          "videoAlt": {
            "description": "Alt text describing the video, for accessibility.",
            "type": "string"
          },
          "captions": {
            "description": "Up to 20 WebVTT caption tracks: [{lang:'en', url:'https://…/en.vtt'}] or [{lang:'en', content:'WEBVTT\\n\\n00:00…'}]. Each file is capped at 20000 bytes.",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "langs": {
            "description": "BCP-47 language tags, e.g. ['en'].",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "linkCard": {
            "description": "Rich link card (`app.bsky.embed.external`). OMIT for the default (a card is built automatically when the post has a URL and no media). `false` never builds one. `true` builds one from the first URL in the text. An object {uri,title,description,thumbUrl} overrides any field — supply BOTH title and description and the page is never fetched. Cannot be combined with imageUrls/videoUrl: a post has ONE embed, so an explicit linkCard beside media is refused.",
            "anyOf": [
              {
                "type": "boolean"
              },
              {
                "type": "object",
                "propertyNames": {
                  "type": "string"
                },
                "additionalProperties": {}
              }
            ]
          }
        },
        "required": [
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_bluesky_post",
      "title": "Delete a post from the connected Bluesky account",
      "description": "PERMANENTLY delete one of the connected Bluesky account's OWN posts. IRREVERSIBLE — the AT Protocol removes the record from the account's repo, there is no trash and no undelete, and the post's likes, reposts, replies and quotes go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the post's REAL text and its live like / repost / reply / quote counts read back from Bluesky. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the post has ANY engagement, confirmText echoing the post's own text (the first 40 characters is enough; any longer leading run works too). confirmText exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. A brand-new post with nothing on it stays a ONE-call delete. Identify the post by its AT-URI or by just its RECORD KEY — the short id at the end of its bsky.app link, e.g. 3mtc4n3fibn2x. Deleting only ever works on the connected account's own posts; another account's URI is refused. 0 credits. Needs Bluesky connected (Settings ▸ Connectors ▸ Bluesky).",
      "group": "publish",
      "section": "THE INBOX",
      "annotations": {
        "title": "Delete a post from the connected Bluesky account",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "uri": {
            "type": "string",
            "description": "the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, or just its record key (3mtc4n3fibn2x)"
          },
          "confirm": {
            "description": "REQUIRED true — deletion is permanent and cannot be undone",
            "type": "boolean"
          },
          "confirmText": {
            "description": "the post's own text as the unconfirmed call reported it — the first 40 characters is enough. Required once the post has any likes, reposts, replies or quotes. A post with no text asks for its cid instead.",
            "type": "string"
          }
        },
        "required": [
          "uri"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_to_telegram",
      "title": "Post to Telegram",
      "description": "Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @hermosoai) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings ▸ Connectors ▸ Telegram by pasting a bot token from @BotFather.",
      "group": "publish",
      "section": "TELEGRAM (2026-08-19)",
      "annotations": {
        "title": "Post to Telegram",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "chatId": {
            "type": "string",
            "description": "REQUIRED — the destination: a public channel's @username, or the numeric chat id. Never guessed; ask the user, or use list_telegram_chats."
          },
          "text": {
            "description": "the message. ≤4096 characters on its own; ≤1024 once any image or video is attached.",
            "type": "string"
          },
          "imageUrl": {
            "description": "one image (≤10MB after upload)",
            "type": "string"
          },
          "imageUrls": {
            "description": "an ALBUM of 2–10 media, in order. Photos and videos may be mixed — Telegram allows it.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "videoUrl": {
            "description": "one video (≤50MB). Passed alongside imageUrls it joins the album as one more item.",
            "type": "string"
          },
          "disablePreview": {
            "description": "suppress the link-preview card on a text-only post. Default is Telegram’s own behaviour (previews on).",
            "type": "boolean"
          },
          "silent": {
            "description": "deliver without a notification sound (Telegram’s disable_notification). This is NOT a visibility setting — the message is just as visible.",
            "type": "boolean"
          }
        },
        "required": [
          "chatId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_telegram_chats",
      "title": "Find Telegram chat ids",
      "description": "Find the chat ids this Telegram bot can be addressed by. STATE THE LIMIT WHENEVER YOU USE IT: this is NOT the list of chats the bot belongs to — the Bot API publishes no such method — it is every chat that SENT the bot an update in the last 24 hours, which is as long as Telegram keeps an update. A channel the bot posts to every day but nobody messages will NOT appear here, and its absence means nothing at all: post to it by @username or numeric id anyway. If the bot has an outgoing WEBHOOK configured the list is empty for that reason alone (Telegram: getUpdates \"will not work if an outgoing webhook is set up\"), and the reply says so rather than reading as an empty account. Nothing is consumed — no update offset is confirmed, so this cannot eat the bot’s pending updates. Free, 0 credits.",
      "group": "publish",
      "section": "TELEGRAM (2026-08-19)",
      "annotations": {
        "title": "Find Telegram chat ids",
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "how many recent updates to scan, 1–100 (default 100)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_telegram_message",
      "title": "Delete a Telegram message",
      "description": "PERMANENTLY delete one message the bot posted to a Telegram chat. Call it WITHOUT confirm first: nothing is deleted and you get a sentence to show the user. There is deliberately NO preview of the message — the Bot API has no method that reads one message back, so anything shown would be invented, and for the same reason the result after deleting is Telegram’s own success answer rather than a verified read-back. TWO VENDOR LIMITS, both Telegram’s and neither ours: \"A message can only be deleted if it was sent less than 48 hours ago\", and in a CHANNEL the bot needs the Post Messages right to remove even its own posts. Takes the same chatId as post_to_telegram plus the messageId post_to_telegram returned. 0 credits. Needs Telegram connected (Settings ▸ Connectors ▸ Telegram).",
      "group": "publish",
      "section": "TELEGRAM (2026-08-19)",
      "annotations": {
        "title": "Delete a Telegram message",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "chatId": {
            "type": "string",
            "description": "the chat the message is in — the same @username or numeric id it was posted with"
          },
          "messageId": {
            "type": "number",
            "description": "the message id post_to_telegram returned (also the number at the end of a t.me link)"
          },
          "confirm": {
            "description": "REQUIRED true — Telegram has no trash and no undelete",
            "type": "boolean"
          }
        },
        "required": [
          "chatId",
          "messageId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_inbox",
      "title": "One inbox for comments, replies, mentions and reviews",
      "description": "EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and Instagram comments, Threads replies and mentions, YouTube and Reddit comments, Google Business reviews, Bluesky replies and mentions, and X mentions. Use this for 'what do I need to reply to', 'any new comments', 'how are people responding'. Each item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is skipped silently; a channel that FAILS to read is named in `notes` rather than dropped, so a short list is never mistaken for a quiet week. Free — it only re-reads what the per-channel tools already read.",
      "group": "publish",
      "section": "TELEGRAM (2026-08-19)",
      "annotations": {
        "title": "One inbox for comments, replies, mentions and reviews",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "sources": {
            "description": "Limit to these sources: facebook, instagram, threads, threads_mention, youtube, reddit, google_business, x, bluesky, bluesky_mention. Omit for every connected channel.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "postId": {
            "description": "Restrict Facebook/Instagram to one post or media id (Meta reads comments per post).",
            "type": "string"
          },
          "videoId": {
            "description": "Restrict YouTube to one video.",
            "type": "string"
          },
          "limit": {
            "description": "Max items per source (default 25).",
            "type": "number"
          },
          "unansweredOnly": {
            "description": "Only items with no reply from the brand yet.",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reply_to_inbox_item",
      "title": "Reply to anything in the inbox",
      "description": "Answer an inbox item BY ITS COMPOSITE ID — the `id` list_inbox returned, e.g. 'facebook:123_456' or 'google_business:accounts/1/locations/2/reviews/3'. Routes to the right channel for you; you do not need to know which reply tool a platform uses. The reply is PUBLIC and posted as the brand, so show the user the exact wording and get their go-ahead first. X is the one exception and says so: a reply there is a new post, so it goes through post_to_x with replyTo.",
      "group": "publish",
      "section": "TELEGRAM (2026-08-19)",
      "annotations": {
        "title": "Reply to anything in the inbox",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "The composite id from list_inbox — '<source>:<platform id>'. A bare platform id is refused: replying to the wrong network as the brand cannot be taken back."
          },
          "text": {
            "type": "string",
            "description": "The reply, exactly as it should appear publicly."
          }
        },
        "required": [
          "id",
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "hermoso_capabilities",
      "title": "Start here: what Hermoso can do and what it costs",
      "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 flags. Call this FIRST so you generate with valid model ids and known costs. Read-only, free.",
      "group": "publish",
      "section": "TELEGRAM (2026-08-19)",
      "annotations": {
        "title": "Start here: what Hermoso can do and what it costs",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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, the credits this account has spent on the calls listed, and those recent priced calls. Check before kicking off paid generation.",
      "group": "publish",
      "section": "TELEGRAM (2026-08-19)",
      "annotations": {
        "title": "Credit balance",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "publish",
      "section": "TELEGRAM (2026-08-19)",
      "annotations": {
        "title": "Buy credits",
        "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": {
        "title": "Report a bug",
        "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": {
        "title": "Request a feature",
        "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 + the price it is ACTUALLY billed — quote plan.priceUsd per plan.period, not plan.monthlyUsd), 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": {
        "title": "Billing status",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Upgrade plan",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "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": {
        "title": "Set auto-reload",
        "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": {
        "title": "List brands",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Switch brand",
        "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": {
        "title": "Create a brand workspace",
        "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": {
        "title": "Delete a brand workspace",
        "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": {
        "title": "Facebook Page + Instagram insights",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Insights for one Facebook/Instagram post",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Instagram account insights + audience demographics",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List the brand’s Instagram posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "instagram_collaborators",
      "title": "Check who accepted a collab invite",
      "description": "Did the collab invites on an Instagram post get accepted? Reports every collaborator on one of the brand’s OWN Instagram posts with the status Instagram actually holds for it — Accepted (the post is live on their profile too, and its reach now includes their followers), Pending (invited, sitting in their notifications, NOT yet on their profile) or Declined. This is the tool for “did @creator accept yet?”, and the only way to find out: Instagram sends no notification either way. The media id is what post_to_meta returned as `postId`, or any id from list_instagram_media. A post with no collaborators simply reports none. Read-only, 0 credits.",
      "group": "publish",
      "section": "META engagement + insights (organic performance and the comment thread under a post)",
      "annotations": {
        "title": "Check who accepted a collab invite",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "mediaId": {
            "type": "string",
            "description": "the Instagram media id — post_to_meta returns it as postId, list_instagram_media lists the account’s own posts"
          },
          "pageId": {
            "description": "Facebook Page id — omit when only one Page is connected",
            "type": "string"
          }
        },
        "required": [
          "mediaId"
        ],
        "$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. REPLIES: a reply is a comment ON a comment, and its id exists only under its PARENT — it is never returned by the post. Each row says how many replies it has; to read them (and to get the id reply_to_meta_comment / moderate_meta_comment need), call this tool again with postId set to that COMMENT id.",
      "group": "publish",
      "section": "META engagement + insights (organic performance and the comment thread under a post)",
      "annotations": {
        "title": "Read comments on a Meta post",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "post/media id — or a COMMENT id, which returns that comment’s replies"
          },
          "pageId": {
            "description": "Page id — omit when only one Page is connected",
            "type": "string"
          },
          "limit": {
            "description": "how many comments (1–50, default 25)",
            "type": "number"
          },
          "cursor": {
            "description": "the cursor from a previous call. A post with more comments than one page comes back with hasMore + a truncationNote — counts or sentiment drawn from ONE page describe a sample, not the conversation.",
            "type": "string"
          }
        },
        "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": {
        "title": "Reply to a Facebook/Instagram comment",
        "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": {
        "title": "Hide, unhide or delete a Meta comment",
        "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": {
        "title": "List your Threads posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Threads insights",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List replies on a Threads post",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Reply on Threads",
        "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": {
        "title": "Hide or unhide a Threads reply",
        "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 — Threads has no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the post’s real text plus its views, likes, replies and reposts read back from Threads. Show the user that, get an unambiguous yes, then call again with confirm:true — plus confirmName (the post’s exact text as it was reported) once anyone has engaged with it. confirmName exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. Threads allows only 100 deletions per account per rolling 24 hours; threads_publishing_limit says how many are left, and a quota refusal otherwise reads like a broken connection. Note Meta documents nothing about what a delete does to the replies underneath a post, so do not promise the conversation survives. 0 credits.",
      "group": "publish",
      "section": "THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review)",
      "annotations": {
        "title": "Delete a Threads post",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "post id from list_threads_posts"
          },
          "confirm": {
            "description": "REQUIRED true — deletion is permanent; only set it after the user has explicitly agreed",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the post’s exact text as the unconfirmed call reported it — required once it has any likes, replies or reposts",
            "type": "string"
          }
        },
        "required": [
          "postId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "repost_thread",
      "title": "Repost a Threads post",
      "description": "Repost an existing Threads post to the brand’s own Threads profile — the Threads equivalent of a retweet. It is how a brand amplifies a customer’s post, a mention, or one of its own older threads without copying the text, and there was previously no way to do it. Works on any Threads post id: list_threads_posts, list_threads_mentions and search_threads_keyword all return them. This creates a NEW post on the profile, so show the user what is being reposted and get a yes first. Threads publishes NO un-repost endpoint — because a repost returns its own media id, deleting THAT id with delete_thread is the likely undo, but Meta does not document it, so check the profile afterwards rather than promising it worked. 0 credits. Needs Threads connected.",
      "group": "publish",
      "section": "THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review)",
      "annotations": {
        "title": "Repost a Threads post",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "the Threads post id to repost"
          }
        },
        "required": [
          "postId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "threads_publishing_limit",
      "title": "Threads quota remaining",
      "description": "How much of the brand’s Threads quota is left right now — posts (250 per rolling 24 hours), replies (1,000), DELETIONS (100) and location searches (500) — each as used, total and REMAINING. Check it before any bulk operation, and read it the moment Threads starts refusing: a quota refusal is otherwise indistinguishable from a broken connection or a missing permission, and reconnecting cannot fix it. A number comes back null when Threads did not report it, never as 0 — \"none left\" and \"we could not tell\" are different answers. Read-only, 0 credits. Needs Threads connected.",
      "group": "publish",
      "section": "THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review)",
      "annotations": {
        "title": "Threads quota remaining",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$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": {
        "title": "Threads mentions of the brand",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Search Threads by keyword",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List Meta pages & ad accounts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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, video, audio, PDF or document, 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 — passing two is an error, never a silent preference: `url` (ANY public http(s) link — Hermoso fetches it server-side, so nothing crosses this connection and there is no practical size limit; THIS IS THE ONE THAT ALWAYS WORKS, including on the hosted connector), `path` (a local file — ONLY when Hermoso runs on the user's own machine over stdio/CLI; the hosted connector cannot see their disk), or `dataUri` (a base64 data: URI — keep it under ~15MB, since the bytes travel over this connection). 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. (Calling the underlying HTTP route directly? POST /api/upload takes the file's RAW BYTES as the request body with its own content-type — NOT multipart/form-data — or ?url=<link> with no body.) Returns {url, kind, bytes}.",
      "group": "publish",
      "section": "META publishing + ads management (needs a connected Meta account: Settings ▸ Connectors ▸ Meta)",
      "annotations": {
        "title": "Upload a local file → durable public URL",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "url": {
            "description": "a PUBLIC http(s) URL Hermoso fetches server-side (private/internal addresses are refused, and every redirect hop is re-checked). Works on every surface including the hosted connector, and the bytes never cross this connection — prefer this whenever the file is reachable on the web.",
            "type": "string"
          },
          "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,<…>) — bytes travel over this connection, so keep it small",
            "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": {
        "title": "Find a place to tag on Threads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept, which is the reach play behind every creator partnership. 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": {
        "title": "Post to Facebook, Instagram or Threads",
        "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"
          },
          "collaborators": {
            "description": "INSTAGRAM COLLAB — up to 3 Instagram usernames invited to CO-AUTHOR this post. Once one accepts, the post appears on THEIR profile too, with both handles in the header and the likes and comments shared — it is how a brand reaches a creator’s audience without paying for placement, and it is the single most-asked thing a scheduler normally cannot do. Pass handles only (\"hermosoai\"), not profile links; a leading @ is fine. INSTAGRAM ONLY — Facebook and Threads have no collab post at all and are refused BY NAME rather than silently dropping the co-authors — and never on a Story. AN INVITE IS NOT A CO-POST: publishing SENDS a request the other account must accept in their Instagram notifications, and until they do the post is on this brand’s profile ALONE; they may also decline, and Instagram sends no notification either way. So never report the post as live on both accounts — read the invite status back out of the reply, and use instagram_collaborators later to find out whether they accepted.",
            "type": "array",
            "items": {
              "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"
          },
          "trialReel": {
            "description": "INSTAGRAM TRIAL REEL — publish this Reel to NON-FOLLOWERS ONLY at first, so a hook can be tested on a cold audience without spending it on the people who already follow the brand. Instagram then shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs well. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, a Facebook post or a Threads post is REFUSED BY NAME rather than quietly published as an ordinary post — a trial that silently goes to every follower is the exact opposite of what was asked for. Omit it for a normal Reel.",
            "type": "string",
            "enum": [
              "MANUAL",
              "SS_PERFORMANCE"
            ]
          },
          "altText": {
            "description": "ACCESSIBILITY — the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order — that is strictly better on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta’s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY — Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (AI disclosure is separate and automatic — every Instagram post Hermoso publishes is flagged is_ai_generated, which is not a caller setting.)",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            ]
          },
          "pageId": {
            "description": "target Page id (from list_meta_pages); omit = first Page",
            "type": "string"
          },
          "linkAttachment": {
            "description": "THREADS TEXT POSTS ONLY — a full http(s) URL rendered as a clickable link card. This is how a Threads post carries a destination at all; without it a link is just text. Meta allows at most 5 links per post and refuses a link attachment on a post that also carries an image or video, so Hermoso refuses that combination up front rather than letting Meta silently drop it.",
            "type": "string"
          },
          "replyControl": {
            "description": "THREADS ONLY — who may reply. Default is everyone.",
            "type": "string",
            "enum": [
              "everyone",
              "accounts_you_follow",
              "mentioned_only",
              "parent_post_author_only",
              "followers_only"
            ]
          },
          "quotePostId": {
            "description": "THREADS ONLY — the id of a post to QUOTE. A quote post is a distinct post type, not a formatting option.",
            "type": "string"
          },
          "topicTag": {
            "description": "THREADS ONLY — one topic tag for discovery, 1–50 characters. A leading # is stripped for you; Threads refuses \".\" and \"&\".",
            "type": "string"
          },
          "countryCodes": {
            "description": "THREADS ONLY — restrict the post to these ISO 3166-1 alpha-2 countries. ⚠ This is an ALLOWLIST, so the post becomes INVISIBLE in every country not named — it narrows reach, it does not target.",
            "type": "array",
            "items": {
              "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`. INSTAGRAM takes `collaborators` — up to 3 usernames invited to CO-AUTHOR the post, which puts it on their profile too once they accept (the invite is sent when the post fires, and is pending until then). 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": {
        "title": "Schedule a post for later",
        "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",
                "bluesky",
                "telegram"
              ]
            },
            "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"
            }
          },
          "replyControl": {
            "description": "THREADS ONLY — who may reply. Omit for Threads' own default (everyone).",
            "type": "string",
            "enum": [
              "everyone",
              "accounts_you_follow",
              "mentioned_only",
              "parent_post_author_only",
              "followers_only"
            ]
          },
          "quotePostId": {
            "description": "THREADS ONLY — the id of the Threads post this one quotes.",
            "type": "string"
          },
          "linkAttachment": {
            "description": "THREADS ONLY — a full http(s) URL rendered as a link card. This is the ONLY way a Threads post carries a destination, and Threads attaches it to TEXT-ONLY posts (a post with media cannot also carry a card).",
            "type": "string"
          },
          "topicTag": {
            "description": "THREADS ONLY — one topic tag for the post, without the leading #.",
            "type": "string"
          },
          "countryCodes": {
            "description": "THREADS ONLY — two-letter country codes to limit who can see the post. Omit to show it everywhere.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "slideText": {
            "description": "PINTEREST CAROUSEL ONLY — per-slide title / description / link, aligned to slide order. Every other platform takes ONE caption for the whole carousel.",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "title": {
                  "type": "string"
                },
                "description": {
                  "type": "string"
                },
                "link": {
                  "type": "string"
                }
              }
            }
          },
          "altText": {
            "description": "ACCESSIBILITY — the screen-reader description of the attached image. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order — that is strictly better on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. 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. CARRIED BY: X (max 1000, one per media), Pinterest (max 500 — PIN-LEVEL only, since its API has no per-item alt text, so slide 1’s description is used for the whole Pin and the result says the others were not sent), LinkedIn COMPANY PAGES (max 4086, one per slide), INSTAGRAM image posts and image slides (max 1000), FACEBOOK photos and albums, and BLUESKY, whose lexicon makes it REQUIRED on every image. The schedule is REFUSED if the LONGEST description exceeds the tightest of the channels on it, rather than truncated on the way out. NOT CARRIED, and none of these is a refusal — the post still publishes, just undescribed there, and the per-channel result says which: TikTok (its photo post has no alt field at any level), a THREADS CAROUSEL, an INSTAGRAM Reel or video slide, and a LinkedIn PERSONAL-profile post.",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "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"
          },
          "privacyLevel": {
            "description": "TIKTOK — WHICH PRIVACY LEVEL the post goes out at, in TikTok’s own vocabulary. TikTok requires the USER to choose this from the levels their own account allows: call tiktok_creator_info, show them the real options, and pass back the one they picked — never a default and never a guess, which TikTok refuses at init. Omit it and the post falls back to the coarse `visibility` (private → SELF_ONLY, otherwise PUBLIC_TO_EVERYONE), which cannot express MUTUAL_FOLLOW_FRIENDS or FOLLOWER_OF_CREATOR at all. It must agree with the TikTok visibility (SELF_ONLY is the private one) and it is refused on a TikTok DRAFT, which carries no post info.",
            "type": "string",
            "enum": [
              "PUBLIC_TO_EVERYONE",
              "MUTUAL_FOLLOW_FRIENDS",
              "FOLLOWER_OF_CREATOR",
              "SELF_ONLY"
            ]
          },
          "commercialContent": {
            "description": "TIKTOK — the COMMERCIAL CONTENT DISCLOSURE toggle: true when the post promotes a brand, product or service at all. TikTok requires at least one of yourBrand / brandedContent alongside it, and a post declaring itself commercial without naming which kind is refused. Setting either disclosure already implies this, so it is only needed to be explicit.",
            "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"
          },
          "collaborators": {
            "description": "INSTAGRAM — a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only (\"hermosoai\"); a leading @ is fine. Instagram must be one of the `channels` — asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "trialReel": {
            "description": "INSTAGRAM TRIAL REEL — publish this Reel to NON-FOLLOWERS ONLY at first, so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post — a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel.",
            "type": "string",
            "enum": [
              "MANUAL",
              "SS_PERFORMANCE"
            ]
          },
          "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"
          },
          "chatId": {
            "description": "TELEGRAM — REQUIRED whenever telegram is a channel: WHICH chat, group or channel the bot posts to. A public channel’s @username (@hermosoai) or the numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists a bot’s chats — so scheduling telegram without one is refused up front. list_telegram_chats finds ids for chats that have messaged the bot in the last 24 hours.",
            "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": {
        "title": "List scheduled and past posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Change a scheduled post",
        "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",
                "bluesky",
                "telegram"
              ]
            }
          },
          "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"
            }
          },
          "replyControl": {
            "description": "THREADS ONLY — who may reply.",
            "type": "string",
            "enum": [
              "everyone",
              "accounts_you_follow",
              "mentioned_only",
              "parent_post_author_only",
              "followers_only"
            ]
          },
          "quotePostId": {
            "description": "THREADS ONLY — the id of the Threads post this one quotes.",
            "type": "string"
          },
          "linkAttachment": {
            "description": "THREADS ONLY — a full http(s) URL rendered as a link card on a TEXT-ONLY post. The only way a Threads post carries a destination.",
            "type": "string"
          },
          "topicTag": {
            "description": "THREADS ONLY — one topic tag, without the leading #.",
            "type": "string"
          },
          "countryCodes": {
            "description": "THREADS ONLY — two-letter country codes limiting who can see the post.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "slideText": {
            "description": "PINTEREST CAROUSEL ONLY — per-slide title / description / link, aligned to slide order.",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "title": {
                  "type": "string"
                },
                "description": {
                  "type": "string"
                },
                "link": {
                  "type": "string"
                }
              }
            }
          },
          "privacyLevel": {
            "description": "TIKTOK — WHICH PRIVACY LEVEL the post goes out at, in TikTok’s own vocabulary. TikTok requires the USER to choose this from the levels their own account allows: call tiktok_creator_info, show them the real options, and pass back the one they picked — never a default and never a guess, which TikTok refuses at init. Omit it and the post falls back to the coarse `visibility` (private → SELF_ONLY, otherwise PUBLIC_TO_EVERYONE), which cannot express MUTUAL_FOLLOW_FRIENDS or FOLLOWER_OF_CREATOR at all. It must agree with the TikTok visibility (SELF_ONLY is the private one) and it is refused on a TikTok DRAFT, which carries no post info.",
            "type": "string",
            "enum": [
              "PUBLIC_TO_EVERYONE",
              "MUTUAL_FOLLOW_FRIENDS",
              "FOLLOWER_OF_CREATOR",
              "SELF_ONLY"
            ]
          },
          "altText": {
            "description": "ACCESSIBILITY — replace the screen-reader description(s). A STRING describes every slide; an ARRAY describes them one at a time in slide order and REPLACES the whole list. \"\" clears it.",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "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"
          },
          "commercialContent": {
            "description": "TIKTOK — the COMMERCIAL CONTENT DISCLOSURE toggle: true when this post promotes a brand, product or service at all. TikTok requires at least one of yourBrand / brandedContent once it is on, and refuses a post that declares itself commercial without naming which kind. Either disclosure already implies it.",
            "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"
          },
          "collaborators": {
            "description": "INSTAGRAM — replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet — an invite already sent cannot be withdrawn from here.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "trialReel": {
            "description": "INSTAGRAM — replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit \"\" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued — a Reel already published cannot be converted into a trial.",
            "type": "string",
            "enum": [
              "MANUAL",
              "SS_PERFORMANCE",
              ""
            ]
          },
          "boardId": {
            "description": "PINTEREST — move the Pin to a different board (list_pinterest_boards)",
            "type": "string"
          },
          "chatId": {
            "description": "TELEGRAM — send it to a different chat, group or channel (@username or numeric id). It can be changed but never cleared: telegram cannot publish without one.",
            "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": {
        "title": "Cancel a scheduled post",
        "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": "retry_scheduled",
      "title": "Retry a failed scheduled post",
      "description": "Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause — and you can fix it IN THIS CALL: pass `boardId`, `pageId`, `linkedinOrganizationId`, `locationId`, `message` or `captions` to correct the value that failed, and the corrected post is re-validated exactly like a fresh schedule. That matters because the commonest cause is a field, not an outage: a Pin aimed at the wrong board fails identically however many times it is re-sent. Anything you do not name is copied from the original. To send the same thing again ON PURPOSE, use duplicate_scheduled.",
      "group": "publish",
      "section": "RETRY + DUPLICATE (2026-08-05)",
      "annotations": {
        "title": "Retry a failed scheduled post",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the scheduled post id from list_scheduled"
          },
          "channels": {
            "description": "retry only these channels (default: every channel that did not publish)",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "facebook",
                "instagram",
                "threads",
                "tiktok",
                "youtube",
                "linkedin",
                "x",
                "pinterest",
                "google_business",
                "bluesky",
                "telegram"
              ]
            }
          },
          "at": {
            "description": "hold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post.",
            "type": "string"
          },
          "allowDuplicate": {
            "description": "ONLY for a channel you have checked by hand and confirmed the post is genuinely NOT there. It bypasses the double-post protection and can publish a second public copy, so never set it to work around a refusal you have not investigated.",
            "type": "boolean"
          },
          "boardId": {
            "description": "CORRECT THE BOARD on retry — the Pinterest board the Pin goes on, from list_pinterest_boards. A Pin aimed at a board Pinterest refuses fails the same way on every retry until this is changed.",
            "type": "string"
          },
          "chatId": {
            "description": "CORRECT THE TELEGRAM DESTINATION on retry — the @username or numeric id of the chat. A post aimed at a chat the bot is not in fails every time it is retried until this changes.",
            "type": "string"
          },
          "pageId": {
            "description": "CORRECT THE PAGE on retry — which connected Facebook Page (and its linked Instagram/Threads) publishes, from list_meta_pages.",
            "type": "string"
          },
          "linkedinOrganizationId": {
            "description": "CORRECT THE LINKEDIN AUTHOR on retry — the company Page id from list_linkedin_pages. Set it to an empty string to fall back to the personal profile.",
            "type": "string"
          },
          "locationId": {
            "description": "CORRECT THE LISTING on retry — which Google Business Profile location, e.g. \"locations/123\" from list_business_locations.",
            "type": "string"
          },
          "message": {
            "description": "CORRECT THE CAPTION on retry — use this when the original was refused for length or content. Anything not named here is copied from the original post.",
            "type": "string"
          },
          "captions": {
            "description": "CORRECT ONE CHANNEL’S CAPTION on retry, e.g. { \"x\": \"...\" } when only that channel refused the text.",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {
              "type": "string"
            }
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "duplicate_scheduled",
      "title": "Duplicate a scheduled post",
      "description": "Copy an existing scheduled or already-published post into a NEW queued post — the way to run a creative again, reuse a post that worked as the starting point for the next one, or re-send something after it went out. It copies the caption, media, per-channel captions, title, description, tags and the target board / Page / company Page / listing, and ANY of those can be overridden in the same call. Give a new time in `at`, or useQueue:true to drop it into the brand’s next free posting slot. The copy is INDEPENDENT — editing or cancelling it never touches the original — and it is a genuinely new post rather than a re-send, so it publishes even where the original already did. To re-fire only the channels that FAILED, use retry_scheduled instead.",
      "group": "publish",
      "section": "RETRY + DUPLICATE (2026-08-05)",
      "annotations": {
        "title": "Duplicate a scheduled post",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the post to copy, from list_scheduled"
          },
          "at": {
            "description": "when the copy goes out — ISO timestamp or epoch milliseconds (default: an hour from now)",
            "type": "string"
          },
          "useQueue": {
            "description": "instead of naming a time, take the brand’s next free posting slot",
            "type": "boolean"
          },
          "timezone": {
            "description": "IANA zone for the queue, e.g. \"America/New_York\"",
            "type": "string"
          },
          "channels": {
            "description": "post the copy to these channels instead of the original’s",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "facebook",
                "instagram",
                "threads",
                "tiktok",
                "youtube",
                "linkedin",
                "x",
                "pinterest",
                "google_business",
                "bluesky",
                "telegram"
              ]
            }
          },
          "message": {
            "description": "a different caption for the copy",
            "type": "string"
          },
          "captions": {
            "description": "per-channel caption overrides for the copy",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {
              "type": "string"
            }
          },
          "imageUrl": {
            "type": "string"
          },
          "videoUrl": {
            "type": "string"
          },
          "imageUrls": {
            "description": "CAROUSEL — an ORDERED list of image URLs published as ONE swipeable post",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "title": {
            "type": "string"
          },
          "link": {
            "type": "string"
          },
          "boardId": {
            "description": "PINTEREST — the board for the copy (list_pinterest_boards)",
            "type": "string"
          },
          "chatId": {
            "description": "TELEGRAM — which chat, group or channel the copy goes to (@username or numeric id)",
            "type": "string"
          },
          "linkedinOrganizationId": {
            "description": "LINKEDIN — publish the copy as this company Page (list_linkedin_pages)",
            "type": "string"
          },
          "pageId": {
            "description": "FACEBOOK / INSTAGRAM / THREADS — which connected Page (list_meta_pages)",
            "type": "string"
          },
          "locationId": {
            "description": "GOOGLE BUSINESS — which listing (list_business_locations)",
            "type": "string"
          },
          "visibility": {
            "type": "string",
            "enum": [
              "public",
              "unlisted",
              "private",
              "draft"
            ]
          }
        },
        "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": {
        "title": "Posting refill status",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Configure the posting refill",
        "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",
                "google_business",
                "bluesky",
                "telegram"
              ]
            }
          },
          "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"
          },
          "chatId": {
            "description": "TELEGRAM — which chat, group or channel posts go to (@username or numeric id). Without one, telegram is skipped: there is no default chat and posting to the wrong one is a public mistake.",
            "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": {
        "title": "Run the posting refill",
        "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": {
        "title": "Publish to LinkedIn",
        "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. Each brand also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet, and a promoted post needs a post id, so publish here first and promote that post. 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": {
        "title": "Publish a post to X (Twitter)",
        "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"
          },
          "mediaUrls": {
            "description": "UP TO FOUR Hermoso-hosted media attached to ONE post — X’s own schema caps media_ids at 4. X renders them as a GRID: every image visible at once, nothing to swipe to. That is NOT a carousel, and a numbered \"1/6 · SWIPE\" slide deck must still not be sent here — it would publish as a grid and the \"swipe\" instruction would make no sense. Order decides the layout. On a thread the media rides the FIRST post; give each later part its own post to attach more. Mutually exclusive with mediaUrl, and more than 4 is refused before anything is uploaded.",
            "type": "array",
            "items": {
              "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 one metadata write PER media. With SEVERAL media, pass an ARRAY aligned to their order — X attaches alt text per media id, and a single string describes only the FIRST one (X renders up to four media as a GRID, not a carousel, so one sentence would be wrong for the other three).",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "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": {
        "title": "Delete a post on X",
        "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": "THE X ANALYTICS TOOL THAT WORKS — impressions, likes, reposts, replies, quotes and bookmarks for any post, PLUS the advertiser numbers (link clicks, profile clicks, engagements) for YOUR OWN posts published in the last 30 days. X serves those private metrics on this same lookup with the user-context connection you already have; that is X's own design, not a workaround. Prefer this over x_post_insights, whose endpoint family X has retired. If a post is deleted, protected or suspended, X answers with no data at all and this says so — that is MISSING DATA, never zero engagement, and must never be reported as a measured zero. 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": {
        "title": "Read performance of a post on X",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the numeric X post id — the last part of the post URL"
          },
          "publishedAt": {
            "description": "epoch ms the post went out, if known — lets the private owned-post metrics be requested only inside X's 30-day window instead of costing a refused call",
            "type": "number"
          }
        },
        "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": {
        "title": "Advertiser analytics for your own posts on X",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Advertiser analytics for your own X posts, over any date range",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Read who is mentioning you on X",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Post to a subreddit",
        "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": {
        "title": "How a Reddit post did",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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_reddit_posts",
      "title": "The connected Reddit account’s own posts",
      "description": "The connected Reddit account’s OWN submissions — id, title, subreddit, score, comment count, whether the subreddit removed it, and whether its body can be edited at all. THIS IS WHERE THE postId EVERY OTHER REDDIT TOOL NEEDS COMES FROM: post_to_reddit returns an id only at the instant it publishes, so an agent that did not itself just post had no way to name a post and had to ask the user for a link. Read-only, 0 credits. Needs Reddit connected.",
      "group": "publish",
      "section": "OPERATING A REDDIT POST AFTER IT IS SUBMITTED (2026-08-05). We could submit and read the score, and nothing",
      "annotations": {
        "title": "The connected Reddit account’s own posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "1–100, default 25",
            "type": "number"
          },
          "sort": {
            "description": "default new",
            "type": "string",
            "enum": [
              "new",
              "hot",
              "top",
              "controversial"
            ]
          },
          "cursor": {
            "description": "the cursor a previous call returned",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "edit_reddit_post",
      "title": "Edit a Reddit text post’s body",
      "description": "Rewrite the BODY of one of the connected account’s Reddit TEXT posts — the fix for a dead link, a wrong price or a correction the comments are asking for. THREE THINGS REDDIT DOES NOT ALLOW, and you must not offer them: (1) a post’s TITLE can never be changed by any API — `title` exists only on Reddit’s submit endpoint, so a published title is frozen for every client, not just this one; (2) a LINK post cannot be edited at all — Reddit documents this endpoint as editing \"the body text of a comment or self-post\" and refuses a link post; (3) a post that has already been deleted cannot be edited. In each case the only remedy is to delete and submit again, which loses the score, the age and the whole comment thread — say that plainly instead of implying an edit is possible. The result is READ BACK from Reddit, so an accepted edit that did not apply is reported as NOT confirmed rather than narrated as done. 0 credits. Needs Reddit connected.",
      "group": "publish",
      "section": "OPERATING A REDDIT POST AFTER IT IS SUBMITTED (2026-08-05). We could submit and read the score, and nothing",
      "annotations": {
        "title": "Edit a Reddit text post’s body",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "the post id, its t3_… fullname, or the permalink (list_reddit_posts returns them)"
          },
          "text": {
            "type": "string",
            "description": "the new body markdown — this REPLACES the existing body"
          }
        },
        "required": [
          "postId",
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_reddit_post",
      "title": "Delete a Reddit post",
      "description": "PERMANENTLY delete one of the connected account’s Reddit posts. Reddit has no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the post’s real title, subreddit, score and comment count read back from Reddit. Show the user that, get an unambiguous yes, then call again with confirm:true — plus confirmName (its exact title) once it has comments or a real score, because confirming that you meant to delete SOMETHING does not prove you aimed at the right post. TELL THE USER THIS BEFORE THEY AGREE: deleting a Reddit post does NOT delete the comments under it — Reddit keeps the thread and shows the post as [deleted], so the conversation stays public with only their side removed. Reddit’s delete endpoint returns an empty success for every call, including one aimed at a post the account did not write, so the verdict here comes from re-reading the post afterwards and never from that response. 0 credits. Needs Reddit connected.",
      "group": "publish",
      "section": "OPERATING A REDDIT POST AFTER IT IS SUBMITTED (2026-08-05). We could submit and read the score, and nothing",
      "annotations": {
        "title": "Delete a Reddit post",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "the post id, its t3_… fullname, or the permalink"
          },
          "confirm": {
            "description": "REQUIRED true — deletion is permanent",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the post’s EXACT title as the unconfirmed call reported it — required once it has comments or a real score",
            "type": "string"
          }
        },
        "required": [
          "postId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_reddit_comments",
      "title": "Comments on a Reddit post",
      "description": "Read the comments under one of the connected account’s Reddit posts — author, text, score, whether it is the poster’s own reply, and when. On Reddit the thread IS the value of a post, and this is where the questions, objections and exact customer wording live: the same raw material for ad copy that list_meta_comments and list_youtube_comments give you on the other channels, from the audience that argues back hardest. Each row carries the fullname to pass to reply_to_reddit_comment. Read-only, 0 credits. Needs Reddit connected.",
      "group": "publish",
      "section": "OPERATING A REDDIT POST AFTER IT IS SUBMITTED (2026-08-05). We could submit and read the score, and nothing",
      "annotations": {
        "title": "Comments on a Reddit post",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "the post id, its t3_… fullname, or the permalink"
          },
          "limit": {
            "description": "1–100, default 25",
            "type": "number"
          },
          "sort": {
            "description": "default top",
            "type": "string",
            "enum": [
              "top",
              "new",
              "confidence",
              "controversial",
              "old",
              "qa"
            ]
          }
        },
        "required": [
          "postId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reply_to_reddit_comment",
      "title": "Reply on Reddit",
      "description": "Reply on Reddit as the connected account — either a top-level comment on a post, or a reply to somebody’s comment. This publishes PUBLICLY under their username immediately, so show the user the exact wording and get an explicit yes BEFORE calling. Reddit judges brands harder on how they behave in comments than on what they post: answer the actual question, in plain language, and do not paste marketing copy — an account that does gets buried and can get the whole domain banned from the subreddit. parentId is a FULLNAME, not a bare id: t3_… replies to a POST (a new top-level comment), t1_… replies to a COMMENT. list_reddit_comments returns the right one on every row. 0 credits. Needs Reddit connected.",
      "group": "publish",
      "section": "OPERATING A REDDIT POST AFTER IT IS SUBMITTED (2026-08-05). We could submit and read the score, and nothing",
      "annotations": {
        "title": "Reply on Reddit",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "parentId": {
            "type": "string",
            "description": "t3_… fullname of a post (top-level comment) or t1_… fullname of a comment (a reply to it)"
          },
          "text": {
            "type": "string",
            "description": "the reply markdown"
          }
        },
        "required": [
          "parentId",
          "text"
        ],
        "$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": {
        "title": "List Pinterest boards",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Pinterest organic analytics",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Create a Pinterest board",
        "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": {
        "title": "Create a Pin",
        "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. PIN-LEVEL: Pinterest’s API has no per-item alt text at all, so on a CAROUSEL the FIRST description is used for the whole Pin and the reply states that the others were not sent.",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            ]
          },
          "slideText": {
            "description": "PINTEREST CAROUSEL ONLY — per-slide title, description and destination LINK, one object per slide in slide order. This is the ONLY genuine per-slide caption on any channel Hermoso publishes to: slide 4 can send people to the product ON slide 4, where every other platform gives a carousel one shared caption. Omit any field to leave it unset; the Pin’s own title/description/link still describe the Pin as a whole. Pinterest publishes no length limit on these, so nothing is truncated. More entries than slides is refused rather than dropped.",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "title": {
                  "type": "string"
                },
                "description": {
                  "type": "string"
                },
                "link": {
                  "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": "list_pinterest_pins",
      "title": "List Pins on a Pinterest board",
      "description": "The Pins on one of the account’s boards — or, with no boardId, the account’s own Pins across all of them. Each row carries the Pin id, title, description, destination link, alt text, board, creation date, and whether it HAS BEEN PROMOTED in an ad. THIS IS WHERE THE pinId EVERY OTHER PIN TOOL NEEDS COMES FROM: post_to_pinterest returns an id only at the instant it pins, so an agent that did not itself just pin had no way to name a Pin. Prefer passing a boardId — Pinterest’s own spec warns the account-wide listing has known timeouts. Read-only, 0 credits. Needs Pinterest connected.",
      "group": "publish",
      "section": "OPERATING A PIN AND A BOARD AFTER THEY EXIST (2026-08-05). We could create both and then never touch either",
      "annotations": {
        "title": "List Pins on a Pinterest board",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "boardId": {
            "description": "numeric board id from list_pinterest_boards — omit for the account’s own Pins across all boards",
            "type": "string"
          },
          "limit": {
            "description": "1–100, default 25",
            "type": "number"
          },
          "cursor": {
            "description": "the cursor a previous call returned",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_pinterest_pin",
      "title": "Edit a published Pin",
      "description": "Edit a published Pin — its title, description, destination link, alt text, or which board it sits on. Only send the fields that should change. TWO LIMITS TO STATE BEFORE OFFERING THIS. (1) Pinterest marks its Update Pin endpoint \"currently in beta and not available to all apps\" in its own API description, so it may be refused outright whatever the account’s scopes or access tier — reconnecting cannot change that. If it is refused, save_pinterest_pin gets the Pin onto another board (generally available) and changing the wording means deleting and re-pinning. (2) A published Pin’s IMAGE or VIDEO can never be changed by anyone: Pinterest’s update model has no media field at all, so swapping the creative means delete and re-pin, which loses the Pin’s accumulated saves. The values reported back are what Pinterest STORED, not what was sent. 0 credits. Needs Pinterest connected.",
      "group": "publish",
      "section": "OPERATING A PIN AND A BOARD AFTER THEY EXIST (2026-08-05). We could create both and then never touch either",
      "annotations": {
        "title": "Edit a published Pin",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "pinId": {
            "type": "string",
            "description": "numeric Pin id from list_pinterest_pins"
          },
          "title": {
            "description": "max 100 characters",
            "type": "string"
          },
          "description": {
            "description": "max 800 characters — the text Pinterest search reads",
            "type": "string"
          },
          "link": {
            "description": "destination URL, max 2048",
            "type": "string"
          },
          "altText": {
            "description": "accessibility alt text, max 500",
            "type": "string"
          },
          "boardId": {
            "description": "move the Pin to this board",
            "type": "string"
          },
          "boardSectionId": {
            "description": "section within the board",
            "type": "string"
          }
        },
        "required": [
          "pinId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "save_pinterest_pin",
      "title": "Save a Pin to another board",
      "description": "Save an existing Pin onto another of the account’s boards. This is the GENERALLY AVAILABLE way to get a Pin onto the right board — unlike update_pinterest_pin, which Pinterest keeps in a limited beta — so reach for it first when a Pin is on the wrong board. It COPIES rather than moves: Pinterest’s save endpoint creates a new Pin and the original stays where it is, so delete that one with delete_pinterest_pin if it should not be in two places. Let the USER pick the destination board (list_pinterest_boards) — a Pin on the wrong board is a public mistake. 0 credits. Needs Pinterest connected.",
      "group": "publish",
      "section": "OPERATING A PIN AND A BOARD AFTER THEY EXIST (2026-08-05). We could create both and then never touch either",
      "annotations": {
        "title": "Save a Pin to another board",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "pinId": {
            "type": "string",
            "description": "numeric Pin id"
          },
          "boardId": {
            "type": "string",
            "description": "the board to save it to, from list_pinterest_boards — the user picks, never guess"
          },
          "boardSectionId": {
            "type": "string"
          }
        },
        "required": [
          "pinId",
          "boardId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_pinterest_pin",
      "title": "Delete a Pin",
      "description": "PERMANENTLY delete a Pin. Pinterest has no undelete and no archive for one. Call it WITHOUT confirm first: nothing is deleted, and it answers with the Pin’s real title, its lifetime saves and impressions, and whether it HAS BEEN PROMOTED in an ad — all read back from Pinterest. Show the user that, get an unambiguous yes, then call again with confirm:true plus confirmName (its exact title) once it has saves or has been promoted, because confirming that you meant to delete SOMETHING does not prove you aimed at the right Pin. DELETING A PIN THAT AN AD PROMOTES pulls the creative out from under that ad, so check the promoted flag before agreeing. The verdict is read back from Pinterest, never taken from its 2xx. 0 credits. Needs Pinterest connected.",
      "group": "publish",
      "section": "OPERATING A PIN AND A BOARD AFTER THEY EXIST (2026-08-05). We could create both and then never touch either",
      "annotations": {
        "title": "Delete a Pin",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "pinId": {
            "type": "string",
            "description": "numeric Pin id from list_pinterest_pins"
          },
          "confirm": {
            "description": "REQUIRED true — deletion is permanent",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the Pin’s EXACT title as the unconfirmed call reported it — required once it has saves or has been promoted",
            "type": "string"
          }
        },
        "required": [
          "pinId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_pinterest_board",
      "title": "Rename or re-privacy a Pinterest board",
      "description": "Rename a board, rewrite its description, or change its privacy. ⚠️ SETTING A BOARD TO SECRET HIDES EVERY PIN ON IT from everyone but this account — nothing errors and nothing is deleted, the Pins simply stop being public, which is the Pinterest flavour of a post that looks published and is not. Say so and get a yes before doing it; it IS reversible (set PUBLIC again), and the read-back reports how many Pins were hidden. Pinterest accepts only PUBLIC or SECRET on an update: PROTECTED can be chosen when a board is created and can never be set afterwards, so that is refused by name rather than sent and rejected. 0 credits. Needs Pinterest connected.",
      "group": "publish",
      "section": "OPERATING A PIN AND A BOARD AFTER THEY EXIST (2026-08-05). We could create both and then never touch either",
      "annotations": {
        "title": "Rename or re-privacy a Pinterest board",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "boardId": {
            "type": "string",
            "description": "numeric board id from list_pinterest_boards"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "description": "max 500 characters",
            "type": "string"
          },
          "privacy": {
            "description": "SECRET hides every Pin on the board from everyone but this account",
            "type": "string",
            "enum": [
              "PUBLIC",
              "SECRET"
            ]
          }
        },
        "required": [
          "boardId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_pinterest_board",
      "title": "Delete a Pinterest board",
      "description": "PERMANENTLY delete a board AND EVERY PIN ON IT. This is the heaviest thing that can be done to a Pinterest account and there is no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the board’s real name, how many Pins are on it, how many people FOLLOW it and how many collaborators lose access — all read back from Pinterest. Show the user that, then call again with confirm:true plus confirmName (its exact name) and confirmChildren (the Pin count it reported); those echoes exist because a caller who has not looked at the board cannot supply them, and confirming intent alone does not prove aim. IF THEY ONLY WANT IT OUT OF PUBLIC VIEW: do NOT reach for update_pinterest_board(privacy:\"SECRET\") — making a board secret is a SEPARATE Pinterest permission (boards:write_secret) that Hermoso does not request, so that call is refused for every user and steering someone onto it turns a survivable delete into a dead end. The reversible option lives in Pinterest’s own app. The unconfirmed refusal says which of the two applies to this connection, read off the live scope set — relay it verbatim rather than guessing. 0 credits. Needs Pinterest connected.",
      "group": "publish",
      "section": "OPERATING A PIN AND A BOARD AFTER THEY EXIST (2026-08-05). We could create both and then never touch either",
      "annotations": {
        "title": "Delete a Pinterest board",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "boardId": {
            "type": "string",
            "description": "numeric board id from list_pinterest_boards"
          },
          "confirm": {
            "description": "REQUIRED true — the board and its Pins are gone for good",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the board’s EXACT name as the unconfirmed call reported it",
            "type": "string"
          },
          "confirmChildren": {
            "description": "the number of Pins the unconfirmed call reported on the board",
            "type": "number"
          }
        },
        "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": {
        "title": "Pinterest deep (async) ad report",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Pinterest ads by audience segment",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Pinterest audience insights",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "search_pinterest_ads_targeting",
      "title": "Find Pinterest ad targeting options",
      "description": "THE IDS AN AD GROUP’S targetingSpec NEEDS. create_pinterest_ads_campaign and create_pinterest_ads_ad_group accept interests, locales, locations, age buckets and keywords — this is where those values come from, and guessing one is the worst option available: Pinterest ACCEPTS a well-formed but wrong interest id and the campaign then quietly targets the wrong people. Pass targetingType (INTEREST, GEO, LOCATION, LOCALE, AGE_BUCKET, GENDER, APPTYPE, KEYWORD, AUDIENCE_INCLUDE, AUDIENCE_EXCLUDE) and optionally `query` to narrow the list. INTERESTS ARE A TREE: pass interestId instead to drill into one and get its children, which is how you get from \"Food\" to something specific enough to target. Pinterest publishes no search parameter of its own, so `query` filters the full list HERE — the note says so, because a filter we applied is not a filter the vendor applied. Read-only, 0 credits, no new permission (it uses ads:read, already granted).",
      "group": "ads",
      "section": "PINTEREST DEEP ANALYTICS (2026-08-05)",
      "annotations": {
        "title": "Find Pinterest ad targeting options",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "targetingType": {
            "description": "which catalog to list — required unless you pass interestId",
            "type": "string",
            "enum": [
              "APPTYPE",
              "GENDER",
              "LOCALE",
              "AGE_BUCKET",
              "LOCATION",
              "GEO",
              "INTEREST",
              "KEYWORD",
              "AUDIENCE_INCLUDE",
              "AUDIENCE_EXCLUDE"
            ]
          },
          "query": {
            "description": "narrow the list to options mentioning this text (filtered here, not by Pinterest)",
            "type": "string"
          },
          "interestId": {
            "description": "drill INTO one interest and return its child interests — how you walk the interest tree",
            "type": "string"
          },
          "limit": {
            "description": "1–500, default 100",
            "type": "number"
          }
        },
        "$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": {
        "title": "List Google business listings",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Post to Google Business Profile",
        "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": {
        "title": "List Google Business Profile Posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Delete a Google Business Profile Post",
        "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": {
        "title": "Read the reviews on a Google Business listing",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Reply to (or remove a reply from) a Google review",
        "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": {
        "title": "Read the Q&A on a Google Business listing",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Answer a question on a Google Business listing",
        "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": {
        "title": "Google Business Profile performance",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Read a Google Business Profile listing",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Update a Google Business Profile listing",
        "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": {
        "title": "Google Business Profile account for a listing",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 \"People & Blogs\" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. 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": {
        "title": "Post a video to YouTube",
        "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"
            ]
          },
          "categoryId": {
            "description": "YouTube category id, NUMERIC — default \"22\" (People & Blogs), which is wrong for most ads. 1 Film & Animation · 2 Autos & Vehicles · 10 Music · 15 Pets & Animals · 17 Sports · 19 Travel & Events · 20 Gaming · 22 People & Blogs · 23 Comedy · 24 Entertainment · 25 News & Politics · 26 Howto & Style · 27 Education · 28 Science & Technology · 29 Nonprofits & Activism. The assignable set is region-specific, so the value is forwarded as given and YouTube has the last word.",
            "type": "string"
          },
          "publishAt": {
            "description": "SCHEDULE the publish — ISO 8601, e.g. \"2026-09-01T15:00:00Z\", and it must be in the future. YouTube only allows this on a PRIVATE video and makes it PUBLIC at that moment, so pass privacy:\"private\" (or leave privacy unset) — asking for a scheduled \"unlisted\" or \"public\" post is refused rather than half-honoured.",
            "type": "string"
          },
          "notifySubscribers": {
            "description": "THE DEFAULT FOLLOWS PRIVACY. privacy:\"public\" NOTIFIES the channel's subscribers — that is YouTube's own default and normally what someone publishing publicly wants. privacy:\"unlisted\" and \"private\" do NOT: the video is not on the channel, so announcing it is nonsense, and a blast to somebody's whole subscriber list cannot be undone. A scheduled publish (publishAt) is PRIVATE at upload, so it does not notify either — pass true to announce one. An explicit value ALWAYS wins in both directions: true announces an unlisted/private upload, false publishes publicly and quietly. The reply reports which way it went and why.",
            "type": "boolean"
          }
        },
        "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": {
        "title": "Get the connected YouTube channel",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List the brand’s own YouTube uploads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Performance of one of your YouTube videos",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "YouTube analytics broken down by dimension",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "YouTube bulk report (thumbnail CTR, cards, end screens)",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List YouTube bulk reporting jobs",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Delete a YouTube bulk reporting job",
        "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": {
        "title": "Update a YouTube video’s title, description, tags or privacy",
        "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": {
        "title": "Delete a video from the connected YouTube channel",
        "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": "update_youtube_channel",
      "title": "Apply the brand to the YouTube channel itself",
      "description": "APPLY THE BRAND TO THE CHANNEL ITSELF — banner art, description, keywords, country and the trailer non-subscribers see. Every other YouTube tool brands the videos; this brands the page they sit on. Under the hood channels.update is a PUT, so the CURRENT settings are read and merged first — otherwise setting a description would silently wipe the channel's keywords, country and trailer. AND YOUTUBE SILENTLY IGNORES SOME FIELDS, channel title above all (usually only changeable in YouTube Studio): the result DIFFS what YouTube actually stored against what was asked for and reports anything that did not stick in `notApplied`. DO NOT REPORT THOSE AS CHANGED — a 200 is what YouTube accepted, not what it stored. A banner must be a Hermoso render (jpeg or png, under 6MB); YouTube re-crops it per device, so the safe area is the middle 1235x338 of a 2048x1152 image. Public and immediate — show the user what is going on the channel first. 0 credits. Needs YouTube connected.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL BRANDING (2026-08-19)",
      "annotations": {
        "title": "Apply the brand to the YouTube channel itself",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "description": {
            "description": "the channel description — the About text",
            "type": "string"
          },
          "keywords": {
            "description": "channel keywords, COMMA-SEPARATED (Google's wire format is one string, not a list)",
            "type": "string"
          },
          "country": {
            "description": "two-letter country code for the channel",
            "type": "string"
          },
          "defaultLanguage": {
            "description": "the channel's default language",
            "type": "string"
          },
          "unsubscribedTrailer": {
            "description": "video id of the trailer shown to people who are not subscribed",
            "type": "string"
          },
          "title": {
            "description": "the channel title — YouTube often accepts and ignores this; the result says whether it stuck",
            "type": "string"
          },
          "bannerImageUrl": {
            "description": "any public https image URL to upload as the channel banner — a Hermoso render, or ANY file of your own brought in with upload_file",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_youtube_watermark",
      "title": "Set or remove the YouTube branding watermark",
      "description": "Set (or remove) the BRANDING WATERMARK — the small subscribe badge overlaid on EVERY video on the channel, including ones uploaded later. One generated asset brands the whole channel at once, which is why it is worth doing before a batch of uploads rather than after. YouTube wants a SQUARE image, at least 150x150, under 10MB, and it renders SMALL: a full logo lockup with text will not read at that size. By default it shows for the whole video; timingType offsetFromStart/offsetFromEnd with offsetMs and durationMs narrows it. THE DATA API PUBLISHES NO WAY TO READ A WATERMARK BACK — there is only set and unset — so this reports 'accepted', never 'confirmed', and says so rather than claiming a verification it did not get. 0 credits. Needs YouTube connected.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL BRANDING (2026-08-19)",
      "annotations": {
        "title": "Set or remove the YouTube branding watermark",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "action": {
            "description": "defaults to 'set' when an imageUrl is given",
            "type": "string",
            "enum": [
              "set",
              "unset"
            ]
          },
          "imageUrl": {
            "description": "any public https image URL — a Hermoso render, or ANY file of your own brought in with upload_file. Square, at least 150x150",
            "type": "string"
          },
          "timingType": {
            "description": "leave off for a watermark that shows for the whole video",
            "type": "string",
            "enum": [
              "offsetFromStart",
              "offsetFromEnd"
            ]
          },
          "offsetMs": {
            "description": "when the watermark appears, relative to timingType",
            "type": "number"
          },
          "durationMs": {
            "description": "how long it stays on screen",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_youtube_video_stats",
      "title": "Batch YouTube video stats",
      "description": "Views, likes and comment counts for up to 50 YouTube videos IN ONE CALL, which is how to answer \"how are my last twenty uploads doing\" without one youtube_video_insights per video. Pass videoIds from list_youtube_videos. IT CARRIES NO TITLES, and that is the resource rather than a bug: VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos when a name is needed. YouTube calls this endpoint \"intentionally not atomic\", so a short answer is normal: a video that is private, deleted, or not visible to the connected account simply does not come back, and this tool names the missing ids. Never report a missing id as zero views. Read-only, free.",
      "group": "publish",
      "section": "YOUTUBE BATCH STATS + TRAINABILITY (2026-08-19, fourth wave)",
      "annotations": {
        "title": "Batch YouTube video stats",
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "up to 50 video ids, from list_youtube_videos"
          },
          "part": {
            "description": "defaults to snippet + statistics. An unknown part 400s the whole call, so it is refused here",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "snippet",
                "statistics",
                "contentDetails"
              ]
            }
          }
        },
        "required": [
          "videoIds"
        ],
        "$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 BATCH STATS + TRAINABILITY (2026-08-19, fourth wave)",
      "annotations": {
        "title": "Set the custom thumbnail on a YouTube video",
        "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 BATCH STATS + TRAINABILITY (2026-08-19, fourth wave)",
      "annotations": {
        "title": "Read comments on one of your YouTube videos",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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 BATCH STATS + TRAINABILITY (2026-08-19, fourth wave)",
      "annotations": {
        "title": "Reply to a YouTube comment",
        "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": "list_youtube_playlists",
      "title": "List the channel’s playlists (and what is in one)",
      "description": "List the playlists on the connected YouTube channel — id, title, description, privacy and video count. Pass a playlistId to get that ONE playlist plus its entries in order. IMPORTANT: each entry carries BOTH a `videoId` and an `itemId`; `itemId` is the playlist-ENTRY id and is what manage_youtube_playlist_items needs to remove or re-order a row, because one video can appear in a playlist more than once so a videoId does not identify the entry. Read-only, 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL MANAGEMENT (2026-08-17)",
      "annotations": {
        "title": "List the channel’s playlists (and what is in one)",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "playlistId": {
            "description": "one playlist to open, with its entries. Omit to list the channel’s playlists.",
            "type": "string"
          },
          "limit": {
            "description": "max rows, default 25, cap 50 (YouTube’s own maximum)",
            "type": "number"
          },
          "pageToken": {
            "description": "nextPageToken from a previous call",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_youtube_playlist",
      "title": "Create, rename or delete a YouTube playlist",
      "description": "Create, update or delete a playlist on the connected channel. CREATE makes it PRIVATE unless you pass privacy — a public playlist appears on the channel immediately, so publishing is always a deliberate choice. UPDATE merges: an omitted title or description is LEFT ALONE rather than erased. DELETE is confirm-gated and does NOT delete the videos — what dies is the playlist, its ordering and every link or embed pointing at its URL; a playlist that is public or has videos in it additionally needs confirmTitle echoed back exactly, because confirm alone proves you meant to delete something and not that you aimed at the right playlist. To take a playlist out of public view WITHOUT destroying it use action=\"update\" with privacy=\"private\" — that is reversible and delete is not. 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL MANAGEMENT (2026-08-17)",
      "annotations": {
        "title": "Create, rename or delete a YouTube playlist",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "action": {
            "type": "string",
            "enum": [
              "create",
              "update",
              "delete"
            ],
            "description": "create | update | delete"
          },
          "playlistId": {
            "description": "required for update and delete",
            "type": "string"
          },
          "title": {
            "description": "required on create; on update, omit to leave unchanged",
            "type": "string"
          },
          "description": {
            "description": "on update, omit to leave unchanged",
            "type": "string"
          },
          "privacy": {
            "description": "default private on create",
            "type": "string",
            "enum": [
              "public",
              "unlisted",
              "private"
            ]
          },
          "confirm": {
            "description": "required to delete",
            "type": "boolean"
          },
          "confirmTitle": {
            "description": "the playlist’s EXACT title — required to delete one that is public or non-empty",
            "type": "string"
          }
        },
        "required": [
          "action"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_youtube_playlist_items",
      "title": "Add, remove or re-order a video in a playlist",
      "description": "Add a video to a playlist, remove an entry, or move an entry to a new position. ADD takes playlistId + videoId and returns the new entry’s `itemId`. REMOVE and MOVE take that `itemId` — NOT the videoId — because one video can sit in a playlist more than once, so a videoId does not identify which row to touch; list_youtube_playlists with a playlistId returns the itemId of every entry. Removing an entry does NOT delete the video, only its place in the playlist. Positions are zero-based. 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL MANAGEMENT (2026-08-17)",
      "annotations": {
        "title": "Add, remove or re-order a video in a playlist",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "action": {
            "type": "string",
            "enum": [
              "add",
              "remove",
              "move"
            ],
            "description": "add | remove | move"
          },
          "playlistId": {
            "description": "required for add",
            "type": "string"
          },
          "videoId": {
            "description": "required for add",
            "type": "string"
          },
          "itemId": {
            "description": "the playlist-ENTRY id — required for remove and move (from list_youtube_playlists)",
            "type": "string"
          },
          "position": {
            "description": "zero-based position; required for move, optional on add (default: appended)",
            "type": "number"
          }
        },
        "required": [
          "action"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_youtube_channel_section",
      "title": "Channel homepage sections",
      "description": "THE SHELVES ON THE CHANNEL HOMEPAGE — what a visitor sees first, and the only place a chosen playlist can be put above YouTube’s own default layout. action:'list' reads them in the order they appear, 'create' adds one, 'update' replaces one, 'delete' removes one (confirm-gated, and there is no undo — the layout has to be rebuilt by hand). EVERY WRITE IS PUBLIC IMMEDIATELY: a channel homepage is not a draft. `type` decides what the shelf holds — singlePlaylist and multiplePlaylists take playlist ids, multipleChannels takes channel ids and those two plus multiplePlaylists take a title you choose, while popularUploads, recentUploads, subscriptions and the rest are filled by YouTube and take neither. `position` is zero-based and is what re-orders the page. DO NOT TREAT A LIST AS A READ-BACK: YouTube's own section list lags a write by a few seconds in BOTH directions — measured, it returned nothing right after a create and still returned a deleted section right after a delete — so a write reports what YouTube returned and a delete reports as ACCEPTED, and re-listing straight away can show the old layout. Free.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Channel homepage sections",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "action": {
            "description": "defaults to 'list'",
            "type": "string",
            "enum": [
              "list",
              "create",
              "update",
              "delete"
            ]
          },
          "type": {
            "description": "singlePlaylist | multiplePlaylists | popularUploads | recentUploads | likes | allPlaylists | likedPlaylists | recentPosts | recentActivity | liveEvents | upcomingEvents | completedEvents | multipleChannels | postedVideos | postedPlaylists | subscriptions. Required to create or update",
            "type": "string"
          },
          "title": {
            "description": "the heading, and only multiplePlaylists and multipleChannels take one — YouTube writes the heading for every other type",
            "type": "string"
          },
          "playlists": {
            "description": "playlist ids, from list_youtube_playlists. Required for singlePlaylist (exactly one) and multiplePlaylists",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "channels": {
            "description": "channel ids to feature. Required for multipleChannels",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "position": {
            "description": "zero-based position on the homepage. Leave it off and YouTube places the section",
            "type": "number"
          },
          "style": {
            "description": "leave it off to let YouTube choose",
            "type": "string",
            "enum": [
              "horizontalRow",
              "verticalList"
            ]
          },
          "sectionId": {
            "description": "for update and delete — from action:'list'",
            "type": "string"
          },
          "hl": {
            "description": "language for the returned titles, e.g. \"en\"",
            "type": "string"
          },
          "confirm": {
            "description": "must be true to actually delete",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_youtube_playlist_image",
      "title": "Custom playlist cover image",
      "description": "PUT A CUSTOM COVER ON A PLAYLIST — the last call in a chain that already existed, since make_thumbnail renders the artwork and the playlist tools own the playlist. Without one YouTube shows the first video's thumbnail. action:'list' reads what is on a playlist, 'set' uploads a cover, 'delete' removes it. imageUrl must be a Hermoso-hosted URL, up to 50MB — a Hermoso render, a make_thumbnail result, or the user’s OWN artwork brought in with upload_file, which turns any local or external file into a URL this accepts. Nothing else is fetched server-side. YOUTUBE ANSWERS EVERY FAILURE HERE AS AN HTTP 500 \"Internal error encountered\" with the real reason buried inside it, so a plain relay would report a missing playlist id as a Hermoso outage — the refusals here are the real ones. If it comes back refused, the first thing to check is CHANNEL VERIFICATION: custom imagery needs a verified YouTube channel (add a phone number at youtube.com/verify), and on an unverified channel the sibling call that sets a custom video thumbnail is refused in the same way. Free.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Custom playlist cover image",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "playlistId": {
            "type": "string",
            "description": "from list_youtube_playlists"
          },
          "action": {
            "description": "defaults to 'list'",
            "type": "string",
            "enum": [
              "list",
              "set",
              "delete"
            ]
          },
          "imageUrl": {
            "description": "for action:'set' — a Hermoso render or make_thumbnail URL",
            "type": "string"
          },
          "imageId": {
            "description": "for action:'delete' — from action:'list'",
            "type": "string"
          }
        },
        "required": [
          "playlistId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_youtube_captions",
      "title": "List (and read) a video’s caption tracks",
      "description": "List the caption/subtitle tracks on one of the connected channel’s videos, and optionally DOWNLOAD one as text. A caption TRACK is not the same thing as burned-in captions: a track is what YouTube indexes the video by, what a viewer toggles on, and what accessibility depends on. Each row says whether YouTube generated it automatically (`isAutoGenerated`, trackKind ASR) — those are read-only and cannot be edited or deleted. Downloading is also the fastest way to get an existing video’s full script back for repurposing. Read-only, 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "List (and read) a video’s caption tracks",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoId": {
            "type": "string",
            "description": "the YouTube video id — captions are listed per video"
          },
          "download": {
            "description": "a captionId or a language code to also download as text",
            "type": "string"
          },
          "format": {
            "description": "download format, default srt",
            "type": "string",
            "enum": [
              "srt",
              "vtt",
              "sbv"
            ]
          }
        },
        "required": [
          "videoId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_youtube_caption",
      "title": "Upload, replace or delete a caption track",
      "description": "Attach a real subtitle track to one of the connected channel’s videos, replace one, or delete one. Pass the caption file BODY itself as `text` (SRT or WebVTT), not a URL. `language` is required and may not be guessed — YouTube uses it to decide which viewers see the track. A track YouTube generated itself (trackKind ASR) is refused by name: YouTube owns it and it cannot be edited or deleted through any API; to replace it, upload your own track in the same language and YouTube prefers yours. Delete is confirm-gated — viewers with captions on lose them immediately and YouTube stops indexing the video by that text, though it can be re-uploaded if you still have the file. 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Upload, replace or delete a caption track",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "action": {
            "type": "string",
            "enum": [
              "upload",
              "update",
              "delete"
            ],
            "description": "upload | update | delete"
          },
          "videoId": {
            "description": "required for upload; strongly recommended for update and delete so the track can be resolved and read back",
            "type": "string"
          },
          "captionId": {
            "description": "required for update and delete (from list_youtube_captions)",
            "type": "string"
          },
          "text": {
            "description": "the caption file body — SRT or WebVTT. Required for upload.",
            "type": "string"
          },
          "language": {
            "description": "BCP-47 code such as en, es, pt-BR. Required for upload.",
            "type": "string"
          },
          "name": {
            "description": "a label shown in YouTube’s caption picker",
            "type": "string"
          },
          "draft": {
            "description": "true keeps the track unpublished while you review it",
            "type": "boolean"
          },
          "confirm": {
            "description": "required to delete",
            "type": "boolean"
          }
        },
        "required": [
          "action"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "moderate_youtube_comment",
      "title": "Hide, reject, spam-report or delete a YouTube comment",
      "description": "Moderate a comment on the connected channel — the YouTube twin of moderate_meta_comment. ACTIONS: publish (make it visible again) · hold (hide it pending review) · reject (hide it as unfit for display) · spam (report it to YouTube — a REPORT, not a removal, so do not tell the user it is gone) · delete (permanent; YouTube has no undelete for a comment). hold, reject and publish are all reversible with each other, so only delete is confirm-gated. If the goal is just to take a comment out of public view, use reject rather than delete. banAuthor is valid ONLY with action=\"reject\" — that is YouTube’s own restriction — and auto-rejects every future comment that author leaves on the channel. Get commentId from list_youtube_comments. 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Hide, reject, spam-report or delete a YouTube comment",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "action": {
            "type": "string",
            "enum": [
              "publish",
              "hold",
              "reject",
              "spam",
              "delete"
            ],
            "description": "publish | hold | reject | spam | delete"
          },
          "commentId": {
            "type": "string",
            "description": "id of the comment (from list_youtube_comments)"
          },
          "banAuthor": {
            "description": "reject only — also auto-reject every future comment from this author",
            "type": "boolean"
          },
          "confirm": {
            "description": "required for delete",
            "type": "boolean"
          }
        },
        "required": [
          "action",
          "commentId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_youtube_categories",
      "title": "List the video categories you can publish under",
      "description": "List the video categories YouTube will accept on an upload in a given country. post_to_youtube takes a `categoryId` and this is the only way to discover a valid one — the id set AND the names differ by country, which is why regionCode is required rather than defaulted. Categories YouTube marks not-assignable are filtered out by default because an upload using one is refused. Read-only, 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "List the video categories you can publish under",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "regionCode": {
            "type": "string",
            "description": "ISO 3166-1 alpha-2 country code — US, GB, DE. YouTube’s category ids differ by country, so this cannot be guessed."
          },
          "includeUnassignable": {
            "description": "also return categories YouTube will refuse on upload (default false)",
            "type": "boolean"
          }
        },
        "required": [
          "regionCode"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "google_business_search_keywords",
      "title": "What people searched to find this business",
      "description": "The actual search terms people typed on Google Search and Maps before this business listing appeared — the only keyword data a local business gets for free, and the direct input to their Google Ads keyword set, page titles and profile description. google_business_insights answers HOW MANY people found the listing; this answers WHICH WORDS they used. ⚠ LOW-VOLUME TERMS ARE SUPPRESSED: Google withholds an exact count for them and returns only an upper bound, so those rows come back with impressions=null and below=<threshold>. Report those as “fewer than N” — NEVER as zero and never as the threshold itself, both of which are numbers a marketer would act on and neither is true. Counts are UNIQUE USERS per month summed across the window, not impressions; the two are not comparable. Google keeps roughly 12 months of history. Read-only, 0 credits. Needs Google Business Profile connected.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "What people searched to find this business",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "locationId": {
            "description": "which listing, when the brand has more than one (list_business_locations)",
            "type": "string"
          },
          "months": {
            "description": "how many whole months back, default 3, cap 12. The current month is excluded because it is always partial.",
            "type": "number"
          },
          "limit": {
            "description": "max terms, default 100, which is also Google’s maximum",
            "type": "number"
          },
          "pageToken": {
            "description": "nextPageToken from a previous call",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_bluesky_posts",
      "title": "List the brand's own Bluesky posts",
      "description": "The brand's OWN recent Bluesky posts, newest first — and THIS is where the at:// AT-URI every other Bluesky tool needs comes from. bluesky_post_metrics and delete_bluesky_post both address a post by AT-URI, so without this the only way to hold one was to have just published it in the same conversation; an agent reviewing past work had no way to name anything. Each row carries the text, when it went out, its web URL, its live like/repost/reply/quote/bookmark counts, and whether it is a REPOST of someone else's post or a reply — a repost is not the brand's own creative and must not be reported as its performance. Optional filter: posts_no_replies, posts_with_media, posts_with_replies, posts_and_author_threads (an unknown one is refused by name). Bluesky publishes NO impression or view count in any lexicon, so these are counts with no denominator and no engagement rate can be computed from them. Read-only, 0 credits.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "List the brand's own Bluesky posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "how many posts, 1–100 (default 25). 100 is Bluesky's own maximum.",
            "type": "number"
          },
          "cursor": {
            "description": "nextCursor from a previous call — a short page is NOT end-of-feed",
            "type": "string"
          },
          "filter": {
            "description": "posts_no_replies | posts_with_media | posts_with_replies | posts_and_author_threads",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "bluesky_post_metrics",
      "title": "Read likes, reposts, replies and quotes on your Bluesky posts",
      "description": "Read live engagement for up to 25 of the connected account’s Bluesky posts — likes, reposts, replies, quotes and bookmarks. Address a post by its AT-URI (the `at://…` value post_to_bluesky returns), not its web URL. Bluesky publishes NO impression or view count in any AT Protocol lexicon, so these are COUNTS with no denominator and no engagement rate can be computed from them — do not present one. A uri Bluesky returns nothing for is reported as MISSING (deleted, or not on the connected account), never as zero engagement. Read-only, 0 credits. Needs Bluesky connected.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Read likes, reposts, replies and quotes on your Bluesky posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "uris": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "AT-URIs of the posts, at most 25 (Bluesky’s own maximum for one call)"
          }
        },
        "required": [
          "uris"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_bluesky_convos",
      "title": "List Bluesky direct-message conversations",
      "description": "Read the connected Bluesky account’s DM conversations — who each one is with, how many messages are unread, and whether it is a REQUEST (someone the account does not follow, which Bluesky holds separately, the same idea as a message request). Free, no ScrapeCreators credits, no vendor approval: AT Protocol app passwords are not scoped. It DOES need a PRIVILEGED app password — one created with direct-message access ticked — and says so precisely if the saved one cannot chat, which is a property of the password and NOT a broken connection. Filters: readState \"unread\", status \"request\" or \"accepted\", kind \"direct\" or \"group\". An unknown filter value is refused by name, never silently dropped.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "List Bluesky direct-message conversations",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "how many conversations, 1–100 (default 25)",
            "type": "number"
          },
          "cursor": {
            "description": "walk further back — pass the cursor from a previous call",
            "type": "string"
          },
          "readState": {
            "description": "only conversations with unread messages",
            "type": "string",
            "enum": [
              "unread"
            ]
          },
          "status": {
            "description": "\"request\" = from someone this account does not follow",
            "type": "string",
            "enum": [
              "request",
              "accepted"
            ]
          },
          "kind": {
            "type": "string",
            "enum": [
              "direct",
              "group"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "read_bluesky_dm",
      "title": "Read the messages in one Bluesky conversation",
      "description": "Read a Bluesky DM conversation in CHRONOLOGICAL order (Bluesky returns newest-first on the wire; this reverses it so the conversation reads top to bottom). Each message says whether it was sent by this account. A message with no text is reported as DELETED rather than as an empty message — “they unsent it” and “they sent nothing” are different facts. Get a convoId from list_bluesky_convos.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Read the messages in one Bluesky conversation",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "convoId": {
            "type": "string",
            "description": "from list_bluesky_convos"
          },
          "limit": {
            "description": "how many messages, 1–100 (default 30)",
            "type": "number"
          },
          "cursor": {
            "description": "walk further back through older messages",
            "type": "string"
          }
        },
        "required": [
          "convoId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "send_bluesky_dm",
      "title": "Send a Bluesky direct message",
      "description": "Send ONE direct message on the connected Bluesky account, either into an existing conversation (convoId) or to a named handle (a conversation is opened for you — the handle is resolved to a DID first, because Bluesky identifies chat members by DID). A DM allows 1000 characters, NOT the 300 a post allows. Refusals name the real situation rather than a generic failure: the recipient has DMs off, only accepts DMs from people they follow, has blocked this account, is suspended, or does not exist. There is deliberately no broadcast form — a recipient is always named.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Send a Bluesky direct message",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "convoId": {
            "description": "reply into an existing conversation (from list_bluesky_convos). Omit to start one with `to`.",
            "type": "string"
          },
          "to": {
            "description": "the recipient’s Bluesky handle, e.g. alice.bsky.social. Ignored when convoId is given.",
            "type": "string"
          },
          "text": {
            "type": "string",
            "description": "the message, up to 1000 characters"
          },
          "replyToMessageId": {
            "description": "reply to a specific message in the conversation",
            "type": "string"
          }
        },
        "required": [
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "mark_bluesky_convo_read",
      "title": "Mark a Bluesky conversation as read",
      "description": "Clear the unread count on one Bluesky DM conversation, optionally only up to a specific message. Useful after triaging an inbox so the next list_bluesky_convos does not surface the same thread again. Reports the unread count Bluesky reads back, not the one requested.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Mark a Bluesky conversation as read",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "convoId": {
            "type": "string",
            "description": "from list_bluesky_convos"
          },
          "messageId": {
            "description": "mark read only up to this message; omit to clear the whole conversation",
            "type": "string"
          }
        },
        "required": [
          "convoId"
        ],
        "$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 CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Read the connected TikTok creator’s posting options",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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 CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Post a video or photo post to TikTok",
        "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 CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Read the connected TikTok account",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "The connected account’s own TikTok posts with per-video stats — views, likes, comments, shares, duration, cover image and link. TWO WAYS TO ASK: with no arguments it lists the most recent (newest first, up to 20 a page); with videoIds it reads THOSE posts directly however old they are, which is how you answer \"how did that specific video do\" without paging back through the account. Any id TikTok does not return comes back under `unresolved` — meaning it is not on this account or no longer exists, which TikTok does not distinguish — never as a zero. Only ever the connected user’s OWN videos. ⚠️ TIKTOK OFFERS NO WAY TO DELETE OR EDIT A PUBLISHED POST through its API — not the caption, not the privacy level, not the comment/duet/stitch settings, not the cover. Every one of those is fixed at the moment of publishing. If the user wants a TikTok changed or taken down, tell them plainly that it has to be done in the TikTok app; do not look for a tool for it. Read-only, 0 credits. Needs TikTok connected.",
      "group": "publish",
      "section": "YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "List the connected account’s TikTok posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "1-20, default 10 (ignored when videoIds is given)",
            "type": "number"
          },
          "videoIds": {
            "description": "read these specific TikTok video ids instead of listing recent ones — up to 20 per call",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "$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 CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Upload an asset to a Meta ad account",
        "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 CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Create a Meta ad campaign (paused)",
        "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 CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20)",
      "annotations": {
        "title": "Activate or pause a Meta campaign",
        "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. BOOST AN EXISTING POST: pass boostPostId — a post you have ALREADY published (numeric id, the <pageId>_<postId> form, or a permalink) — INSTEAD of any creative, and the ad promotes that post exactly as published, comments and all. Meta ignores creative overrides on an existing post, so message/headline/cta/link do NOT apply; targeting, budget, schedule, bidding and PAUSED-by-default all work identically. Find ids with list_meta_posts. AN INSTAGRAM POST NEEDS boostTarget:\"instagram\" — an IG media id and a Facebook post id are both bare digits, so Hermoso will NOT guess which one you meant, and a Facebook boost given an IG media id is refused rather than built against a fabricated id. Instagram eligibility is checked for free before anything is created (Meta refuses to boost a post carrying licensed music or an interactive element).",
      "group": "ads",
      "section": "Meta ad-set targeting, shared by create_meta_ad and create_meta_adset. Ids come from find_meta_audiences —",
      "annotations": {
        "title": "Build a full Meta ad (campaign → ad set → ad, paused)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "boostPostId": {
            "description": "Promote a post that ALREADY EXISTS instead of building a new ad from media. Accepts the numeric post id, <pageId>_<postId>, or a permalink (an Instagram post is its NUMERIC media id — an instagram.com link carries only a shortcode, which Meta cannot resolve). Cannot be combined with image/video inputs, and creative fields do not apply — a boost shows the post as published.",
            "type": "string"
          },
          "boostTarget": {
            "description": "Which surface the boosted post lives on. Default facebook. REQUIRED for an Instagram post: an IG media id and a Facebook post id are both bare digits, so this is never inferred — Meta takes a different creative for each (object_story_id for a Page post; object_id + instagram_user_id + source_instagram_media_id for an IG post). list_meta_posts(target:'instagram') returns the ids.",
            "type": "string",
            "enum": [
              "facebook",
              "instagram"
            ]
          },
          "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"
          },
          "leadFormId": {
            "description": "LEAD ADS — attach an instant form (from list_meta_lead_forms / create_meta_lead_form) so the button opens that form INSIDE Facebook/Instagram instead of sending the click to a website. Requires objective:\"OUTCOME_LEADS\"; the ad set is then optimized for LEAD_GENERATION and pointed at the form automatically. Read the submissions afterwards with read_meta_leads.",
            "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"
          },
          "destinationType": {
            "description": "WHERE A CLICK LANDS, and worth deciding for a merchant with a shop. \"WEBSITE\" sends it to their own site — their pixel fires, their email capture runs, their upsell flow works. \"WEBSITE_AND_SHOP\" lets Meta route it into the in-app Facebook/Instagram Shop instead: often fewer taps to a purchase, but the visit never reaches their site. FROM META API v26 AN ADVERTISER WITH A SHOP DEFAULTS TO WEBSITE_AND_SHOP, so pass \"WEBSITE_AND_SHOP_OPT_OUT\" to keep every click on their own site. Also accepts APP, MESSENGER, INSTAGRAM_DIRECT, WHATSAPP, ON_AD, ON_POST, ON_PAGE, ON_EVENT, ON_VIDEO, SHOP_AUTOMATIC. An unknown value is refused by name, and the read-back says in words where clicks will go.",
            "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": {
        "title": "Create a Meta ad set (audience + budget + schedule)",
        "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": {
        "title": "Look up Meta targeting ids",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List Meta campaigns / ad sets / ads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Meta ad performance metrics",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Preview a Meta ad exactly as it will appear",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Estimate how many people a Meta audience reaches",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List Meta custom audiences + lookalikes",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Create a Meta custom audience or lookalike",
        "readOnlyHint": false,
        "destructiveHint": 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": "list_meta_pixels",
      "title": "List Meta Pixels on an ad account",
      "description": "List the META PIXELS on one of the brand’s ad accounts — id, name, when it was created, and WHEN IT LAST FIRED. This is where the pixelId every conversion tool needs comes from: create_meta_ad takes it (with conversionEvent) to optimise an ad set for OFFSITE_CONVERSIONS instead of link clicks, and create_meta_audience needs it to build a website retargeting audience. Without this tool that id could only be read off a screen in Events Manager. READ lastFiredAt BEFORE YOU TRUST A PIXEL: one that has NEVER FIRED is not installed on the site, so an ad optimising against it will spend and never learn. Pass includeCode:true to get the <script> snippet for installation (it is long, so it is off by default). Read-only, free.",
      "group": "ads",
      "section": "META LEAD ADS (2026-08-10) — instant forms + on-demand lead retrieval",
      "annotations": {
        "title": "List Meta Pixels on an ad account",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "the ad account (from list_connector_accounts / list_meta_pages), with or without the act_ prefix"
          },
          "includeCode": {
            "description": "also return the full pixel <script> snippet — only ask when the user is about to install it",
            "type": "boolean"
          }
        },
        "required": [
          "adAccountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_meta_pixel",
      "title": "Create a Meta Pixel",
      "description": "Create a META PIXEL on one of the brand’s ad accounts, so their ads can optimise for real website conversions instead of link clicks and so website retargeting audiences become possible. A pixel is a DEFINITION: it cannot serve, cannot spend, and RECORDS NOTHING until its snippet is installed on the site — the reply hands back that snippet and says so. TWO THINGS TO SAY OUT LOUD BEFORE CALLING IT: Meta allows exactly ONE pixel per ad account (a second attempt is refused and Hermoso will name the one that already exists — so call list_meta_pixels first), and Meta publishes NO WAY TO DELETE a pixel, so this is permanent. Creating it spends nothing.",
      "group": "ads",
      "section": "META LEAD ADS (2026-08-10) — instant forms + on-demand lead retrieval",
      "annotations": {
        "title": "Create a Meta Pixel",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "the ad account (from list_connector_accounts / list_meta_pages), with or without the act_ prefix"
          },
          "name": {
            "type": "string",
            "description": "what it will be called in Events Manager — name it after the website or brand it measures"
          }
        },
        "required": [
          "adAccountId",
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_meta_lead_forms",
      "title": "List Meta instant (lead) forms",
      "description": "List the INSTANT LEAD FORMS on a connected Facebook Page — id, name, status, how many leads each has collected and what each one asks. This is where the formId every other lead tool needs comes from, and calling it before create_meta_lead_form is how you avoid building a duplicate. Read-only, free. NEEDS the pages_manage_ads permission, which Hermoso’s Meta consent screen asks for. If Meta answers \"Requires pages_manage_ads\", the user must RECONNECT Meta under Settings ▸ Connectors ▸ Meta — a connection made before that permission was added cannot gain it by retrying. Call the tool rather than pre-refusing: any refusal comes from Meta and names the one thing that fixes it.",
      "group": "ads",
      "section": "META LEAD ADS (2026-08-10) — instant forms + on-demand lead retrieval",
      "annotations": {
        "title": "List Meta instant (lead) forms",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "pageId": {
            "description": "which connected Page (from list_meta_pages) — required only if the brand has more than one",
            "type": "string"
          },
          "limit": {
            "description": "max rows (1–100, default 25)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_meta_lead_form",
      "title": "Create a Meta instant (lead) form",
      "description": "Create an INSTANT LEAD FORM on a connected Facebook Page — the in-app form a Meta lead ad opens instead of sending someone to a website, which is why lead ads convert far better than a landing page on mobile. `questions` takes Meta’s own type names, e.g. [\"FULL_NAME\",\"EMAIL\",\"PHONE\"]; for anything bespoke pass {type:\"CUSTOM\", label:\"What size fleet do you run?\"} and add options:[…] to make it a dropdown. Ask FEWER questions than you think — every extra field costs completions. privacyPolicyUrl is REQUIRED: the form collects real people’s contact details and has to say where their data goes. Optional: headline, contextCard {title, bullets[], buttonText} for the why-should-I screen, thankYou {title, body, buttonType, buttonText, websiteUrl}, locale, followUpActionUrl. CREATING A FORM SPENDS NOTHING and publishes nothing — it is invisible to the public until an ad points at it (create_meta_ad with objective:\"OUTCOME_LEADS\" + leadFormId), and that ad is created PAUSED. Read the submissions with read_meta_leads. NEEDS pages_manage_ads — see list_meta_lead_forms for what to do if Meta refuses it.",
      "group": "ads",
      "section": "META LEAD ADS (2026-08-10) — instant forms + on-demand lead retrieval",
      "annotations": {
        "title": "Create a Meta instant (lead) form",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "internal name for the form — not shown to the person filling it in"
          },
          "questions": {
            "type": "array",
            "items": {},
            "description": "Meta question types as strings (\"FULL_NAME\",\"EMAIL\",\"PHONE\",\"COMPANY_NAME\",\"JOB_TITLE\",\"CITY\",\"ZIP\",\"WORK_EMAIL\",\"DATE_TIME\"…) or objects {type,label,key,options[]} for CUSTOM"
          },
          "privacyPolicyUrl": {
            "type": "string",
            "description": "REQUIRED — the business’s privacy policy URL (https://…)"
          },
          "privacyPolicyLinkText": {
            "type": "string"
          },
          "pageId": {
            "description": "which connected Page (required only if the brand has more than one)",
            "type": "string"
          },
          "headline": {
            "description": "headline shown above the questions",
            "type": "string"
          },
          "locale": {
            "description": "form language, e.g. EN_US, ES_ES, PT_BR, DE_DE",
            "type": "string"
          },
          "followUpActionUrl": {
            "type": "string"
          },
          "contextCard": {
            "description": "{title, bullets[], body, buttonText, style} — the screen shown before the questions",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "thankYou": {
            "description": "{title, body, buttonType, buttonText, websiteUrl} — shown after submitting",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "isOptimizedForQuality": {
            "description": "adds a review-and-confirm step: fewer leads, better ones",
            "type": "boolean"
          },
          "blockNonTargeted": {
            "type": "boolean"
          },
          "trackingParameters": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          }
        },
        "required": [
          "name",
          "questions",
          "privacyPolicyUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "archive_meta_lead_form",
      "title": "Archive (or restore) a Meta instant form",
      "description": "Retire an INSTANT LEAD FORM so it stops collecting — the only way to tidy a Page’s Instant Forms list, because META DOES NOT SUPPORT DELETING A LEAD FORM AT ALL, only archiving it. This is REVERSIBLE: pass status:\"ACTIVE\" to switch an archived form back on. Archiving publishes nothing, spends nothing and DELETES NO LEADS — everything the form already collected stays readable with read_meta_leads. CALL IT WITHOUT confirm FIRST: nothing changes and you get the form’s real name, current status and lead count READ BACK FROM META, so you can show the user exactly which form they are about to retire rather than echoing back an id they may have mistyped; then call again with confirm:true. WARNING WORTH RELAYING BEFORE ARCHIVING: if a live ad still points at this form, its button will open nothing — check the ad first. The new status is READ BACK from Meta after the change, so the summary reports what Meta actually stored and says plainly when the change did NOT take; print it verbatim rather than assuming a successful call means a changed form.",
      "group": "ads",
      "section": "META LEAD ADS (2026-08-10) — instant forms + on-demand lead retrieval",
      "annotations": {
        "title": "Archive (or restore) a Meta instant form",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "formId": {
            "type": "string",
            "description": "the form to retire (from list_meta_lead_forms)"
          },
          "status": {
            "description": "default ARCHIVED; \"ACTIVE\" restores a form that was archived",
            "type": "string",
            "enum": [
              "ARCHIVED",
              "ACTIVE"
            ]
          },
          "pageId": {
            "description": "which connected Page (required only if the brand has more than one)",
            "type": "string"
          },
          "confirm": {
            "description": "must be true to actually change it — without it nothing changes and you get the read-back to show the user",
            "type": "boolean"
          }
        },
        "required": [
          "formId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "read_meta_leads",
      "title": "Read Meta lead-ad submissions",
      "description": "Read the LEADS a form (formId) or a single ad (adId) has collected — the real answers people submitted, fetched live from Meta. THIS RETURNS REAL PEOPLE’S CONTACT DETAILS (names, emails, phone numbers). Treat it as the user’s own customer data: show only what they asked for, never post a lead list anywhere public, and do not copy it into an unrelated document. Hermoso PULLS these on demand and keeps no copy — nothing here watches, polls or files leads anywhere, so if the user wants this batch kept, put it somewhere THEY own in the same turn (a Google Sheet with create_sheet / append_to_sheet, a doc, or their own CRM). Meta makes each lead available for 90 days after it is submitted; anything older is handled in Meta’s own Leads Center and CRM integrations. Narrow with since/until (ISO dates), page with cursor, or pass redact:true to see counts, timestamps and which ad produced each lead WITHOUT the personal details. NEEDS leads_retrieval — see list_meta_lead_forms for what to do if Meta refuses it.",
      "group": "ads",
      "section": "META LEAD ADS (2026-08-10) — instant forms + on-demand lead retrieval",
      "annotations": {
        "title": "Read Meta lead-ad submissions",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "formId": {
            "description": "every lead this form has ever collected (from list_meta_lead_forms)",
            "type": "string"
          },
          "adId": {
            "description": "just the leads this ONE ad produced (from list_meta_ads) — pass this OR formId, never both",
            "type": "string"
          },
          "pageId": {
            "description": "which connected Page (required only if the brand has more than one)",
            "type": "string"
          },
          "since": {
            "description": "ISO date (2026-08-01) or epoch ms — only leads created after this",
            "type": "string"
          },
          "until": {
            "description": "ISO date or epoch ms — only leads created before this",
            "type": "string"
          },
          "limit": {
            "description": "1–500, default 100",
            "type": "number"
          },
          "cursor": {
            "description": "paging cursor returned by a previous call",
            "type": "string"
          },
          "redact": {
            "description": "mask the contact values, keeping counts / dates / attribution — use when the user only wants to know the form is working",
            "type": "boolean"
          }
        },
        "$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 LEAD ADS (2026-08-10) — instant forms + on-demand lead retrieval",
      "annotations": {
        "title": "Delete a Meta custom audience",
        "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": "register_merchant_developer",
      "title": "Register Hermoso with a Merchant Center account",
      "description": "ONE-TIME SETUP, and the FIRST thing to run when Merchant Center calls are being refused. Google blocks every Merchant API call until Hermoso's Google Cloud project is registered against the merchant's account, and says so verbatim (\"GCP project ... is not registered with the merchant account\"). This performs that link. It is NOT a broken connection and reconnecting cannot fix it. Google asks for up to 5 minutes afterwards before the API starts answering.",
      "group": "ads",
      "section": "Google Ads: read + manage (flagship, Meta-parity). Every spend change is confirm-gated.",
      "annotations": {
        "title": "Register Hermoso with a Merchant Center account",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "the numeric id shown top-right in Merchant Center"
          },
          "developerEmail": {
            "type": "string",
            "description": "a contact address Google records against the registration; it is not used to sign in"
          }
        },
        "required": [
          "merchantCenterId",
          "developerEmail"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_merchant_accounts",
      "title": "List Merchant Center accounts",
      "description": "List the Google Merchant Center accounts this brand's connected Google account can reach. EVERY other Merchant tool needs the merchantCenterId this returns, and an agency often has several, so never guess one — ask the user which store. Read-only and free. If it is refused, the Merchant Center permission has not been granted yet; that is not a broken connection and reconnecting will not change it.",
      "group": "ads",
      "section": "Google Ads: read + manage (flagship, Meta-parity). Every spend change is confirm-gated.",
      "annotations": {
        "title": "List Merchant Center accounts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_merchant_products",
      "title": "List Merchant Center products",
      "description": "Read the products in a Merchant Center feed — what the merchant actually offers, which is what Shopping and retail Performance Max campaigns serve. Needs merchantCenterId from list_merchant_accounts. Read-only and free.",
      "group": "ads",
      "section": "Google Ads: read + manage (flagship, Meta-parity). Every spend change is confirm-gated.",
      "annotations": {
        "title": "List Merchant Center products",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "limit": {
            "description": "max products (default 50, max 250)",
            "type": "number"
          }
        },
        "required": [
          "merchantCenterId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_shopify_products",
      "title": "List the Shopify catalog",
      "description": "The merchant's real Shopify products — id, title, description, price, images and storefront URL. This is where the productId for publish_to_shopify_product comes from, and it doubles as ground truth about what the brand actually sells (real titles and real photos, not a guess from the website). Newest-updated first. Only works for accounts created by installing Hermoso from the Shopify App Store. Read-only, free.",
      "group": "publish",
      "section": "SHOPIFY: the merchant's own storefront (2026-08-19)",
      "annotations": {
        "title": "List the Shopify catalog",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "how many products (1–100, default 24)",
            "type": "number"
          },
          "cursor": {
            "description": "pageInfo.endCursor from a previous call, to page further",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "publish_to_shopify_product",
      "title": "Publish an image onto a Shopify product",
      "description": "Attach a finished image to one of the merchant's Shopify product listings, as product media. Pass productId (from list_shopify_products — the gid://shopify/Product/… form) and a PUBLIC https imageUrl, which is what every Hermoso render returns. Shopify fetches the image server-side and processes it asynchronously, so the media can come back status PROCESSING and appear on the listing a moment later — that is success, not a failure. Only works for accounts created by installing Hermoso from the Shopify App Store. Free — the render was already paid for.",
      "group": "publish",
      "section": "SHOPIFY: the merchant's own storefront (2026-08-19)",
      "annotations": {
        "title": "Publish an image onto a Shopify product",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "productId": {
            "type": "string",
            "description": "gid://shopify/Product/… from list_shopify_products"
          },
          "imageUrl": {
            "type": "string",
            "description": "any public https image URL — a Hermoso render works, and so does ANY file of your own brought in with upload_file"
          },
          "alt": {
            "description": "alt text for accessibility and SEO; defaults to a generic credit",
            "type": "string"
          }
        },
        "required": [
          "productId",
          "imageUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "merchant_report",
      "title": "Merchant Center competitive visibility, best sellers and price benchmarks",
      "description": "COMPETITOR INTELLIGENCE GOOGLE COMPUTES FOR FREE, for any retail brand with a Merchant Center. Ten report views, read with a SQL-like MCQL query. The three worth reaching for first: `competitive_visibility_competitor_view` (WHICH other domains appear beside this merchant, their rank, page-overlap and higher-position rates — i.e. who is actually beating them), `best_sellers_product_cluster_view` (what is SELLING on Google in a category right now, with an inventory_status saying whether this merchant even stocks it) and `price_insights_product_view` (Google's own suggested price plus the predicted click and conversion change). Also: competitive_visibility_benchmark_view, competitive_visibility_top_merchant_view, best_sellers_brand_view, price_competitiveness_product_view, product_view, product_performance_view, non_product_performance_view.\n\nMCQL IS NOT SQL: no OR, no subqueries, no GROUP BY, no aggregates, no JOIN, and ORDER BY may only name fields already in SELECT. Date filters use `WHERE date BETWEEN '2026-01-01' AND '2026-01-31'` or `WHERE date DURING LAST_30_DAYS`. Several views REQUIRE specific fields in SELECT and in WHERE — competitive visibility needs report_category_id + report_country_code + traffic_source, and top_merchant uniquely REQUIRES a date condition while FORBIDDING date in SELECT. An unknown view is refused by name with the list.\n\nNOT AVAILABLE ON MULTI-CLIENT (MCA) ACCOUNTS — pass a subaccount id. An empty result is often a normal state (price insights are only produced where Google predicts a substantial gain), and the note says which kind of empty it is. Read-only, free, no new permission.",
      "group": "publish",
      "section": "SHOPIFY: the merchant's own storefront (2026-08-19)",
      "annotations": {
        "title": "Merchant Center competitive visibility, best sellers and price benchmarks",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts — a STANDALONE account or a SUBACCOUNT, never a multi-client (MCA) account"
          },
          "query": {
            "type": "string",
            "description": "the MCQL query, e.g. \"SELECT id, title, price, suggested_price, effectiveness FROM price_insights_product_view\""
          },
          "pageSize": {
            "description": "rows per response, 1–100000 (default 1000). 100,000 is GOOGLE's ceiling, not ours.",
            "type": "number"
          },
          "pageToken": {
            "description": "nextPageToken from a previous call — resend the IDENTICAL query and pageSize with it, which Google requires",
            "type": "string"
          }
        },
        "required": [
          "merchantCenterId",
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_merchant_issues",
      "title": "List Merchant Center account issues",
      "description": "Read the account-level issues Google reports on a Merchant Center — the answer to \"why is this product not showing?\", which Google Ads reporting CANNOT give you, because a disapproved product has no impressions to report on. Needs merchantCenterId from list_merchant_accounts. Read-only and free.",
      "group": "publish",
      "section": "SHOPIFY: the merchant's own storefront (2026-08-19)",
      "annotations": {
        "title": "List Merchant Center account issues",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          }
        },
        "required": [
          "merchantCenterId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_merchant_promotions",
      "title": "List Merchant Center promotions",
      "description": "The sale and discount promotions on a Merchant Center account, with each one's PER-DESTINATION status and any issues Google has raised against it. GOOGLE VALIDATES PROMOTIONS ASYNCHRONOUSLY, so 'created' and 'running' are different states and only destinationStatuses tells them apart — never report a sale as live off a successful create. Read-only, 0 credits. Needs Google Ads connected (Merchant Center rides the same connection).",
      "group": "ads",
      "section": "MERCHANT BREADTH ON THE HELD `content` SCOPE (2026-08-19)",
      "annotations": {
        "title": "List Merchant Center promotions",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "limit": {
            "description": "up to 250, default 50",
            "type": "number"
          }
        },
        "required": [
          "merchantCenterId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_merchant_promotion",
      "title": "Create a Merchant Center promotion (sale badge)",
      "description": "PUT A SALE BADGE ON THE MERCHANT'S SHOPPING LISTINGS — the most seasonal thing a retail advertiser does, and until now unreachable through Hermoso. It creates the promotion and resolves (or creates) the promotion DATA SOURCE that country/language needs automatically; a promotion data source is its own kind and cannot be the product feed. IT IS NOT LIVE ON CREATE: Google validates promotions asynchronously and can take days to approve one, so report it as SUBMITTED and re-read with list_merchant_promotions. Everything Google marks Required is refused up front by name, including the pairings its own 400 does not mention — PERCENT_OFF needs percentOff, MONEY_OFF needs moneyOffAmount, GENERIC_CODE needs genericRedemptionCode — because otherwise Google accepts the promotion and disapproves it hours later where nobody is watching. 0 credits.",
      "group": "ads",
      "section": "MERCHANT BREADTH ON THE HELD `content` SCOPE (2026-08-19)",
      "annotations": {
        "title": "Create a Merchant Center promotion (sale badge)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "promotionId": {
            "type": "string",
            "description": "YOUR OWN id for the promotion, e.g. 'black-friday-2026'"
          },
          "targetCountry": {
            "type": "string",
            "description": "two-letter CLDR territory code, e.g. US. Part of the promotion identity and not changeable later"
          },
          "contentLanguage": {
            "type": "string",
            "description": "two-letter ISO 639-1 code, e.g. en. Must match the language of the products"
          },
          "longTitle": {
            "type": "string",
            "description": "the promotion text shoppers see, e.g. '20% off all winter coats' — describe the offer, do not name the campaign"
          },
          "couponValueType": {
            "type": "string",
            "enum": [
              "MONEY_OFF",
              "PERCENT_OFF",
              "BUY_M_GET_N_MONEY_OFF",
              "BUY_M_GET_N_PERCENT_OFF",
              "BUY_M_GET_MONEY_OFF",
              "BUY_M_GET_PERCENT_OFF",
              "FREE_GIFT",
              "FREE_GIFT_WITH_VALUE",
              "FREE_GIFT_WITH_ITEM_ID",
              "FREE_SHIPPING_STANDARD",
              "FREE_SHIPPING_OVERNIGHT",
              "FREE_SHIPPING_TWO_DAY",
              "MONEY_OFF_RANGE"
            ],
            "description": "the kind of discount"
          },
          "offerType": {
            "type": "string",
            "enum": [
              "NO_CODE",
              "GENERIC_CODE"
            ],
            "description": "NO_CODE when the discount applies automatically, GENERIC_CODE when the shopper types a code"
          },
          "genericRedemptionCode": {
            "description": "REQUIRED when offerType is GENERIC_CODE",
            "type": "string"
          },
          "percentOff": {
            "description": "REQUIRED for PERCENT_OFF — a plain number, e.g. 20",
            "type": "number"
          },
          "moneyOffAmount": {
            "description": "REQUIRED for MONEY_OFF — {\"amountMicros\":\"10000000\",\"currencyCode\":\"USD\"} is $10 off",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "redemptionChannel": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "IN_STORE",
                "ONLINE"
              ]
            },
            "description": "at least one; ONLINE for a webshop sale"
          },
          "promotionDestinations": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "at least one, e.g. ['SHOPPING_ADS'] for paid Shopping or ['FREE_LISTINGS'] for the unpaid surface"
          },
          "productApplicability": {
            "description": "required unless eventApplicability is set — Google allows exactly one of the two",
            "type": "string",
            "enum": [
              "ALL_PRODUCTS",
              "SPECIFIC_PRODUCTS"
            ]
          },
          "eventApplicability": {
            "description": "creates a SALES EVENT instead of a product promotion — a different thing",
            "type": "string",
            "enum": [
              "SITEWIDE",
              "SPECIFIC_CATEGORIES"
            ]
          },
          "startDate": {
            "type": "string",
            "description": "ISO date, e.g. 2026-11-27"
          },
          "endDate": {
            "type": "string",
            "description": "ISO date; widened to the END of that day, because Google's interval end is exclusive"
          },
          "promotionUrl": {
            "description": "the page on the merchant's site where the promotion shows",
            "type": "string"
          },
          "attributes": {
            "description": "any other promotion attribute Google publishes — item/brand/product-type filters, minimum purchase, store filters, display period — forwarded as given",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "customAttributes": {
            "description": "merchant-provided attributes, {name, value}",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          }
        },
        "required": [
          "merchantCenterId",
          "promotionId",
          "targetCountry",
          "contentLanguage",
          "longTitle",
          "couponValueType",
          "offerType",
          "redemptionChannel",
          "promotionDestinations",
          "startDate",
          "endDate"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "merchant_issue_help",
      "title": "Google's own fix steps for a Merchant Center issue",
      "description": "GOOGLE'S OWN FIX STEPS, not just the name of the problem. list_merchant_issues stops at the symptom; this returns the remediation content Google writes for each issue, plus the ACTIONS available on it — request a review, claim the website, edit a named attribute — with the exact opaque ids trigger_merchant_issue_action needs. Pass an offerId (with feedLabel, and contentLanguage if it is not 'en') for one PRODUCT's issues, or leave it off for the ACCOUNT's. The fix text is Google's and is relayed verbatim: RELAY IT, do not paraphrase a remediation step into something that sounds simpler. Read-only, 0 credits.",
      "group": "ads",
      "section": "MERCHANT BREADTH ON THE HELD `content` SCOPE (2026-08-19)",
      "annotations": {
        "title": "Google's own fix steps for a Merchant Center issue",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "offerId": {
            "description": "for one PRODUCT's issues; omit for the account's issues",
            "type": "string"
          },
          "feedLabel": {
            "description": "REQUIRED alongside offerId — from list_merchant_products. A product is addressed by contentLanguage + feedLabel + offerId",
            "type": "string"
          },
          "contentLanguage": {
            "description": "with offerId; defaults to 'en'",
            "type": "string"
          },
          "languageCode": {
            "description": "BCP-47 language for the fix text, e.g. 'en-US'",
            "type": "string"
          },
          "timeZone": {
            "description": "IANA zone for times inside the content, e.g. 'America/Los_Angeles'",
            "type": "string"
          }
        },
        "required": [
          "merchantCenterId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "trigger_merchant_issue_action",
      "title": "Fire a Merchant Center issue action",
      "description": "FIRE one of the actions merchant_issue_help returned — most usefully 'request a review' on a disapproval. SEVERAL OF THESE ARE ONE-SHOT and Google says so in its own dialog callout ('You can only request a review for disagreeing with this issue once. If it's not approved, you'll need to fix the issue and wait'), so read that callout back to the user before confirming: a review spent on the wrong issue cannot be spent again. WITHOUT `confirm` IT FIRES NOTHING. `actionContext` is OPAQUE by Google's own instruction — pass it back exactly as merchant_issue_help gave it; it cannot be constructed or edited. The answer is Google's own message to the business, relayed verbatim, because this endpoint returns no resource to read back. 0 credits.",
      "group": "ads",
      "section": "MERCHANT BREADTH ON THE HELD `content` SCOPE (2026-08-19)",
      "annotations": {
        "title": "Fire a Merchant Center issue action",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "actionContext": {
            "type": "string",
            "description": "the opaque token from merchant_issue_help, on the action you are firing"
          },
          "actionFlowId": {
            "type": "string",
            "description": "the id of the flow you picked from that action's flows — each asks for different inputs"
          },
          "inputValues": {
            "description": "values for the flow's input fields, shaped as Google described them: {inputFieldId, textInputValue|choiceInputValue|checkboxInputValue}",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "languageCode": {
            "description": "BCP-47 language for the response message",
            "type": "string"
          },
          "confirm": {
            "description": "must be true to actually fire it",
            "type": "boolean"
          }
        },
        "required": [
          "merchantCenterId",
          "actionContext",
          "actionFlowId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_merchant_inventory",
      "title": "Merchant store and region overrides",
      "description": "The per-store and per-region price and availability overrides on one Shopping product. An override REPLACES the product's own price and availability for that store or region; where there is no override the product-level values apply, so an empty result is the normal answer and does NOT mean the product is missing. Needs the product's offerId plus its contentLanguage and feedLabel, because in the Merchant API those three together ARE the product's address rather than optional filters. Read-only, free.",
      "group": "ads",
      "section": "MERCHANT LOCAL + REGIONAL INVENTORY, REVIEWS, QUOTA (2026-08-19, fourth wave)",
      "annotations": {
        "title": "Merchant store and region overrides",
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "offerId": {
            "type": "string",
            "description": "the product's own id in the feed, from list_merchant_products"
          },
          "contentLanguage": {
            "type": "string",
            "description": "two-letter feed language, e.g. \"en\". Part of the product's address, not a filter"
          },
          "feedLabel": {
            "type": "string",
            "description": "usually the feed target country, e.g. \"US\". Part of the product's address, not a filter"
          },
          "kind": {
            "description": "omit to read both",
            "type": "string",
            "enum": [
              "local",
              "regional"
            ]
          }
        },
        "required": [
          "merchantCenterId",
          "offerId",
          "contentLanguage",
          "feedLabel"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_merchant_inventory",
      "title": "Set store or region price and stock",
      "description": "SET the price, availability or stock level of one Shopping product at ONE physical store (kind:\"local\", keyed by storeCode) or in ONE region (kind:\"regional\", keyed by region). This is what stops a Shopping ad advertising something the nearest store has sold out of. Google's insert REPLACES the whole entry, so this tool reads what is already stored, merges your fields over it, and reports which stored fields it preserved; pass an explicit null to CLEAR one. The API publishes no enum for availability, so it accepts anything and only Google's local inventory data specification says which values actually serve: In stock, Limited availability, On display to order, Out of stock, submitted in English. An unrecognised value is warned about here rather than refused, because refusing would mean inventing an enum Google does not publish. Google states it can take up to 30 minutes before the product itself reflects the change, so a product read straight afterwards can still show the old value and be correct. Free.",
      "group": "ads",
      "section": "MERCHANT LOCAL + REGIONAL INVENTORY, REVIEWS, QUOTA (2026-08-19, fourth wave)",
      "annotations": {
        "title": "Set store or region price and stock",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "offerId": {
            "type": "string",
            "description": "the product's own id in the feed"
          },
          "contentLanguage": {
            "type": "string",
            "description": "two-letter feed language, e.g. \"en\""
          },
          "feedLabel": {
            "type": "string",
            "description": "usually the feed target country, e.g. \"US\""
          },
          "kind": {
            "type": "string",
            "enum": [
              "local",
              "regional"
            ],
            "description": "local = one physical store (storeCode), regional = one Google region (region)"
          },
          "storeCode": {
            "description": "required for kind:'local' — the store ID from the merchant's Business Profile. Immutable",
            "type": "string"
          },
          "region": {
            "description": "required for kind:'regional' — the Merchant Center region id. Immutable",
            "type": "string"
          },
          "availability": {
            "description": "no API enum exists; Google's local spec publishes In stock, Limited availability, On display to order, Out of stock, in English. Anything else is warned about, not refused. null clears it",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "price": {
            "description": "\"12.99 USD\". A bare number is refused because a price with no currency is not a price. null clears it",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "salePrice": {
            "description": "\"9.99 USD\". Google requires salePriceEffectiveDate alongside it if that is set. null clears it",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "quantity": {
            "description": "local only. null clears it",
            "anyOf": [
              {
                "type": "number"
              },
              {
                "type": "null"
              }
            ]
          },
          "pickupMethod": {
            "description": "local only. Must be sent WITH pickupSla unless the value is \"not supported\"",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "pickupSla": {
            "description": "local only",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "instoreProductLocation": {
            "description": "local only, max 20 bytes",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "merchantCenterId",
          "offerId",
          "contentLanguage",
          "feedLabel",
          "kind"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "merchant_account_status",
      "title": "Merchant Center serving status",
      "description": "WHY THE MERCHANT CENTER ACCOUNT IS OR IS NOT SERVING, in one read. Do not trust the program state on its own: an account can report Shopping ads and free listings as ENABLED and serve in ZERO countries, because Google counts a region as active only where every requirement for that program is met. This reports the active regions, the unmet requirements Google names with its own help link for each, and then the eight account settings that explain them — whether the homepage is CLAIMED (unclaimed stops the whole account serving), whether business info has an address, a verified phone and a customer service contact, whether any active shipping service covers the countries you sell to, the return policies, whether the Merchant Center terms have been accepted, plus autofeed and automatic improvements. An account with no shipping settings at all is reported as not configured, which is an account state and not a failed read. Anything that genuinely could not be read comes back null with a warning, which means unknown rather than missing. Read-only, free.",
      "group": "ads",
      "section": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "Merchant Center serving status",
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          }
        },
        "required": [
          "merchantCenterId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "merchant_quota",
      "title": "Merchant Center quota and limits",
      "description": "Merchant Center API quota and account limits, which is the difference between \"the API refused\" and \"you are out of daily quota\". Two different things in one answer: quota groups are how many API CALLS each method group has left today, and account limits are how many PRODUCTS the account may hold per destination. Google resets the daily quota at 12:00 PM MIDDAY UTC, not at midnight. If one half of the read fails it comes back null with a warning, which means unknown rather than zero. Read-only, free.",
      "group": "ads",
      "section": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "Merchant Center quota and limits",
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          }
        },
        "required": [
          "merchantCenterId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_merchant_notifications",
      "title": "Merchant Center push notifications",
      "description": "PUSH INSTEAD OF POLLING for Merchant Center product status. Google POSTs to a URL the moment a product's status changes — a disapproval is otherwise found whenever someone happens to run list_merchant_issues, which for a feed of thousands means money burning unnoticed. action:'list' shows the subscriptions, 'create' adds one, 'delete' removes one. BE HONEST ABOUT WHERE IT GOES: callBackUri must be an HTTPS endpoint THE MERCHANT runs and can decode — Hermoso does not receive these notifications, so this only helps someone with a server on the other end. 0 credits.",
      "group": "ads",
      "section": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "Merchant Center push notifications",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "action": {
            "description": "defaults to 'list'",
            "type": "string",
            "enum": [
              "list",
              "create",
              "delete"
            ]
          },
          "callBackUri": {
            "description": "HTTPS endpoint Google will POST to (create). Not a Hermoso URL and not localhost",
            "type": "string"
          },
          "registeredEvent": {
            "description": "defaults to PRODUCT_STATUS_CHANGE, the one that fires on a disapproval",
            "type": "string",
            "enum": [
              "PRODUCT_STATUS_CHANGE",
              "ACCOUNT_SERVICE_CHANGE"
            ]
          },
          "allManagedAccounts": {
            "description": "subscribe for every managed account instead of just this one — mutually exclusive with targetAccount",
            "type": "boolean"
          },
          "targetAccount": {
            "description": "accounts/{id} to receive notifications for; defaults to this account",
            "type": "string"
          },
          "subscriptionId": {
            "description": "for action:'delete', from action:'list'",
            "type": "string"
          }
        },
        "required": [
          "merchantCenterId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_merchant_conversion_source",
      "title": "Merchant Center conversion sources",
      "description": "WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes, and nothing in Merchant Center says why. Two kinds, and they are different jobs: a MERCHANT CENTER DESTINATION is a Google tag target, and creating one returns a destination id (MC-…) that is the only place that id exists, so it must be read back to the user because it is what the Google tag has to send conversions to. A GOOGLE ANALYTICS LINK pulls conversions from a GA4 property instead, needs the connected Google account to be an ADMIN on that property, and Google marks it IMMUTABLE — the property cannot be changed afterwards, only deleted and re-created. action:'list' shows every source including archived ones, 'create' adds one, 'update' changes a destination's name, currency or attribution, 'delete' ARCHIVES one (confirm-gated) and 'undelete' restores it until the expiry Google reports on the archived row. Attribution lookback is 7, 30 or 40 days and nothing else. Free.",
      "group": "ads",
      "section": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "Merchant Center conversion sources",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "action": {
            "description": "defaults to 'list'",
            "type": "string",
            "enum": [
              "list",
              "create",
              "update",
              "delete",
              "undelete"
            ]
          },
          "kind": {
            "description": "for action:'create' — which kind to make. Passing propertyId implies the Google Analytics one",
            "type": "string",
            "enum": [
              "merchant_center_destination",
              "google_analytics_link"
            ]
          },
          "displayName": {
            "description": "the name that identifies a tag destination in the Merchant Center UI, e.g. \"Website purchases\". Required to create one; may also name an existing source for update/delete",
            "type": "string"
          },
          "currencyCode": {
            "description": "three-letter ISO 4217 code the conversions are reported in, e.g. USD. Required when creating a tag destination — it decides what every number reported through it means",
            "type": "string"
          },
          "attributionModel": {
            "description": "CROSS_CHANNEL_LAST_CLICK (default) | ADS_PREFERRED_LAST_CLICK | CROSS_CHANNEL_DATA_DRIVEN | CROSS_CHANNEL_FIRST_CLICK | CROSS_CHANNEL_LINEAR | CROSS_CHANNEL_POSITION_BASED | CROSS_CHANNEL_TIME_DECAY",
            "type": "string"
          },
          "attributionLookbackWindowDays": {
            "description": "7, 30 or 40 — Google publishes no other value. Defaults to 30",
            "type": "number"
          },
          "conversionTypes": {
            "description": "names of the conversion types events may be classified as. IMMUTABLE once set, and Google returns it on no read, so it can never be confirmed afterwards — leave it off and Google creates a standard \"purchase\" type",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "propertyId": {
            "description": "NUMERIC GA4 property id for a Google Analytics link (from list_analytics_properties), not a G- measurement id",
            "type": "string"
          },
          "conversionSourceId": {
            "description": "for update, delete and undelete — from action:'list'",
            "type": "string"
          },
          "showDeleted": {
            "description": "include archived sources in a list",
            "type": "boolean"
          },
          "confirm": {
            "description": "must be true to actually archive",
            "type": "boolean"
          }
        },
        "required": [
          "merchantCenterId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_merchant_data_sources",
      "title": "List Merchant Center product feeds",
      "description": "List the data sources (feeds) on a Merchant Center account and say which of them can actually take a product write. Do this BEFORE creating or deleting a product: writes go into a data source, and Google only accepts them into an API-input product feed — a file feed, the feed Merchant Center's own UI creates, and an autofeed all list here and all REFUSE writes, so picking the first row would pick a feed that cannot be written to. Needs merchantCenterId from list_merchant_accounts. Read-only and free.",
      "group": "ads",
      "section": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "List Merchant Center product feeds",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          }
        },
        "required": [
          "merchantCenterId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_merchant_data_source",
      "title": "Create a Merchant Center product feed",
      "description": "Create an API product feed on a Merchant Center account — the container upsert_merchant_product writes into. Most accounts have none until one is made: a store set up through the Merchant Center UI has a UI feed, which is read-only. feedLabel and contentLanguage must be set TOGETHER or not at all and BOTH ARE IMMUTABLE — leave them off and the feed accepts products for any market and language, which is the safer default because a wrong feed label can never be corrected. Omit destinations to inherit wherever the account already sells. Creating a feed costs nothing and cannot spend.",
      "group": "ads",
      "section": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "Create a Merchant Center product feed",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "displayName": {
            "type": "string",
            "description": "the name the merchant will see in Merchant Center"
          },
          "feedLabel": {
            "description": "market bucket, e.g. \"US\" — max 20 chars, A-Z/0-9/dashes. IMMUTABLE, and must be set together with contentLanguage",
            "type": "string"
          },
          "contentLanguage": {
            "description": "two-letter ISO 639-1, e.g. \"en\". IMMUTABLE, and must be set together with feedLabel",
            "type": "string"
          },
          "countries": {
            "description": "CLDR territory codes the items may be shown in",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "destinations": {
            "description": "omit to inherit the account's own program participation (SHOPPING_ADS, FREE_LISTINGS, …)",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "merchantCenterId",
          "displayName"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "upsert_merchant_product",
      "title": "Create or replace a Merchant Center product",
      "description": "Create or replace a product in a Merchant Center feed — this is how a merchant's catalogue gets populated, and it is what Shopping ads, free listings and retail Performance Max actually serve. It is an UPSERT and a WHOLE-ROW write: calling it again for the same offerId REPLACES the product rather than patching it, so send every field you want kept each time. You do not have to find a feed first — omit dataSource and it resolves the account's one writable feed, creates one if there is none, and REFUSES by name if there are several rather than putting the product in a market the campaigns may not target. offerId, contentLanguage and feedLabel together ARE the product's identity and are all immutable; contentLanguage and feedLabel are taken from the feed when the feed declares them. For the product to serve at all Google needs title, description, link, imageLink, availability and price — a row missing any of them is stored and then disapproved, and the reply says so rather than letting you believe it published. The reply reports what GOOGLE STORED, not what was sent. Free: a feed edit cannot spend.",
      "group": "ads",
      "section": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "Create or replace a Merchant Center product",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "offerId": {
            "type": "string",
            "description": "your own unique id for the product — IMMUTABLE, and what you update or delete it by later"
          },
          "dataSource": {
            "description": "the feed to write into, from list_merchant_data_sources. Omit to resolve the account's one writable feed (created if there is none, refused by name if there are several)",
            "type": "string"
          },
          "dataSourceName": {
            "description": "display name for a feed created on the fly",
            "type": "string"
          },
          "contentLanguage": {
            "description": "two-letter, e.g. \"en\" — taken from the feed when the feed declares one",
            "type": "string"
          },
          "feedLabel": {
            "description": "market bucket, e.g. \"US\" — taken from the feed when the feed declares one",
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "link": {
            "description": "the product page URL on the merchant's store",
            "type": "string"
          },
          "imageLink": {
            "type": "string"
          },
          "additionalImageLinks": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "availability": {
            "description": "in_stock | out_of_stock | preorder | backorder",
            "type": "string"
          },
          "condition": {
            "description": "new | refurbished | used",
            "type": "string"
          },
          "price": {
            "type": "object",
            "properties": {
              "value": {
                "description": "major units, e.g. 19.99",
                "type": "number"
              },
              "amountMicros": {
                "description": "micros, if you already have them — 1 unit = 1000000",
                "type": "string"
              },
              "currencyCode": {
                "type": "string",
                "description": "three-letter ISO 4217, e.g. USD"
              }
            },
            "required": [
              "currencyCode"
            ]
          },
          "salePrice": {
            "type": "object",
            "properties": {
              "value": {
                "description": "major units, e.g. 19.99",
                "type": "number"
              },
              "amountMicros": {
                "description": "micros, if you already have them — 1 unit = 1000000",
                "type": "string"
              },
              "currencyCode": {
                "type": "string",
                "description": "three-letter ISO 4217, e.g. USD"
              }
            },
            "required": [
              "currencyCode"
            ]
          },
          "brand": {
            "type": "string"
          },
          "gtin": {
            "type": "string"
          },
          "mpn": {
            "type": "string"
          },
          "identifierExists": {
            "description": "false for a product with no GTIN/MPN/brand, such as a service or a bundle",
            "type": "boolean"
          },
          "googleProductCategory": {
            "type": "string"
          },
          "productTypes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "color": {
            "type": "string"
          },
          "size": {
            "type": "string"
          },
          "material": {
            "type": "string"
          },
          "ageGroup": {
            "type": "string"
          },
          "gender": {
            "type": "string"
          },
          "itemGroupId": {
            "type": "string"
          },
          "attributes": {
            "description": "any other product attribute by its Merchant API name — there are 145 and only the common ones are named above",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "customAttributes": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "value": {
                  "type": "string"
                }
              },
              "required": [
                "name",
                "value"
              ]
            }
          }
        },
        "required": [
          "merchantCenterId",
          "offerId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_merchant_product",
      "title": "Delete a Merchant Center product",
      "description": "Delete a product from a Merchant Center feed. CONFIRM-GATED: without confirm:true nothing is deleted and it reports the real product it WOULD delete — title, price and availability read back from Google — because the id you were given proves nothing about what is actually there. It is recoverable: re-inserting the same offerId re-creates the product. Needs the same offerId + contentLanguage + feedLabel that identify the product (contentLanguage and feedLabel come from the feed when it declares them). Free.",
      "group": "ads",
      "section": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "Delete a Merchant Center product",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "offerId": {
            "type": "string"
          },
          "dataSource": {
            "description": "the feed it lives in, from list_merchant_data_sources",
            "type": "string"
          },
          "contentLanguage": {
            "type": "string"
          },
          "feedLabel": {
            "type": "string"
          },
          "confirm": {
            "description": "must be true to actually delete; without it nothing is deleted and the real product is reported back",
            "type": "boolean"
          }
        },
        "required": [
          "merchantCenterId",
          "offerId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_merchant_product",
      "title": "Update fields on a Merchant Center product",
      "description": "Change SOME fields on a product already in a Merchant Center feed — the everyday operation, because price and availability move daily. This is a PATCH: only the attributes you name are touched and everything else on the product survives, which is the difference from upsert_merchant_product (a whole-row write that wipes anything you leave out). It refuses if the product does not exist rather than quietly creating a half-populated one, and it refuses an empty change rather than sending an update mask with nothing behind it. The reply reports every field Google now holds, so you can see what survived. Free.",
      "group": "ads",
      "section": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "Update fields on a Merchant Center product",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "offerId": {
            "type": "string",
            "description": "the product to change"
          },
          "dataSource": {
            "description": "the feed it lives in, from list_merchant_data_sources",
            "type": "string"
          },
          "contentLanguage": {
            "type": "string"
          },
          "feedLabel": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "link": {
            "type": "string"
          },
          "imageLink": {
            "type": "string"
          },
          "additionalImageLinks": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "availability": {
            "description": "in_stock | out_of_stock | preorder | backorder",
            "type": "string"
          },
          "condition": {
            "description": "new | refurbished | used",
            "type": "string"
          },
          "price": {
            "type": "object",
            "properties": {
              "value": {
                "description": "major units, e.g. 19.99",
                "type": "number"
              },
              "amountMicros": {
                "description": "micros, if you already have them — 1 unit = 1000000",
                "type": "string"
              },
              "currencyCode": {
                "type": "string",
                "description": "three-letter ISO 4217, e.g. USD"
              }
            },
            "required": [
              "currencyCode"
            ]
          },
          "salePrice": {
            "type": "object",
            "properties": {
              "value": {
                "description": "major units, e.g. 19.99",
                "type": "number"
              },
              "amountMicros": {
                "description": "micros, if you already have them — 1 unit = 1000000",
                "type": "string"
              },
              "currencyCode": {
                "type": "string",
                "description": "three-letter ISO 4217, e.g. USD"
              }
            },
            "required": [
              "currencyCode"
            ]
          },
          "brand": {
            "type": "string"
          },
          "gtin": {
            "type": "string"
          },
          "mpn": {
            "type": "string"
          },
          "identifierExists": {
            "type": "boolean"
          },
          "googleProductCategory": {
            "type": "string"
          },
          "productTypes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "color": {
            "type": "string"
          },
          "size": {
            "type": "string"
          },
          "material": {
            "type": "string"
          },
          "ageGroup": {
            "type": "string"
          },
          "gender": {
            "type": "string"
          },
          "itemGroupId": {
            "type": "string"
          },
          "attributes": {
            "description": "any other product attribute by its Merchant API name",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          }
        },
        "required": [
          "merchantCenterId",
          "offerId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_merchant_data_source",
      "title": "Delete a Merchant Center product feed",
      "description": "Delete a data source (feed) from a Merchant Center account. CONFIRM-GATED and the heavier of the two deletes: without confirm:true nothing is deleted and it reports the feed plus HOW MANY PRODUCTS would be destroyed with it, counted live from Google. This is NOT recoverable the way a product delete is — the products would have to be re-inserted into a new feed, and feedLabel/contentLanguage are immutable so a replacement may not be identical. Free.",
      "group": "ads",
      "section": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "Delete a Merchant Center product feed",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "merchantCenterId": {
            "type": "string",
            "description": "from list_merchant_accounts"
          },
          "dataSource": {
            "type": "string",
            "description": "the feed to delete, from list_merchant_data_sources"
          },
          "confirm": {
            "description": "must be true to actually delete; without it nothing is deleted and the product count that would go with it is reported",
            "type": "boolean"
          }
        },
        "required": [
          "merchantCenterId",
          "dataSource"
        ],
        "$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": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "List Google Ads accounts / campaigns",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "MERCHANT ACCOUNT STATUS (2026-08-20)",
      "annotations": {
        "title": "Google Ads GAQL report",
        "readOnlyHint": true,
        "destructiveHint": 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"
          },
          "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": {
        "title": "What changed on a Google Ads account, and when",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Build a Google Ads campaign (paused)",
        "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": "DO NOT SET true — Google retired Enhanced CPC for new campaigns and answers OPERATION_NOT_PERMITTED_FOR_CONTEXT (measured live 2026-08-05); Hermoso refuses it up front with the reason. Use MAXIMIZE_CONVERSIONS / TARGET_CPA instead.",
                "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": {
        "title": "Add an ad group to a Google Ads campaign",
        "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": {
        "title": "Create a Google Ads ad",
        "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": {
        "title": "Add Google Ads keywords",
        "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": "Add Google Ads location & language targeting",
      "description": "ADD locations and languages to an existing Google Ads campaign. THIS ADDS; IT DOES NOT REPLACE — Google campaign criteria are a list, this call only ever creates entries, and there is no remove operation here. So a campaign already targeting the United States that you \"change to Canada\" ends up targeting BOTH and still spending in the US; the read-back names every pre-existing location and language it kept, and you MUST relay that rather than reporting the new total as the answer. Removing targeting is done in Google Ads (Campaign ▸ Settings ▸ Locations). Pass locations by NAME (\"United States\", \"California\", \"Toronto\") — they are resolved to Google's geo target ids for you; excludedLocations adds a NEGATIVE criterion (the reliable way to stop serving somewhere from here); 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": {
        "title": "Add Google Ads location & language targeting",
        "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": {
        "title": "Set a Google Ads bidding strategy",
        "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": "DO NOT SET true — Google retired Enhanced CPC for new campaigns and answers OPERATION_NOT_PERMITTED_FOR_CONTEXT (measured live 2026-08-05); Hermoso refuses it up front with the reason. Use MAXIMIZE_CONVERSIONS / TARGET_CPA instead.",
            "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": {
        "title": "Look up Google Ads locations",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Set a Google Ads campaign budget",
        "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": {
        "title": "Enable, pause or remove a Google Ads campaign / ad group / ad",
        "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 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": {
        "title": "Remove a Google Ads campaign / ad group / ad / keyword / asset link / conversion action",
        "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": {
        "title": "Upload a creative to Google Ads",
        "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": {
        "title": "Create a Google Ads conversion action",
        "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": {
        "title": "List Google Ads conversion actions",
        "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": {
        "title": "Add sitelinks / callouts / structured snippets to a Google Ads campaign",
        "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: pass merchantCenterId (from list_merchant_accounts) to make it a Shopping-feed Performance Max — it then advertises the WHOLE feed (one root listing group); feedLabel narrows it to a single feed. Partitioning the feed by brand/category/custom label is NOT built and is refused by name, so a caller can never believe they narrowed it when they did not.",
      "group": "ads",
      "section": "Google Ads breadth (Dave 2026-07-31): the four holes the connector audit found.",
      "annotations": {
        "title": "Create a Google Ads Performance Max campaign",
        "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"
          },
          "merchantCenterId": {
            "description": "makes this a RETAIL (Shopping-feed) Performance Max — the Merchant Center account whose products it advertises, from list_merchant_accounts. It advertises the WHOLE feed; partitioning by brand/category is not built and is refused by name",
            "type": "string"
          },
          "feedLabel": {
            "description": "optional, retail only: narrow to ONE Merchant Center feed by its feed label. Omit to use products from all feeds",
            "type": "string"
          },
          "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": "Keyword ideas with real Google 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": {
        "title": "Keyword ideas with real Google search volume",
        "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_analytics_properties",
      "title": "List the GA4 properties shared with this brand",
      "description": "The GA4 properties SHARED WITH THIS BRAND, with their numeric property ids, display names and the Analytics account each sits under. CALL THIS FIRST — every other Analytics tool needs a PROPERTY ID, which is NUMERIC (e.g. 123456789) and is NOT the \"G-XXXXXXX\" Measurement ID people usually know from their tracking snippet; the API accepts the numeric id and nothing else. Resolve the property yourself from this list instead of asking the user to go and find one, and only ask when two names are genuinely ambiguous. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: Analytics access is handed out freely, so one login often has Viewer on many different clients' properties, and the user ticks which ones belong to THIS brand. Only ticked properties can be reported on, and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts (or call list_connector_accounts / set_connector_accounts with provider \"google_analytics\"); never name or guess a property. Read-only, 0 credits. Needs Google Analytics connected (Settings ▸ Connectors ▸ Google Analytics).",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "List the GA4 properties shared with this brand",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "analytics_report",
      "title": "Run a GA4 report",
      "description": "WHAT HAPPENED ON THE SITE — the GA4 Data API report, and the tool that answers \"where is our traffic coming from\", \"which campaign converted\", \"which landing page is working\". Pass metrics by name (activeUsers, sessions, screenPageViews, conversions, totalRevenue, engagementRate, bounceRate, averageSessionDuration …) and dimensions to break them down by (sessionDefaultChannelGroup, sessionSource / sessionMedium / sessionCampaignName, landingPage, pagePath, country, deviceCategory, date, eventName …). GA4 publishes hundreds of both and each property ships its OWN custom dimensions, so names are forwarded as given rather than validated against a copied list — use list_analytics_definitions to see a property's custom ones. THE PROPERTY IS A NUMERIC ID (e.g. 123456789), NOT the G-XXXXXXX Measurement ID — run list_analytics_properties to resolve it. Dates default to the last 28 days and accept either YYYY-MM-DD or GA4's relative forms (\"28daysAgo\", \"yesterday\", \"today\"). Rows come back as flat named objects, and a sampled result says so. Read-only, 0 credits.",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "Run a GA4 report",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id from list_analytics_properties (e.g. \"123456789\") — never the G-XXXXXXX Measurement ID, and it must be one SHARED with this brand"
          },
          "metrics": {
            "description": "GA4 metric names — default [\"activeUsers\",\"sessions\",\"screenPageViews\"]",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "dimensions": {
            "description": "GA4 dimension names to break the metrics down by — omit for a single total row",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "startDate": {
            "description": "YYYY-MM-DD or a GA4 relative date like \"28daysAgo\" (default 28daysAgo)",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD or \"today\" (default today)",
            "type": "string"
          },
          "limit": {
            "description": "rows to return, 1–250000 (default 50). 250,000 is GOOGLE's per-request maximum, not ours — asking for more is silently capped there.",
            "type": "number"
          },
          "offset": {
            "description": "skip this many rows — how you page past `limit`. Use the nextOffset the previous call returns.",
            "type": "number"
          },
          "orderByMetric": {
            "description": "sort by this metric — must be one of the metrics requested",
            "type": "string"
          },
          "orderDesc": {
            "description": "default true (largest first) when orderByMetric is set",
            "type": "boolean"
          },
          "dimensionFilter": {
            "description": "NARROW THE REPORT — a GA4 FilterExpression, exactly one of andGroup | orGroup | notExpression | filter. Without it a report is the WHOLE property. Example: {\"filter\":{\"fieldName\":\"sessionSource\",\"stringFilter\":{\"matchType\":\"EXACT\",\"value\":\"google\"}}}; combine with {\"andGroup\":{\"expressions\":[…]}}. METRICS CANNOT BE USED HERE — use metricFilter.",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "metricFilter": {
            "description": "Filter the AGGREGATED rows, GA4's having-clause — same FilterExpression shape. Example: {\"filter\":{\"fieldName\":\"sessions\",\"numericFilter\":{\"operation\":\"GREATER_THAN\",\"value\":{\"int64Value\":\"30\"}}}}. DIMENSIONS CANNOT BE USED HERE — use dimensionFilter.",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "analytics_realtime",
      "title": "Who is on the site right now (GA4 realtime)",
      "description": "WHO IS ON THE SITE RIGHT NOW — GA4's realtime report, covering roughly the last 30 minutes. Use it to see a launch, a post or a campaign landing in real time. IT IS A DIFFERENT REPORT WITH A DIFFERENT METRIC SET, NOT a shortcut for analytics_report: realtime accepts activeUsers, screenPageViews, conversions and eventCount, and REJECTS `sessions` outright — asking for sessions here returns an error, not a zero. Realtime dimensions are also narrower (unifiedScreenName, country, deviceCategory, platform, eventName …) and there is no date range at all. The property is the NUMERIC id from list_analytics_properties, never the G-XXXXXXX Measurement ID. Read-only, 0 credits.",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "Who is on the site right now (GA4 realtime)",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id from list_analytics_properties — it must be one SHARED with this brand, and any other is refused by name"
          },
          "metrics": {
            "description": "realtime metric names — default [\"activeUsers\"]. NOT the same set as analytics_report; `sessions` is not a realtime metric",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "dimensions": {
            "description": "realtime dimension names, e.g. [\"country\"] or [\"unifiedScreenName\"]",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "limit": {
            "description": "rows, 1–1000 (default 50)",
            "type": "number"
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_analytics_definitions",
      "title": "List a GA4 property’s key events and custom dimensions",
      "description": "What a GA4 property already MEASURES — its key events (what GA4 counts as a conversion) and its custom dimensions, with each dimension's parameter name and scope. Two reasons to call it: to learn a property's own custom dimension names before using them in analytics_report, and to CHECK BEFORE CREATING — a custom dimension can never be deleted, only archived, and a property is capped at 50 event-scoped ones, so creating a duplicate permanently burns a slot. The property is the NUMERIC id from list_analytics_properties. Read-only, 0 credits.",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "List a GA4 property’s key events and custom dimensions",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id from list_analytics_properties — it must be one SHARED with this brand, and any other is refused by name"
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_analytics_key_event",
      "title": "Mark a GA4 event as a key event (conversion)",
      "description": "Mark an event GA4 ALREADY COLLECTS as a KEY EVENT — what GA4 calls a conversion since the 2024 rename (the resource is keyEvents; the old conversionEvents spelling is deprecated). This is what makes an event countable as a result in reports and importable into Google Ads as a conversion. THE EVENT NAME MUST MATCH AN EVENT THE SITE ALREADY SENDS (\"purchase\", \"generate_lead\", \"sign_up\", or a custom one) — marking an event that is never fired creates a key event that can never fire, so check list_analytics_definitions or an analytics_report broken down by eventName first. countingMethod ONCE_PER_EVENT counts EVERY occurrence; ONCE_PER_SESSION counts the event at most ONCE PER SESSION, however many times it fires in that session. Those are the only two GA4 accepts — there is no per-USER counting method, and asking for one is refused rather than quietly counted per event. The result is READ BACK from Google, never echoed from the request. The property is the NUMERIC id from list_analytics_properties. Needs the Google Analytics connection to have edit access on the property.",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "Mark a GA4 event as a key event (conversion)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id from list_analytics_properties — it must be one SHARED with this brand, and any other is refused by name"
          },
          "eventName": {
            "type": "string",
            "description": "an event GA4 already collects, e.g. \"purchase\" / \"generate_lead\" / \"sign_up\""
          },
          "countingMethod": {
            "description": "default ONCE_PER_EVENT; ONCE_PER_SESSION counts at most once per session (GA4 publishes no per-user method)",
            "type": "string",
            "enum": [
              "ONCE_PER_EVENT",
              "ONCE_PER_SESSION"
            ]
          }
        },
        "required": [
          "property",
          "eventName"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_analytics_custom_dimension",
      "title": "Create a GA4 custom dimension",
      "description": "Register an event parameter the site ALREADY SENDS as a custom dimension, so reports can break down by it (plan tier, content category, logged-in state…). TWO THINGS TO TELL THE USER BEFORE CALLING: a GA4 custom dimension CANNOT BE DELETED — only archived — and a property is capped at 50 EVENT-scoped dimensions, so a duplicate or a typo permanently burns one of them. Call list_analytics_definitions first to see what already exists. GET THE TWO NAMES THE RIGHT WAY ROUND: parameterName is the event parameter GA4 is already collecting (e.g. \"customer_tier\") and displayName is only the label shown in reports — swapping them silently produces a dimension that always reads \"(not set)\". Registering a dimension is NOT retroactive: it only collects from the moment it is created. The property is the NUMERIC id from list_analytics_properties. Needs the Google Analytics connection to have edit access on the property.",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "Create a GA4 custom dimension",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id from list_analytics_properties — it must be one SHARED with this brand, and any other is refused by name"
          },
          "parameterName": {
            "type": "string",
            "description": "the event parameter GA4 already collects, e.g. \"customer_tier\" — NOT the report label"
          },
          "displayName": {
            "description": "the label shown in GA4 reports — defaults to parameterName",
            "type": "string"
          },
          "scope": {
            "description": "default EVENT. The 50-dimension cap applies to EVENT scope",
            "type": "string",
            "enum": [
              "EVENT",
              "USER",
              "ITEM"
            ]
          },
          "description": {
            "description": "optional description, ≤150 characters",
            "type": "string"
          }
        },
        "required": [
          "property",
          "parameterName"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_analytics_metadata",
      "title": "What a GA4 property can be asked",
      "description": "THE VOCABULARY OF ONE PROPERTY — every dimension and metric analytics_report will accept on it, INCLUDING that property's own custom dimensions, each with its api name and its human label. Use this instead of guessing an api name: GA4 publishes hundreds and they are not memorable (sessions broken down by landing page is `landingPage`, revenue is `totalRevenue`, the channel grouping is `sessionDefaultChannelGroup`), and a wrong name is an error mid-conversation rather than a suggestion. analytics_report deliberately forwards names AS GIVEN — it never validates against a copied list, because that list would go stale and start refusing names Google accepts — so THIS is where a name is checked. PASS `search` almost always: unfiltered this returns several hundred rows, and a search matches both the api name and the label (\"revenue\", \"campaign\", \"device\"). Read-only, 0 credits.",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "What a GA4 property can be asked",
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id from list_analytics_properties — it must be one SHARED with this brand, and any other is refused by name"
          },
          "search": {
            "description": "substring filter over api name AND human label, e.g. \"revenue\", \"campaign\", \"landing\" — strongly recommended, since the unfiltered vocabulary is several hundred entries",
            "type": "string"
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "archive_analytics_custom_dimension",
      "title": "Archive a GA4 custom dimension",
      "description": "THE ONLY WAY TO RETIRE A CUSTOM DIMENSION, and it is ONE-WAY. GA4 publishes no delete and no un-archive for custom dimensions anywhere in its API — archiving is permanent through every programmatic surface — so this is how a typo'd or duplicate dimension is cleared, and it also frees the slot it was holding against the 50-event-scoped cap. Reports lose the ability to break down by it. CALLED WITHOUT `confirm` IT ARCHIVES NOTHING and instead reports what the dimension actually is, read back from Google — check that against what the user asked for before confirming, because naming the right dimension is the only thing `confirm` cannot prove. Identify it by its parameterName (the event parameter), which list_analytics_definitions lists. Needs edit access on the property.",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "Archive a GA4 custom dimension",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id from list_analytics_properties"
          },
          "parameterName": {
            "type": "string",
            "description": "the event parameter of the dimension to archive, e.g. \"customer_tier\" — from list_analytics_definitions, NOT the report label"
          },
          "confirm": {
            "description": "must be true to actually archive. Without it nothing changes and the dimension is described back to you",
            "type": "boolean"
          }
        },
        "required": [
          "property",
          "parameterName"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_analytics_key_event",
      "title": "Stop counting a GA4 event as a conversion",
      "description": "REMOVE A KEY EVENT — the reverse of create_analytics_key_event, and note the ASYMMETRY with custom dimensions: a key event really can be DELETED, where a custom dimension can only be archived. Nothing is destroyed — GA4 keeps collecting the underlying event and keeps all of its history, this only stops it counting as a conversion, and it can be marked again at any time. Use it for an event marked as a conversion by mistake, or one that should no longer be optimised toward. BE AWARE IT REACHES FURTHER THAN GA4: anything importing this conversion — Google Ads smart bidding in particular — stops receiving it, which changes how campaigns bid. Called without `confirm` it deletes nothing and describes the key event back to you. Needs edit access on the property.",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "Stop counting a GA4 event as a conversion",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id from list_analytics_properties"
          },
          "eventName": {
            "type": "string",
            "description": "the key event to stop counting, e.g. \"sign_up\" — from list_analytics_definitions"
          },
          "confirm": {
            "description": "must be true to actually delete. Without it nothing changes and the key event is described back to you",
            "type": "boolean"
          }
        },
        "required": [
          "property",
          "eventName"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_analytics_custom_metric",
      "title": "Register a GA4 custom metric",
      "description": "Register an event parameter the site ALREADY SENDS as a GA4 custom METRIC, so reports can SUM or AVERAGE it. This is the other half of the pair: a custom DIMENSION makes a value something you can group BY, a custom METRIC makes it something you can TOTAL — 'order_value' as a dimension gives you a list of prices, as a metric it gives you revenue. TELL THE USER TWO THINGS BEFORE CALLING: a GA4 custom metric CANNOT be deleted, only archived, and a property is capped at 50 (125 on Analytics 360) — a duplicate or a typo permanently burns a slot, so run list_analytics_definitions first, which lists metrics beside dimensions. measurementUnit IS REQUIRED and is never guessed for you: STANDARD for a plain count or quantity, CURRENCY for money, SECONDS/MINUTES/HOURS for durations. CURRENCY additionally REQUIRES restrictedMetricType (REVENUE_DATA for money coming in, COST_DATA for money going out) and every other unit REFUSES it — both directions are refused before the call is made, because Google's own error names neither field. METRICS ARE EVENT-SCOPED ONLY, unlike dimensions, which take EVENT | USER | ITEM. Not retroactive: it collects from creation onward. The result is READ BACK from Google, never echoed from the request. Needs edit access on the property. 0 credits.",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "Register a GA4 custom metric",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "NUMERIC GA4 property id from list_analytics_properties — never the G-XXXXXXX Measurement ID"
          },
          "parameterName": {
            "type": "string",
            "description": "the event parameter GA4 already collects, e.g. 'order_value' — NOT the report label. Letters, digits and underscores, starting with a letter, max 40 characters"
          },
          "displayName": {
            "description": "the label shown in reports; defaults to parameterName, max 82 characters",
            "type": "string"
          },
          "measurementUnit": {
            "type": "string",
            "enum": [
              "STANDARD",
              "CURRENCY",
              "FEET",
              "METERS",
              "KILOMETERS",
              "MILES",
              "MILLISECONDS",
              "SECONDS",
              "MINUTES",
              "HOURS"
            ],
            "description": "REQUIRED — Google marks it Required and there is no safe default"
          },
          "restrictedMetricType": {
            "description": "REQUIRED when measurementUnit is CURRENCY, and refused for every other unit",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "COST_DATA",
                "REVENUE_DATA"
              ]
            }
          },
          "scope": {
            "description": "EVENT is the only scope GA4 accepts for a metric, and it is immutable",
            "type": "string",
            "enum": [
              "EVENT"
            ]
          },
          "description": {
            "description": "≤150 characters",
            "type": "string"
          }
        },
        "required": [
          "property",
          "parameterName",
          "measurementUnit"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "archive_analytics_custom_metric",
      "title": "Archive a GA4 custom metric (one-way)",
      "description": "THE ONLY WAY TO RETIRE A GA4 CUSTOM METRIC, and it is ONE-WAY: GA4 publishes no delete and no un-archive for custom metrics anywhere in its API, exactly as for dimensions, so archiving is permanent through every programmatic surface. It frees the slot against the 50-per-property cap, and reports lose the ability to sum or average it. WITHOUT `confirm` IT ARCHIVES NOTHING and instead describes the metric read back from Google — check that against what the user asked for before confirming, because naming the RIGHT metric is the one thing confirm cannot prove. Identify it by parameterName (from list_analytics_definitions), not the report label. A custom DIMENSION of the same name is a DIFFERENT resource and needs archive_analytics_custom_dimension. Needs edit access. 0 credits.",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "Archive a GA4 custom metric (one-way)",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "NUMERIC GA4 property id"
          },
          "parameterName": {
            "type": "string",
            "description": "the event parameter of the metric to archive, e.g. 'order_value'"
          },
          "confirm": {
            "description": "must be true to actually archive — without it nothing is archived and the metric is described back to you",
            "type": "boolean"
          }
        },
        "required": [
          "property",
          "parameterName"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "check_analytics_compatibility",
      "title": "Check a GA4 report will work, before running it",
      "description": "FREE PRE-FLIGHT FOR analytics_report — ask Google whether these dimensions and metrics can appear in ONE report together, BEFORE running the report that would fail. Use it whenever a report combines fields you have not combined before, and especially when one of them is a custom dimension: GA4 has real pairs that are each perfectly valid alone and cannot be queried together, and `analytics_report` deliberately forwards names AS GIVEN (GA4 publishes hundreds of dimensions and every property ships its own custom ones, so validating against a copied list would go stale and start REFUSING names Google accepts). Pass EXACTLY what you would pass to analytics_report — same metrics, same dimensions, same filters — and it returns which fields are INCOMPATIBLE so you can drop one and run the real report once. IT DOES NOT CHECK THAT A NAME EXISTS: an unknown api name is refused by Google outright, and list_analytics_metadata is what resolves names. Read-only, 0 credits.",
      "group": "ads",
      "section": "GOOGLE ANALYTICS (GA4) (2026-08-10)",
      "annotations": {
        "title": "Check a GA4 report will work, before running it",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "NUMERIC GA4 property id"
          },
          "metrics": {
            "description": "the same metrics you would send to analytics_report (defaults to the same three it defaults to)",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "dimensions": {
            "description": "the same dimensions you would send to analytics_report",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "dimensionFilter": {
            "description": "the same GA4 FilterExpression you would send to analytics_report",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "metricFilter": {
            "description": "the same GA4 FilterExpression you would send to analytics_report",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "compatibilityFilter": {
            "description": "narrow the answer — COMPATIBLE returns only the fields that CAN be added",
            "type": "string",
            "enum": [
              "COMPATIBILITY_UNSPECIFIED",
              "COMPATIBLE",
              "INCOMPATIBLE"
            ]
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_analytics_google_ads_links",
      "title": "Which Google Ads accounts a GA4 property is linked to",
      "description": "Which Google Ads accounts a GA4 property is linked to, and whether each link has PERSONALIZED ADVERTISING on — the flag that decides whether GA4 audiences and remarketing events reach Google Ads at all. RUN THIS BEFORE two claims: that a GA4 audience can be remarketed to, and that a Google Ads campaign's missing conversion data is a Google Ads problem. For a GA4-first advertiser the conversions come from this link, so an unlinked property is the usual cause and no error anywhere says so. Read-only, 0 credits. Needs Google Analytics connected.",
      "group": "ads",
      "section": "GA4 <-> GOOGLE ADS: THE LINK, AND THE AUDIENCES THAT TRAVEL ACROSS IT (2026-08-19)",
      "annotations": {
        "title": "Which Google Ads accounts a GA4 property is linked to",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id from list_analytics_properties — never the G-XXXXXXX Measurement ID"
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "link_google_ads_to_analytics",
      "title": "Link a GA4 property to a Google Ads account",
      "description": "LINK a GA4 property to a Google Ads account. This is the prerequisite for two things users constantly ask for and cannot otherwise have: importing GA4 key events as Google Ads CONVERSIONS — which is what gets a smart-bidding campaign past the conversion-tracking refusal for a GA4-first advertiser — and using a GA4 AUDIENCE for remarketing. RE-LINKING AN ACCOUNT THAT IS ALREADY LINKED IS NOT AN ERROR: it reports that and changes nothing, because this is exactly the call an agent makes when something downstream is not working. adsPersonalizationEnabled DEFAULTS TO TRUE deliberately — a link with it off is the silent dead end where the audience exists in GA4 and never appears in Google Ads, with nothing reporting an error. The connected Google account needs EDIT on the GA4 property AND admin on the Google Ads account; without the second Google refuses and the refusal reads like a Hermoso fault. Google can take 24-48 hours before an audience shows up on the other side. 0 credits.",
      "group": "ads",
      "section": "GA4 <-> GOOGLE ADS: THE LINK, AND THE AUDIENCES THAT TRAVEL ACROSS IT (2026-08-19)",
      "annotations": {
        "title": "Link a GA4 property to a Google Ads account",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id from list_analytics_properties"
          },
          "customerId": {
            "type": "string",
            "description": "the 10-digit Google Ads customer id shown top-right in Google Ads; dashes optional"
          },
          "adsPersonalizationEnabled": {
            "description": "defaults to TRUE. Only set false for measurement-only linking — it disables remarketing",
            "type": "boolean"
          }
        },
        "required": [
          "property",
          "customerId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "unlink_google_ads_from_analytics",
      "title": "Unlink Google Ads from a GA4 property",
      "description": "REMOVE the link between a GA4 property and a Google Ads account. The link itself is trivially re-creatable, which is why this is not a one-way-door gate — but what breaks downstream is not: every campaign bidding on a conversion imported from this property loses that signal, and every remarketing list built from a GA4 audience stops refreshing. WITHOUT `confirm` IT CHANGES NOTHING and reports what is actually attached, read back from Google, including whether it is a MANAGER account (in which case the link covers every account underneath). 0 credits.",
      "group": "ads",
      "section": "GA4 <-> GOOGLE ADS: THE LINK, AND THE AUDIENCES THAT TRAVEL ACROSS IT (2026-08-19)",
      "annotations": {
        "title": "Unlink Google Ads from a GA4 property",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id"
          },
          "customerId": {
            "type": "string",
            "description": "the Google Ads customer id, from list_analytics_google_ads_links"
          },
          "confirm": {
            "description": "must be true to actually unlink — without it nothing changes and the link is described back to you",
            "type": "boolean"
          }
        },
        "required": [
          "property",
          "customerId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_analytics_audiences",
      "title": "List the GA4 audiences on a property",
      "description": "The GA4 audiences (remarketing lists) on a property, WITH whether they can actually be used in Google Ads — it reads the Google Ads link state alongside them, because an audience on an unlinked property is a silent dead end that reports no error anywhere. ARCHIVED audiences are not listed, so absence here is not proof one never existed. GA4's PREDEFINED audiences ('All Users', 'Purchasers') ARE returned — verified live against a real property — and they come back with clauseCount 0, because Google defines them internally rather than with filter clauses; that is normal and is not a broken audience. Read-only, 0 credits.",
      "group": "ads",
      "section": "GA4 <-> GOOGLE ADS: THE LINK, AND THE AUDIENCES THAT TRAVEL ACROSS IT (2026-08-19)",
      "annotations": {
        "title": "List the GA4 audiences on a property",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id"
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_analytics_audience",
      "title": "Build a GA4 audience (remarketing list)",
      "description": "BUILD A GA4 AUDIENCE — a remarketing list defined by what people did on the site, which flows into Google Ads across the property's Google Ads link. TELL THE USER TWO THINGS BEFORE CALLING: GA4 marks the DEFINITION, the MEMBERSHIP DURATION and every filter SCOPE immutable, so a wrong audience is archived and rebuilt rather than edited; and it is NOT retroactive — it starts collecting members from creation onward, and Google takes 24-48 hours to populate it. `filterClauses` is GA4's own nested shape and the TOP level of each filterExpression MUST be a single `andGroup` (Google's schema says so and its rejection names no field). Worked example, 'everyone who fired purchase': [{\"clauseType\":\"INCLUDE\",\"simpleFilter\":{\"scope\":\"AUDIENCE_FILTER_SCOPE_ACROSS_ALL_SESSIONS\",\"filterExpression\":{\"andGroup\":{\"filterExpressions\":[{\"orGroup\":{\"filterExpressions\":[{\"eventFilter\":{\"eventName\":\"purchase\"}}]}}]}}}}]. The result says whether the property is linked to Google Ads, because an audience nobody can remarket to is not a finished job. 0 credits.",
      "group": "ads",
      "section": "GA4 <-> GOOGLE ADS: THE LINK, AND THE AUDIENCES THAT TRAVEL ACROSS IT (2026-08-19)",
      "annotations": {
        "title": "Build a GA4 audience (remarketing list)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id"
          },
          "displayName": {
            "type": "string",
            "description": "the audience name shown in GA4 and in Google Ads"
          },
          "description": {
            "type": "string",
            "description": "REQUIRED by Google on the Audience resource — one line saying who is in it"
          },
          "membershipDurationDays": {
            "type": "number",
            "description": "REQUIRED and IMMUTABLE, 1-540. 30 is the usual remarketing window; 540 is GA4's maximum"
          },
          "filterClauses": {
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            },
            "description": "GA4 AudienceFilterClause list — each {clauseType:INCLUDE|EXCLUDE, simpleFilter|sequenceFilter}. See the worked example in the description"
          },
          "exclusionDurationMode": {
            "description": "IMMUTABLE, and it applies to EVERY exclude clause on the audience",
            "type": "string",
            "enum": [
              "EXCLUDE_TEMPORARILY",
              "EXCLUDE_PERMANENTLY"
            ]
          },
          "eventTrigger": {
            "description": "optional {eventName, logCondition} event GA4 logs when a user joins the audience",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          }
        },
        "required": [
          "property",
          "displayName",
          "description",
          "membershipDurationDays",
          "filterClauses"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_analytics_data_streams",
      "title": "GA4 data streams",
      "description": "The GA4 data streams on a property, with the measurement ID (G-...) each one carries and how many Measurement Protocol secrets it already has. THIS IS WHERE A GTAG OR GTM INSTALL GETS ITS ID, and it is the first call before sending any server-side event. A web stream has a measurementId; an app stream has none and uses its firebaseAppId instead, so do not report an app stream as broken for having no G- id. A stream whose secret count could not be read comes back as null, which means unknown and not zero. Read-only, free.",
      "group": "ads",
      "section": "GA4 SERVER-SIDE EVENTS (2026-08-19, fourth wave)",
      "annotations": {
        "title": "GA4 data streams",
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "NUMERIC GA4 property id, from list_analytics_properties"
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_analytics_stream_setup",
      "title": "GA4 install tag and stream settings",
      "description": "THE GTAG SNIPPET TO PASTE, plus what that web stream is actually set to collect. list_analytics_data_streams gives the measurement ID and stops one step short of what someone installing GA4 needs, which is the finished <script> block — Google returns it verbatim and marks it immutable. The same call answers the two questions that come next: whether ENHANCED MEASUREMENT is collecting scrolls, outbound clicks, site search, video, downloads, form interactions and single-page-app page views, and whether client-side REDACTION is stripping emails or query parameters out of recorded URLs. Read the master switch before believing a toggle: if enhanced measurement is off for the stream, every individual toggle is inert whatever it says. WEB STREAMS ONLY — all three resources are web-stream resources, and an app stream is refused by name with what it has instead. Any half that could not be read comes back null, which means unknown and not off. Read-only, free.",
      "group": "ads",
      "section": "GA4 WEB-STREAM SETUP (2026-08-20)",
      "annotations": {
        "title": "GA4 install tag and stream settings",
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "NUMERIC GA4 property id, from list_analytics_properties"
          },
          "dataStream": {
            "description": "stream id, measurement id or display name. Optional when the property has exactly one stream",
            "type": "string"
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_analytics_measurement_protocol_secret",
      "title": "GA4 Measurement Protocol secrets",
      "description": "THE SERVER-SIDE EVENT CHANNEL FOR GA4, the Google twin of the conversions APIs Hermoso already ships for Reddit, Snapchat and OpenAI Ads. A Measurement Protocol secret is the api_secret half of https://www.google-analytics.com/mp/collect; the other half is the stream's measurementId for a web stream or its firebaseAppId for an app stream, and this tool reports whichever applies. action:\"list\" shows every secret with its value, action:\"create\" mints one (displayName is required, and it is the ONLY label a secret ever gets because the values are opaque), action:\"delete\" revokes one and NEEDS confirm:true. TELL THE USER THIS BEFORE THEY DELETE: there is no rotation anywhere in the API, because the value is output-only and patch reaches only the display name. A delete takes effect at once, and anything still sending with the old value stops being recorded SILENTLY, since the Measurement Protocol answers a bad api_secret with a 2xx and drops the hit. Rotating safely means create the new one, move every sender across, then delete the old one. Free.",
      "group": "ads",
      "section": "GA4 WEB-STREAM SETUP (2026-08-20)",
      "annotations": {
        "title": "GA4 Measurement Protocol secrets",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "NUMERIC GA4 property id"
          },
          "action": {
            "description": "defaults to 'list'",
            "type": "string",
            "enum": [
              "list",
              "create",
              "delete"
            ]
          },
          "dataStream": {
            "description": "stream id, measurement id or display name. Optional when the property has exactly one stream; required when it has several, and an ambiguous name is refused rather than first-matched",
            "type": "string"
          },
          "displayName": {
            "description": "REQUIRED for action:'create' — what will be sending with this secret, e.g. \"Shopify webhook\". It is the only label a secret ever gets. May also be used to name one for delete",
            "type": "string"
          },
          "secretId": {
            "description": "for action:'delete', from action:'list' — preferred over displayName because it is unambiguous",
            "type": "string"
          },
          "confirm": {
            "description": "must be true to actually delete",
            "type": "boolean"
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "archive_analytics_audience",
      "title": "Archive a GA4 audience (one-way)",
      "description": "ARCHIVE a GA4 audience, and it is ONE-WAY: GA4 publishes no delete and no un-archive anywhere in its API. Any Google Ads remarketing list sourced from it stops refreshing, and because the definition is IMMUTABLE the audience cannot be recovered by editing — rebuilding means a NEW audience that starts collecting members from scratch, so months of accumulated membership are gone. WITHOUT `confirm` IT ARCHIVES NOTHING and describes the audience read back from Google; check that against what the user asked for, because naming the right audience is the one thing confirm cannot prove. An ambiguous displayName is REFUSED rather than first-matched — GA4 does not enforce unique names and there is no way back. 0 credits.",
      "group": "ads",
      "section": "GA4 WEB-STREAM SETUP (2026-08-20)",
      "annotations": {
        "title": "Archive a GA4 audience (one-way)",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "the NUMERIC GA4 property id"
          },
          "audienceId": {
            "description": "from list_analytics_audiences — preferred, because it is unambiguous",
            "type": "string"
          },
          "displayName": {
            "description": "the audience name, if the id is not to hand. Refused when it matches more than one",
            "type": "string"
          },
          "confirm": {
            "description": "must be true to actually archive",
            "type": "boolean"
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_analytics_channel_group",
      "title": "GA4 channel groups",
      "description": "HOW GA4 DECIDES WHICH BUCKET A CLICK LANDS IN, which is the number an ad studio is judged on and the answer to \"why is my campaign showing as Unassigned\". action:'list' reads every group with its channels in the order GA4 tests them, including Google's own Default channel group — read that first, because a campaign landing in the wrong channel is a rule that does not match rather than missing data. 'create' authors your own group, 'update' changes one, 'delete' removes one (confirm-gated). AUTHORING: `channels` is an ordered list, each with a name and its conditions, and GA4 STOPS AT THE FIRST RULE THAT MATCHES — so a broad rule above a narrow one silently swallows it. A condition is {fieldName, matchType, value} or {fieldName, values:[…]} for a list, plus not:true to invert; an entry that is itself an ARRAY of conditions is OR'ed, and separate entries are AND'ed. THE FIELD NAMES ARE NOT THE REPORT DIMENSIONS: use eachScopeSource, eachScopeMedium, eachScopeCampaignName, eachScopeCampaignId, eachScopeSourcePlatform or eachScopeDefaultChannelGroup — sessionSource, medium and the rest are real GA4 dimensions that Google REFUSES here, and its refusal does not say what to use instead. `primary:true` makes the group the one every report is bucketed by AND unsets whichever group was primary before. Google's own Default channel group cannot be edited and is refused by name. An update REPLACES the whole rule set. Free.",
      "group": "ads",
      "section": "GA4 CHANNEL GROUPS + CALCULATED METRICS (2026-08-20)",
      "annotations": {
        "title": "GA4 channel groups",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "NUMERIC GA4 property id, from list_analytics_properties"
          },
          "action": {
            "description": "defaults to 'list'",
            "type": "string",
            "enum": [
              "list",
              "create",
              "update",
              "delete"
            ]
          },
          "displayName": {
            "description": "the group name shown in GA4, max 80 characters. Required to create; may also name an existing group for update/delete",
            "type": "string"
          },
          "description": {
            "description": "max 256 characters",
            "type": "string"
          },
          "channels": {
            "description": "ordered list of {name, conditions:[…]}. Required to create. On update it REPLACES every channel, so send the ones you want to keep",
            "type": "array",
            "items": {}
          },
          "primary": {
            "description": "make this the group reports are bucketed by. It unsets the previous primary group, changing what every report in the property shows",
            "type": "boolean"
          },
          "channelGroupId": {
            "description": "for update and delete — from action:'list', and unambiguous where a name is not",
            "type": "string"
          },
          "confirm": {
            "description": "must be true to actually delete",
            "type": "boolean"
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_analytics_calculated_metric",
      "title": "GA4 calculated metrics",
      "description": "THE DERIVED NUMBER A MARKETER ACTUALLY REPORTS — cost per purchase, revenue per session, a margin — built from metrics GA4 already collects and then available to analytics_report like any other metric. action:'list' reads them, 'create' adds one, 'update' changes the formula, name, unit or description, 'delete' removes one (confirm-gated). THE ID IS CHOSEN ONCE AND IS PERMANENT: calculatedMetricId becomes the API name reports ask for (calcMetric:your_id), Google marks it output-only afterwards, and renaming is refused by name rather than silently ignored — so pick it deliberately. The formula takes + - * / and parentheses over metric names and plain numbers, referencing at most 5 unique custom metrics; Google answers a syntax error with the exact position. WATCH invalidMetricReference ON THE WAY BACK: a formula naming a metric this property does not collect is created happily and flagged invalid, and any report using it may fail or return unexpected numbers — report that rather than a clean success. Free.",
      "group": "ads",
      "section": "GA4 CHANNEL GROUPS + CALCULATED METRICS (2026-08-20)",
      "annotations": {
        "title": "GA4 calculated metrics",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "property": {
            "type": "string",
            "description": "NUMERIC GA4 property id, from list_analytics_properties"
          },
          "action": {
            "description": "defaults to 'list'",
            "type": "string",
            "enum": [
              "list",
              "create",
              "update",
              "delete"
            ]
          },
          "calculatedMetricId": {
            "description": "the API name, e.g. \"cost_per_purchase\". Required to create and PERMANENT; for update/delete it names the metric",
            "type": "string"
          },
          "displayName": {
            "description": "the name shown in GA4, max 82 characters. Required to create",
            "type": "string"
          },
          "formula": {
            "description": "e.g. \"advertiserAdCost / conversions\". Required to create",
            "type": "string"
          },
          "metricUnit": {
            "description": "STANDARD | CURRENCY | FEET | MILES | METERS | KILOMETERS | MILLISECONDS | SECONDS | MINUTES | HOURS. Required to create",
            "type": "string"
          },
          "description": {
            "description": "max 4096 characters",
            "type": "string"
          },
          "confirm": {
            "description": "must be true to actually delete",
            "type": "boolean"
          }
        },
        "required": [
          "property"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tag_manager_containers",
      "title": "List the Tag Manager containers shared with this brand",
      "description": "The Google Tag Manager containers SHARED WITH THIS BRAND, with the exact containerPath every other Tag Manager tool needs. CALL THIS FIRST. A container is addressed by PATH, \"accounts/<id>/containers/<id>\", never by the GTM-XXXXXX public id a human reads off the snippet: no Tag Manager method accepts the public id, so passing one is refused. Each row carries the public id beside the path so you can match what the user says to what the API wants. THIS IS NOT EVERY CONTAINER THE GOOGLE ACCOUNT CAN SEE: one agency login routinely administers every client's container, and a container is not data about a client, it is control of what runs on their website, so the user ticks which containers belong to THIS brand and only those are reachable. An empty list means nothing is ticked yet: say so and point the user at Settings, Connectors, Google Tag Manager, Manage accounts. Never name or guess a container. Read-only, free. Read-only, 0 credits. Needs Google Tag Manager connected.",
      "group": "ads",
      "section": "GOOGLE TAG MANAGER (2026-08-19)",
      "annotations": {
        "title": "List the Tag Manager containers shared with this brand",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tag_manager_tags",
      "title": "What is actually firing on the site",
      "description": "WHAT IS ACTUALLY FIRING ON THE SITE. Every tag in the container's workspace with its type, whether it is paused, which triggers fire it by name, and the one field that matters most: whether each tag is LIVE on the website or only STAGED in a draft. Also returns the container's triggers so you can reuse one instead of creating a duplicate. USE THIS TO ANSWER \"is conversion tracking actually working\": a Google Ads conversion action created by create_google_ads_conversion_action records nothing until a tag in this container fires it, and this is the only way to see whether that tag exists, whether it is paused, and whether it has been published. THREE VALUES FOR live, AND THEY ARE DIFFERENT ANSWERS: true means it is in the published container version and running, false means it is staged and NOT running yet, and null means the published version could not be read so you cannot tell. Never report null as false. If the response says the container has never been published, nothing in it is running at all. Read-only, free. 0 credits. Needs Google Tag Manager connected.",
      "group": "ads",
      "section": "GOOGLE TAG MANAGER (2026-08-19)",
      "annotations": {
        "title": "What is actually firing on the site",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "containerPath": {
            "type": "string",
            "description": "the \"accounts/<id>/containers/<id>\" path from list_tag_manager_containers, never the GTM-XXXXXX public id, and it must be one SHARED with this brand"
          },
          "workspace": {
            "description": "workspace id or name. Defaults to the Default Workspace, and the answer always states which one it read.",
            "type": "string"
          }
        },
        "required": [
          "containerPath"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tag_manager_tag",
      "title": "Install a tag in the container draft",
      "description": "INSTALL A TAG in the container's draft workspace: a Google Ads conversion tag, a GA4 event, or a third-party pixel. IT DOES NOT GO LIVE. Everything written here lands in a WORKSPACE, which is a draft, and it does not run on the website until a human opens Tag Manager and publishes the container. Staging NEVER publishes: making a tag live is a separate, explicitly confirmed call (publish_tag_manager_container), because publishing changes what executes for every visitor on the live site. Always tell the user the change is staged, and that it does nothing until it is published. TWO THINGS YOU MUST GET FROM THE CONTAINER RATHER THAN INVENT. (1) type is a Tag Manager type string and Google publishes no machine-readable list of them, so Hermoso forwards whatever you pass rather than validating against a list that would go stale and start refusing real types. Get the right one by calling list_tag_manager_tags and copying the type off a tag of the same kind that already exists, or read it in Tag Manager. (2) firingTriggerId is required and must name at least one real trigger id from list_tag_manager_tags, because a tag with no trigger is installed and never runs, which is worse than not installing it. Use create_tag_manager_trigger if the container has no suitable trigger. 0 credits. Needs Google Tag Manager connected.",
      "group": "ads",
      "section": "GOOGLE TAG MANAGER (2026-08-19)",
      "annotations": {
        "title": "Install a tag in the container draft",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "containerPath": {
            "type": "string",
            "description": "the \"accounts/<id>/containers/<id>\" path from list_tag_manager_containers"
          },
          "workspace": {
            "description": "workspace id or name. Defaults to the Default Workspace.",
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "what a human will look for in the Tag Manager UI"
          },
          "type": {
            "type": "string",
            "description": "the Tag Manager tag type string. Copy it off a tag of the same kind already in the container (list_tag_manager_tags) rather than guessing: Google publishes no machine-readable list of these."
          },
          "firingTriggerId": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "REQUIRED, at least one real trigger id from list_tag_manager_tags. A tag with no trigger is installed and never runs."
          },
          "parameter": {
            "description": "Tag Manager parameters, each { type, key, value }. type defaults to \"template\", which is Tag Manager's string type.",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "blockingTriggerId": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "tagFiringOption": {
            "description": "unlimited, oncePerEvent or oncePerLoad",
            "type": "string"
          },
          "paused": {
            "type": "boolean"
          },
          "notes": {
            "type": "string"
          }
        },
        "required": [
          "containerPath",
          "name",
          "type",
          "firingTriggerId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_tag_manager_tag",
      "title": "Change a tag in the container draft",
      "description": "CHANGE AN EXISTING TAG in the draft workspace: rename it, fix a wrong conversion id or label, move it onto a different trigger, or pause it. Like create_tag_manager_tag this lands in a DRAFT and does not reach the website until a human publishes the container. Hermoso reads the tag first and merges your change on top of it, because Tag Manager's update replaces the whole tag and sending only the changed field would silently strip its parameters and triggers. A tag's type cannot be changed: that is deleting one tag and creating another. If somebody edited the tag in Tag Manager after Hermoso read it the change is REFUSED rather than overwriting their edit, and the fix is to read it again and reapply. To stop a tag running, pause it rather than removing its trigger. 0 credits. Needs Google Tag Manager connected.",
      "group": "ads",
      "section": "GOOGLE TAG MANAGER (2026-08-19)",
      "annotations": {
        "title": "Change a tag in the container draft",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "containerPath": {
            "type": "string"
          },
          "workspace": {
            "type": "string"
          },
          "tagId": {
            "type": "string",
            "description": "the tagId from list_tag_manager_tags"
          },
          "name": {
            "type": "string"
          },
          "firingTriggerId": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "parameter": {
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "blockingTriggerId": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "tagFiringOption": {
            "type": "string"
          },
          "paused": {
            "description": "pause the tag rather than removing its trigger, which is how you stop one running",
            "type": "boolean"
          },
          "notes": {
            "type": "string"
          }
        },
        "required": [
          "containerPath",
          "tagId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tag_manager_trigger",
      "title": "Create a trigger in the container draft",
      "description": "CREATE A TRIGGER, the rule that decides when a tag fires, in the container's draft workspace. Use it when list_tag_manager_tags shows no trigger matching what a conversion needs, for example a purchase confirmation page or a dataLayer purchase event. A trigger on its own fires nothing: it has to be named in a tag's firingTriggerId, so create the trigger, then create or update the tag that uses it. Like every other write here it lands in a DRAFT and does not reach the website until a human publishes the container. type must be one of Tag Manager's own trigger types, and an unrecognised one is refused by name with the accepted list, for free, before any request. A customEvent trigger with no customEventFilter fires on EVERY dataLayer event in the container rather than the one you meant, so that is refused too. 0 credits. Needs Google Tag Manager connected.",
      "group": "ads",
      "section": "GOOGLE TAG MANAGER (2026-08-19)",
      "annotations": {
        "title": "Create a trigger in the container draft",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "containerPath": {
            "type": "string"
          },
          "workspace": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "description": "one of Tag Manager's own trigger types, e.g. pageview, domReady, windowLoaded, customEvent, formSubmission, linkClick, click, historyChange, scrollDepth, elementVisibility, timer. An unknown one is refused by name with the full list."
          },
          "filter": {
            "description": "Tag Manager Condition objects, each { type, parameter: [...] }",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "customEventFilter": {
            "description": "REQUIRED for a customEvent trigger, or it would fire on every dataLayer event in the container",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "notes": {
            "type": "string"
          }
        },
        "required": [
          "containerPath",
          "name",
          "type"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tag_manager_versions",
      "title": "Tag Manager version history",
      "description": "THE CONTAINER'S VERSION HISTORY, and the thing to read before proposing to publish or to roll one back. Every version with its id, its name, how many tags, triggers and variables it holds, whether it is archived, and which one is LIVE on the site right now. This is what makes a publish reversible: pass a row's path as publish_tag_manager_container's versionPath to put that version back on the site. The live flag is three-valued — true, false, or null when Google would not tell us which version is live, and null must never be read as false. Read-only. 0 credits. Needs Google Tag Manager connected.",
      "group": "ads",
      "section": "PUBLISHING (2026-08-20) — the one Tag Manager call that reaches the live site",
      "annotations": {
        "title": "Tag Manager version history",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "containerPath": {
            "type": "string",
            "description": "the \"accounts/<id>/containers/<id>\" path from list_tag_manager_containers, never the GTM-XXXXXXX public id"
          }
        },
        "required": [
          "containerPath"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "preview_tag_manager_publish",
      "title": "What would go live",
      "description": "WHAT WOULD HAPPEN IF YOU PUBLISHED — free, read-only, and it publishes nothing. Returns the container, the version live on the site right now and what it holds, the exact list of staged changes (added, updated, deleted) as Tag Manager itself reports them, any merge conflicts, and the one sentence to show a user before asking them to agree. Use it to answer 'what is waiting to go live?' without going anywhere near the publish call. publish_tag_manager_container without confirm returns the same facts, so this is for when you want them with no chance of a publish at all. 0 credits. Needs Google Tag Manager connected.",
      "group": "ads",
      "section": "PUBLISHING (2026-08-20) — the one Tag Manager call that reaches the live site",
      "annotations": {
        "title": "What would go live",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "containerPath": {
            "type": "string",
            "description": "the \"accounts/<id>/containers/<id>\" path from list_tag_manager_containers"
          },
          "workspace": {
            "description": "workspace id or name; defaults to the Default Workspace",
            "type": "string"
          },
          "versionPath": {
            "description": "optional — preview re-publishing an EXISTING version instead of the workspace",
            "type": "string"
          }
        },
        "required": [
          "containerPath"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "publish_tag_manager_container",
      "title": "Publish the container to the live site",
      "description": "PUBLISH THE CONTAINER — the ONE call in Hermoso that changes what runs on the customer's live website for every visitor, immediately. Everything else in Tag Manager is staged; this is not. TWO STEPS UNDER THE HOOD, because Tag Manager publishes a VERSION and not a workspace: Hermoso turns the draft workspace into a version and then publishes that version. CALLING IT WITHOUT confirm PUBLISHES NOTHING and is the correct first move: it returns what is live right now, how many tags, triggers and variables that live version holds, and exactly what would change — read back from Google, not echoed from your request. Show the user that, get an unambiguous yes, then call again. THE GATE SCALES WITH THE DAMAGE. A container that has NEVER been published has nothing that currently works and nothing this can break, so confirm:true alone is enough. A container that ALREADY has a live version is replacing something that works, so it additionally needs confirmPublicId set to that container's GTM-XXXXXXX public id — the id printed on the snippet installed on the website. It cannot be guessed; read it from list_tag_manager_containers or from the preview. IT REFUSES, FOR FREE AND WITHOUT PUBLISHING, in four cases you should relay rather than retry: merge conflicts in the workspace (somebody changed the same thing in the container — resolve in Tag Manager first), a workspace with no changes at all, a version Google reports a COMPILER ERROR in, and a container whose public id could not be read. TO UNDO A PUBLISH, publish the previous version again: pass versionPath (from list_tag_manager_versions) and no workspace is involved at all. The reply names the version that was live before this call in rolledBackTo for exactly that purpose, so a publish is reversible. READ verified BEFORE YOU TELL ANYONE IT IS LIVE. It is three-valued: true means the container's live version was re-read and IS the one just published; false means Google accepted it but something else is live, which can be Tag Manager's own read lag and must be reported as unconfirmed; null means the read-back could not run, so it is not known — never report false or null as published. TWO THINGS THAT SURPRISE PEOPLE: creating the version DELETES the draft workspace it was made from (Tag Manager's own behaviour) and opens a fresh empty one, whose id is in the reply, so any workspace id you were holding is stale afterwards. And publishing needs the PUBLISH right on the container in Tag Manager, Admin, User Management, which is separate from Edit — an account that can stage a tag can still be refused here, and that is a permission on the container, not a broken connection. 0 credits. Needs Google Tag Manager connected.",
      "group": "ads",
      "section": "PUBLISHING (2026-08-20) — the one Tag Manager call that reaches the live site",
      "annotations": {
        "title": "Publish the container to the live site",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "containerPath": {
            "type": "string",
            "description": "the \"accounts/<id>/containers/<id>\" path from list_tag_manager_containers"
          },
          "workspace": {
            "description": "workspace id or name; defaults to the Default Workspace",
            "type": "string"
          },
          "versionPath": {
            "description": "ROLLBACK / RE-PUBLISH: publish this EXISTING version instead of the workspace. From list_tag_manager_versions. Must be a version inside the same container.",
            "type": "string"
          },
          "versionName": {
            "description": "name for the version being created; defaults to a timestamped Hermoso name",
            "type": "string"
          },
          "versionNotes": {
            "description": "notes stored on the version, visible in Tag Manager",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED to publish. Without it nothing is published and you get the blast-radius report instead.",
            "type": "boolean"
          },
          "confirmPublicId": {
            "description": "the container's GTM-XXXXXXX public id, REQUIRED when the container already has a live version. Read it from the preview; it cannot be guessed.",
            "type": "string"
          }
        },
        "required": [
          "containerPath"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_search_console_sites",
      "title": "List the Search Console properties shared with this brand",
      "description": "The Google Search Console properties SHARED WITH THIS BRAND, each with the exact property string every other Search Console tool takes and the connected account's permission level on it. CALL THIS FIRST. A property is EITHER \"sc-domain:example.com\" (a Domain property, covering every scheme and subdomain) OR the full URL-prefix form \"https://example.com/\" including scheme and trailing slash — Google treats those as different properties and one of them will 403, so resolve it here rather than guessing, and never pass a bare domain. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: one login commonly holds a dozen clients' properties, so the user ticks which belong to THIS brand and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at Settings ▸ Connectors ▸ Google Search Console ▸ Manage accounts (or call list_connector_accounts / set_connector_accounts with provider \"google_search_console\"); never name or guess a property. A row whose permissionLevel is siteUnverifiedUser will refuse every later call. Read-only, 0 credits. Needs Google Search Console connected.",
      "group": "research",
      "section": "SEARCH-ENGINE WEBMASTER SURFACES: Search Console · Bing Webmaster · IndexNow (2026-08-18)",
      "annotations": {
        "title": "List the Search Console properties shared with this brand",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_console_performance",
      "title": "What people searched for on Google, and what they clicked",
      "description": "THE REAL SEARCH TERMS — Google Search Console's performance report, and the most useful research tool here for writing copy. `dimensions:[\"query\"]` returns the literal words people typed to reach this brand, with impressions, clicks, CTR and average position; `dimensions:[\"page\",\"query\"]` answers \"which landing page already wins this demand\", i.e. where paid traffic should point. Every other dimension is available too: country, device, searchAppearance, date, hour. USE IT BEFORE mine_angles OR plan_ad — this is first-party MEASURED demand language, not a competitor's guess (an ad library) and not a model (a keyword planner), and it exists nowhere else: organic search terms left Google Analytics with \"not provided\" years ago. startDate and endDate are REQUIRED and are exact YYYY-MM-DD in Pacific time — Google Analytics' relative forms like \"28daysAgo\" are NOT accepted anywhere in Search Console. THREE THINGS TO REPEAT WHENEVER YOU REPORT THESE NUMBERS: (1) rows are the TOP ones, not all of them, by Google's own statement; (2) anonymised queries are WITHHELD, so clicks summed per query will not equal the property total and that gap is not an error; (3) the default dataState \"final\" lags roughly 2-3 days, and \"all\" returns fresh partial data that will still change. Search Console keeps 16 months of history. Read-only, 0 credits.",
      "group": "research",
      "section": "SEARCH-ENGINE WEBMASTER SURFACES: Search Console · Bing Webmaster · IndexNow (2026-08-18)",
      "annotations": {
        "title": "What people searched for on Google, and what they clicked",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact property string from list_search_console_sites — \"sc-domain:example.com\" or \"https://example.com/\", never a bare domain, and it must be one SHARED with this brand"
          },
          "startDate": {
            "type": "string",
            "description": "YYYY-MM-DD, required, Pacific time, inclusive"
          },
          "endDate": {
            "type": "string",
            "description": "YYYY-MM-DD, required, Pacific time, inclusive"
          },
          "dimensions": {
            "description": "group by any of query, page, country, device, searchAppearance, date, hour. Omit for one total row. An unknown name is refused by name rather than dropped.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "type": {
            "description": "web (default), image, video, news, discover or googleNews",
            "type": "string"
          },
          "dimensionFilterGroups": {
            "description": "narrow the report, e.g. [{\"filters\":[{\"dimension\":\"query\",\"operator\":\"contains\",\"expression\":\"buy\"}]}]. dimension is one of query/page/country/device/searchAppearance — date and hour are NOT filterable, they are the date range. operator is equals/notEquals/contains/notContains/includingRegex/excludingRegex. An empty expression is refused: Google matches it literally, so it would silently return nothing.",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "aggregationType": {
            "description": "auto (default), byPage, byProperty or byNewsShowcasePanel",
            "type": "string"
          },
          "dataState": {
            "description": "final (default, lags 2-3 days), all (includes fresh partial data that WILL change), or hourlyAll",
            "type": "string"
          },
          "rowLimit": {
            "description": "1-25000, default 1000",
            "type": "number"
          },
          "startRow": {
            "description": "zero-based, for paging past rowLimit",
            "type": "number"
          }
        },
        "required": [
          "siteUrl",
          "startDate",
          "endDate"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_console_url_inspection",
      "title": "Is this page actually in Google?",
      "description": "THE LIVE INDEX STATUS OF ONE PAGE, straight from Google: the index verdict, coverage state, whether robots.txt allows it, which URL Google picked as canonical versus the one the page itself declares, when it was last crawled and as which crawler, plus rich-result and mobile-usability verdicts. This is the tool for \"why is this page getting no traffic\" — a page Google cannot crawl, or whose canonical Google has reassigned to a different URL, will never rank however good the copy is. THE URL MUST SIT INSIDE THE PROPERTY: a URL-prefix property covers only its own prefix, while an \"sc-domain:\" property covers every scheme and subdomain. 🚨 QUOTA — Google allows 2,000 inspections per DAY and 600 per minute PER SITE, and that is the CUSTOMER'S property quota, not ours: inspect the handful of URLs the question is actually about and NEVER loop this over a sitemap, a page list or a crawl. Read-only, 0 credits.",
      "group": "research",
      "section": "SEARCH-ENGINE WEBMASTER SURFACES: Search Console · Bing Webmaster · IndexNow (2026-08-18)",
      "annotations": {
        "title": "Is this page actually in Google?",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact property string from list_search_console_sites, and it must be one SHARED with this brand"
          },
          "inspectionUrl": {
            "type": "string",
            "description": "the full URL to inspect, e.g. \"https://example.com/pricing\" — it must be under the property"
          },
          "languageCode": {
            "description": "BCP-47 code for translated issue messages, e.g. \"de-CH\". Default en-US.",
            "type": "string"
          }
        },
        "required": [
          "siteUrl",
          "inspectionUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_search_console_sitemaps",
      "title": "List a property’s sitemaps, with submitted-vs-indexed counts",
      "description": "The sitemaps registered on a Search Console property: when each was last submitted and last downloaded by Google, whether Google has processed it yet, its error and warning counts, and per content type how many URLs it SUBMITTED against how many are INDEXED. That last pair is the fastest read on whether a site has an indexing problem rather than a ranking one. Pass `feedpath` for one sitemap instead of the list, or `sitemapIndex` to list the sitemaps inside an index file. An empty list is a real answer — the property has no sitemap registered — and is not a failure. Read-only, 0 credits.",
      "group": "research",
      "section": "SEARCH-ENGINE WEBMASTER SURFACES: Search Console · Bing Webmaster · IndexNow (2026-08-18)",
      "annotations": {
        "title": "List a property’s sitemaps, with submitted-vs-indexed counts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact property string from list_search_console_sites"
          },
          "feedpath": {
            "description": "the FULL url of one sitemap, e.g. \"https://example.com/sitemap.xml\"",
            "type": "string"
          },
          "sitemapIndex": {
            "description": "the FULL url of a sitemap INDEX, to list the sitemaps it contains",
            "type": "string"
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "submit_search_console_sitemap",
      "title": "Submit a sitemap to Google",
      "description": "Tell Google about a sitemap. `feedpath` is the sitemap's COMPLETE URL (\"https://example.com/sitemap.xml\"), never a path — Google addresses a sitemap by its full url. Re-submitting one that is already registered is harmless and simply re-notifies Google. THE ANSWER IS READ BACK FROM GOOGLE, not echoed from the request: it reports whether the sitemap is now actually registered, whether Google has processed it yet, and what errors and warnings it already carries. 0 credits.",
      "group": "research",
      "section": "SEARCH-ENGINE WEBMASTER SURFACES: Search Console · Bing Webmaster · IndexNow (2026-08-18)",
      "annotations": {
        "title": "Submit a sitemap to Google",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact property string from list_search_console_sites"
          },
          "feedpath": {
            "type": "string",
            "description": "the FULL sitemap URL, e.g. \"https://example.com/sitemap.xml\""
          }
        },
        "required": [
          "siteUrl",
          "feedpath"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_search_console_sitemap",
      "title": "Remove a sitemap from a Search Console property",
      "description": "Remove a sitemap from a property. THIS DOES NOT REMOVE THOSE PAGES FROM GOOGLE'S INDEX — it stops Google using that file to discover new ones, and it can be submitted again at any time, so it is reversible. CONFIRM-GATED: called without confirm it removes nothing and reports what the sitemap actually IS, read back from Google (its error and warning counts and how many URLs it covers), so you can check you are aiming at the right one. Re-run with confirm:true to proceed. 0 credits.",
      "group": "research",
      "section": "SEARCH-ENGINE WEBMASTER SURFACES: Search Console · Bing Webmaster · IndexNow (2026-08-18)",
      "annotations": {
        "title": "Remove a sitemap from a Search Console property",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact property string from list_search_console_sites"
          },
          "feedpath": {
            "type": "string",
            "description": "the FULL sitemap URL to remove"
          },
          "confirm": {
            "description": "true to actually remove it. Without this nothing is removed and the sitemap is described back to you.",
            "type": "boolean"
          }
        },
        "required": [
          "siteUrl",
          "feedpath"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "add_search_console_site",
      "title": "Add a property to Search Console",
      "description": "Add a property to the connected Google account. TWO THINGS THAT MUST REACH THE USER, and the answer states both: (1) ADDING IS NOT VERIFYING — the property arrives with the account as an unverified user and EVERY read on it is refused until ownership is proven with a DNS record, an HTML file or a tag, which no API can do and which the user completes in Search Console itself; (2) the new property is NOT yet shared with this brand, so someone has to tick it under Settings ▸ Connectors ▸ Google Search Console ▸ Manage accounts before any tool here can use it. The permission level is READ BACK from Google, so the answer says which of those two states it is actually in. 0 credits.",
      "group": "research",
      "section": "SEARCH-ENGINE WEBMASTER SURFACES: Search Console · Bing Webmaster · IndexNow (2026-08-18)",
      "annotations": {
        "title": "Add a property to Search Console",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "\"sc-domain:example.com\" for a Domain property (covers every scheme and subdomain), or the full URL-prefix form \"https://example.com/\". These are different properties — pick deliberately."
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_search_console_site",
      "title": "Remove a property from Search Console",
      "description": "Remove a property from the connected Google account. THIS LOSES HISTORY: the account loses access to that property's stored Search Console performance data, and re-adding it later means verifying ownership from scratch — Google does NOT restore the 16-month window on re-add. Nothing about the site itself, its pages or its ranking changes. CONFIRM-GATED: the unconfirmed call removes nothing and describes what would be lost, including how many sitemaps this account can currently see on it. The property is also dropped from this brand's shared list, so it cannot linger as a tick pointing at something unreachable. 0 credits.",
      "group": "research",
      "section": "SEARCH-ENGINE WEBMASTER SURFACES: Search Console · Bing Webmaster · IndexNow (2026-08-18)",
      "annotations": {
        "title": "Remove a property from Search Console",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact property string from list_search_console_sites"
          },
          "confirm": {
            "description": "true to actually remove it",
            "type": "boolean"
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_bing_webmaster_sites",
      "title": "List the Bing Webmaster sites shared with this brand",
      "description": "The Bing Webmaster Tools sites SHARED WITH THIS BRAND, each exactly as Bing holds it (e.g. \"https://example.com\"). CALL THIS FIRST — every other Bing tool takes that exact string. THIS IS NOT EVERY SITE THE CONNECTION CAN REACH: Microsoft issues one key per USER covering every site that Bing account verified, so the user ticks which belong to THIS brand and any other one is refused by name. An empty list means nothing is ticked yet — point the user at Settings ▸ Connectors ▸ Bing Webmaster Tools ▸ Manage accounts (or call set_connector_accounts with provider \"bing_webmaster\") and never name or guess a site. A site flagged not verified will refuse every later call. Read-only, 0 credits.",
      "group": "research",
      "section": "BING WEBMASTER TOOLS",
      "annotations": {
        "title": "List the Bing Webmaster sites shared with this brand",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "bing_webmaster_traffic",
      "title": "Bing clicks and impressions",
      "description": "Bing clicks and impressions over time — for the whole site (mode \"site\", the default), for one page (mode \"page\", needs `url`), or for every page under one directory (mode \"directory\", needs `url`). Bing is a smaller share of search than Google, but its index is what Copilot and ChatGPT web search read from, so this is the answer-engine visibility read rather than a second traffic report. An unknown mode is refused by name. Read-only, 0 credits.",
      "group": "research",
      "section": "BING WEBMASTER TOOLS",
      "annotations": {
        "title": "Bing clicks and impressions",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites, and it must be one SHARED with this brand"
          },
          "mode": {
            "description": "site (default) | page | directory",
            "type": "string"
          },
          "url": {
            "description": "required for page and directory modes — the full URL",
            "type": "string"
          },
          "page": {
            "description": "zero-based results page, directory mode",
            "type": "number"
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "bing_webmaster_query_stats",
      "title": "What people searched on Bing, and which pages they landed on",
      "description": "The Bing twin of search_console_performance, split by what you want pinned. Modes: \"queries\" (top search terms for the site, the default), \"pages\" (top pages), \"pageQueries\" (which queries brought traffic to one `page`), \"queryPages\" (which pages one `query` landed on), \"queryPageDetail\" (one query on one page, day by day). Worth reading BESIDE Search Console rather than instead of it — the two engines' demand language genuinely differs, and Bing is the one feeding Copilot and ChatGPT. MICROSOFT UPDATES THIS DATA WEEKLY by their own statement, so it lags Search Console and will not move day to day; do not report a flat week as a drop. An unknown mode is refused by name. Read-only, 0 credits.",
      "group": "research",
      "section": "BING WEBMASTER TOOLS",
      "annotations": {
        "title": "What people searched on Bing, and which pages they landed on",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          },
          "mode": {
            "description": "queries (default) | pages | pageQueries | queryPages | queryPageDetail",
            "type": "string"
          },
          "query": {
            "description": "required for queryPages and queryPageDetail",
            "type": "string"
          },
          "page": {
            "description": "the full page URL — required for pageQueries and queryPageDetail",
            "type": "string"
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "bing_webmaster_keywords",
      "title": "Keyword research with real impression volumes, no ad spend",
      "description": "REAL SEARCH DEMAND FOR ANY TERM, free. Modes: \"keyword\" (impressions for exactly this term over a date range), \"related\" (the same for terms Bing considers related — the one to use for expanding a seed into a list), \"history\" (the term's historical series). This is the closest thing in Hermoso to a free keyword planner: google_ads_keyword_ideas needs Google Ads Basic access, and this needs only the Bing connection anyone can set up in two minutes. NOT SITE-SCOPED — these are Microsoft's signatures and they take no site, so it works for ANY term including a competitor's brand, and no property tick list applies. `country` AND `language` are BOTH REQUIRED and are deliberately never defaulted: a search volume with no market is unactionable, and picking one for you would answer confidently about the wrong country. Read-only, 0 credits.",
      "group": "research",
      "section": "BING WEBMASTER TOOLS",
      "annotations": {
        "title": "Keyword research with real impression volumes, no ad spend",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "q": {
            "type": "string",
            "description": "the keyword to look up, e.g. \"running shoes\""
          },
          "mode": {
            "description": "keyword (default) | related | history",
            "type": "string"
          },
          "country": {
            "type": "string",
            "description": "REQUIRED, e.g. \"us\" — there is no default and one will not be guessed"
          },
          "language": {
            "type": "string",
            "description": "REQUIRED, e.g. \"en-US\""
          },
          "startDate": {
            "description": "YYYY-MM-DD — required for keyword and related modes",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD — required for keyword and related modes",
            "type": "string"
          }
        },
        "required": [
          "q",
          "country",
          "language"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "bing_webmaster_crawl",
      "title": "Can Bing crawl this site?",
      "description": "Whether Bing can actually reach the site's pages: crawl statistics (mode \"stats\"), the URLs with problems (mode \"issues\", the default), one page's index detail (mode \"page\", needs `url`), a directory's (mode \"directory\"), or the crawl settings themselves (mode \"settings\"). Issue names are DECODED from Bing's bitmask into readable ones — Code301, Code302, Code4xx, Code5xx, BlockedByRobotsTxt, ContainsMalware, ImportantUrlBlockedByRobotsTxt, DnsErrors, TimeOutErrors — because the raw JSON hands back an integer that means nothing on its own, and a bit Microsoft has added since is reported as unknown rather than dropped. Microsoft notes it may take a few days for a FIXED issue to disappear from this list, so a listed issue is not proof it is still live. Read-only, 0 credits.",
      "group": "research",
      "section": "BING WEBMASTER TOOLS",
      "annotations": {
        "title": "Can Bing crawl this site?",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          },
          "mode": {
            "description": "issues (default) | stats | page | directory | settings",
            "type": "string"
          },
          "url": {
            "description": "required for page and directory modes",
            "type": "string"
          },
          "page": {
            "description": "zero-based results page, directory mode",
            "type": "number"
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "bing_webmaster_links",
      "title": "Inbound links as Bing sees them",
      "description": "Inbound links according to Bing: counts per page (mode \"counts\", the default), the inbound links pointing at one page (mode \"page\", needs `url`), or the pages the site owner has declared as connected (mode \"connected\"). Read-only, 0 credits.",
      "group": "research",
      "section": "BING WEBMASTER TOOLS",
      "annotations": {
        "title": "Inbound links as Bing sees them",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          },
          "mode": {
            "description": "counts (default) | page | connected",
            "type": "string"
          },
          "url": {
            "description": "required for page mode — the page whose inbound links you want",
            "type": "string"
          },
          "page": {
            "description": "zero-based results page",
            "type": "number"
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "bing_webmaster_submission_quota",
      "title": "How many URLs can still be submitted to Bing today",
      "description": "How many URLs this site may still submit to Bing today and this month, plus the separate content-submission budget. BING SETS THIS PER SITE AND IT VARIES — Microsoft's own documented example allows 5 a day and 24 a month — so it is always read live and never assumed. Check it before promising anyone a bulk submission, because Bing rejects an over-quota batch as a WHOLE rather than taking what fits. Read-only, 0 credits.",
      "group": "research",
      "section": "BING WEBMASTER TOOLS",
      "annotations": {
        "title": "How many URLs can still be submitted to Bing today",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "submit_bing_webmaster_urls",
      "title": "Submit URLs to Bing for indexing",
      "description": "Submit URLs to Bing for indexing — the fastest way to get a new or changed page crawled by Bing, and therefore into Copilot and ChatGPT web search. THE SITE'S REMAINING DAILY QUOTA IS READ FIRST and a batch that would exceed it is refused with the real number rather than half-submitted, because Bing rejects an over-quota batch as a whole and a partial failure leaves you unable to say which URLs made it. Bing accepts at most 500 per batch (Microsoft's own limit) and the site's daily quota is usually far lower. THE CONFIRMATION IS BING'S QUOTA COUNTER MOVING: its submit methods return no body at all, so the answer reports the counter before and after rather than claiming success from a 200. 0 credits.",
      "group": "research",
      "section": "BING WEBMASTER TOOLS",
      "annotations": {
        "title": "Submit URLs to Bing for indexing",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          },
          "urls": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "full URLs on THIS site — a URL on another host is refused before anything is submitted"
          }
        },
        "required": [
          "siteUrl",
          "urls"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_bing_webmaster_sitemaps",
      "title": "The sitemaps Bing has for this site",
      "description": "The sitemaps registered for this site — Bing calls them FEEDS — with the status, URL count, file size and last-crawled date Bing holds for each. This is the read to make BEFORE submitting or removing one, because both of those are addressed by the sitemap's full URL exactly as Bing stores it. Pass `feedUrl` to expand a sitemap INDEX into its child sitemaps (Microsoft's GetFeedDetails); an empty answer for a plain non-index sitemap is a real answer, not a failure. Read-only, 0 credits.",
      "group": "research",
      "section": "THE BING WRITE SURFACE (2026-08-19)",
      "annotations": {
        "title": "The sitemaps Bing has for this site",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          },
          "feedUrl": {
            "description": "optional — the full URL of a sitemap INDEX, to list its children",
            "type": "string"
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "submit_bing_webmaster_sitemap",
      "title": "Register a sitemap with Bing",
      "description": "Register a sitemap with Bing so it discovers new and changed pages by itself — the durable twin of submit_bing_webmaster_urls, which spends a small per-site daily quota every time. Takes the sitemap's FULL URL, not a path. Bing accepts Sitemap, RSS 2.0, Atom 0.3, Atom 1.0 and plain text files (Microsoft's own list). A sitemap hosted on ANOTHER host is not refused — Bing allows that when the other host is also verified — but it is called out, because it is the likeliest reason one silently never crawls. SubmitFeed returns no body whatsoever, so the answer is read back from Bing's own feed list and reports what Bing says rather than that the call returned. 0 credits.",
      "group": "research",
      "section": "THE BING WRITE SURFACE (2026-08-19)",
      "annotations": {
        "title": "Register a sitemap with Bing",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          },
          "feedUrl": {
            "type": "string",
            "description": "the FULL sitemap URL, e.g. \"https://example.com/sitemap.xml\""
          }
        },
        "required": [
          "siteUrl",
          "feedUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "remove_bing_webmaster_sitemap",
      "title": "Remove a sitemap from Bing",
      "description": "Remove a sitemap from Bing. GATED ON BLAST RADIUS, not just on intent: called WITHOUT `confirm` it removes nothing and reports what Bing says that sitemap actually carries — its URL count, status and type — and a sitemap carrying any URLs then ALSO needs `confirmUrlCount` set to that number. confirm:true proves you meant to remove something; the echoed count proves you aimed at the sitemap you inspected rather than one that merely shares a name. A sitemap Bing reports as empty stays a one-call removal. Removing it does NOT remove those pages from Bing's index — Bing simply stops using it to find new ones — and it can be submitted again at any time. If the read-back that confirms the removal cannot run, the answer says \"could not tell\", never \"removed\". 0 credits.",
      "group": "research",
      "section": "THE BING WRITE SURFACE (2026-08-19)",
      "annotations": {
        "title": "Remove a sitemap from Bing",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          },
          "feedUrl": {
            "type": "string",
            "description": "the FULL sitemap URL to remove, exactly as list_bing_webmaster_sitemaps shows it"
          },
          "confirm": {
            "description": "must be true; without it nothing is removed and you get the radius instead",
            "type": "boolean"
          },
          "confirmUrlCount": {
            "description": "the URL count Bing reports for that sitemap — required when it carries any",
            "type": "number"
          }
        },
        "required": [
          "siteUrl",
          "feedUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_bing_webmaster_fetched_urls",
      "title": "What Bingbot fetched on demand",
      "description": "The URLs fetched on demand as Bingbot for this site, or one of them in detail (pass `url`) including what Bingbot actually received. This is how you see a page through the CRAWLER's eyes rather than a browser's — a robots block, a redirect chain or a JS-only page all look fine in a browser and wrong here. An empty list means nothing has been fetched on demand yet, which is a real answer and not a failure. Read-only, 0 credits.",
      "group": "research",
      "section": "THE BING WRITE SURFACE (2026-08-19)",
      "annotations": {
        "title": "What Bingbot fetched on demand",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          },
          "url": {
            "description": "optional — one full URL, for the stored detail of that fetch",
            "type": "string"
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "fetch_bing_webmaster_url",
      "title": "Ask Bingbot to fetch a page now",
      "description": "Ask Bingbot to fetch one URL now, so you can then read back what the crawler actually received with list_bing_webmaster_fetched_urls. THE FETCH IS QUEUED, NOT PERFORMED WHILE YOU WAIT — so \"it has not appeared in the fetched list yet\" is the normal FIRST answer, and it is reported as \"requested, not yet done\" rather than as a failure or as success. A URL that is not on this site is refused before anything is requested. 0 credits.",
      "group": "research",
      "section": "THE BING WRITE SURFACE (2026-08-19)",
      "annotations": {
        "title": "Ask Bingbot to fetch a page now",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          },
          "url": {
            "type": "string",
            "description": "the full URL on THIS site for Bingbot to fetch"
          }
        },
        "required": [
          "siteUrl",
          "url"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "submit_bing_webmaster_content",
      "title": "Hand Bing a page's content directly",
      "description": "Give Bing a page's content DIRECTLY instead of waiting for it to crawl — for a page Bingbot renders badly or cannot reach. YOU PASS THE HTML AND NOTHING ELSE: Microsoft's httpMessage parameter is a base64 raw HTTP response whose status line and every header must end CRLF with exactly two CRLFs before the body, and an LF-only message is accepted with a 200 while indexing nothing useful — a silent wrong answer — so Hermoso builds that framing and computes Content-Length itself. Optional `structuredData` carries JSON-LD for non-HTML content such as images or PDFs; `dynamicServing` is left at \"none\" unless the site really does serve different content per device, because declaring otherwise is a claim about the customer's infrastructure. This spends the CONTENT submission budget, which is separate from the URL one and is read first — check both with bing_webmaster_submission_quota. Max 10MB per submission. 0 credits.",
      "group": "research",
      "section": "THE BING WRITE SURFACE (2026-08-19)",
      "annotations": {
        "title": "Hand Bing a page's content directly",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          },
          "url": {
            "type": "string",
            "description": "the full URL on this site that this content belongs to"
          },
          "html": {
            "type": "string",
            "description": "the page content Bing should index for that URL"
          },
          "status": {
            "description": "HTTP status for the message, default 200",
            "type": "number"
          },
          "contentType": {
            "description": "default text/html",
            "type": "string"
          },
          "headers": {
            "description": "extra response headers. Content-Length is always computed and never taken from you.",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "structuredData": {
            "description": "optional JSON-LD, for non-HTML content types",
            "type": "string"
          },
          "dynamicServing": {
            "description": "none (default) | pc-laptop | mobile | amp | tablet | non-visual-browser",
            "type": "string"
          }
        },
        "required": [
          "siteUrl",
          "url",
          "html"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "add_bing_webmaster_site",
      "title": "Add a site to the Bing Webmaster account",
      "description": "Add a site to the connected Bing Webmaster account. 🚨 ADDING IS NOT VERIFYING, and saying so is most of this tool's value: the site arrives UNVERIFIED and every read on it is refused until an ownership proof is placed on the site itself — an XML file at the root, a meta tag in the home page <head>, or a CNAME DNS record — which no API can do. Place one, then call verify_bing_webmaster_site. Microsoft documents that adding a site which is already there does NOT error, so a success here is not even evidence anything changed, which is why the answer is read back from Bing's site list. It is also NOT shared with this brand until the user ticks it under Settings ▸ Connectors ▸ Bing Webmaster Tools ▸ Manage accounts. 0 credits.",
      "group": "research",
      "section": "THE BING WRITE SURFACE (2026-08-19)",
      "annotations": {
        "title": "Add a site to the Bing Webmaster account",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the site with its scheme, e.g. \"https://example.com\""
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "verify_bing_webmaster_site",
      "title": "Ask Bing to verify site ownership",
      "description": "Ask Bing to CHECK the ownership proof for a site already on the account. IT DOES NOT PLACE THE PROOF — nothing can over an API. The user puts an XML file at the site root, a meta tag in the home page <head>, or a CNAME DNS record, and Bing Webmaster Tools shows the exact filename and value for each. A negative answer therefore means \"the proof is not in place yet\", never that Hermoso or the connection failed, and the reply says which three options exist. Bing refuses to verify a site that was never added, which is knowable for free, so that is refused here with add_bing_webmaster_site named as the fix. The verdict is read back from the site LIST rather than taken from the call's own return value — the two can disagree. 0 credits.",
      "group": "research",
      "section": "THE BING WRITE SURFACE (2026-08-19)",
      "annotations": {
        "title": "Ask Bing to verify site ownership",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the site to verify, exactly as it was added"
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "remove_bing_webmaster_site",
      "title": "Remove a site from the Bing Webmaster account",
      "description": "Remove a site from the connected Bing Webmaster account — the heaviest thing in this connector. GATED ON BLAST RADIUS: called WITHOUT `confirm` it removes nothing and reports whether Bing holds the site as VERIFIED and how many sitemaps are registered on it; a site that is verified or has any sitemaps then ALSO needs `confirmSiteUrl` set to that exact site string, which is a fact you only have after inspecting it. A site that was never verified and has no sitemaps is the empty radius and stays a one-call removal — friction that does not scale with the loss just gets routed around. RE-ADDING A VERIFIED SITE LATER MEANS PROVING OWNERSHIP FROM SCRATCH; no API restores it. Nothing about the site itself or its Bing ranking changes. The site is also dropped from this brand's shared list, and if the read-back that confirms the removal cannot run the answer says \"could not tell\", never \"removed\". 0 credits.",
      "group": "research",
      "section": "THE BING WRITE SURFACE (2026-08-19)",
      "annotations": {
        "title": "Remove a site from the Bing Webmaster account",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "siteUrl": {
            "type": "string",
            "description": "the exact site string from list_bing_webmaster_sites"
          },
          "confirm": {
            "description": "must be true; without it nothing is removed and you get the radius instead",
            "type": "boolean"
          },
          "confirmSiteUrl": {
            "description": "the exact site string again — required unless the site is unverified with no sitemaps",
            "type": "string"
          }
        },
        "required": [
          "siteUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "submit_indexnow",
      "title": "Tell the IndexNow search engines a page changed",
      "description": "Ping the IndexNow search engines that pages were added, updated or deleted — Bing, Yandex, Naver, Seznam.cz and Yep, all reached by ONE submission. 🚨 GOOGLE DOES NOT PARTICIPATE IN INDEXNOW and never has, so say that plainly rather than letting anyone expect a Google effect; for Google use submit_search_console_sitemap and search_console_url_inspection. NO CONNECTION IS NEEDED, because the credential belongs to the USER: they generate a key, host it as a plain text file on their own web server, and any engine fetching that file is the whole ownership check. HERMOSO VERIFIES THAT FILE BEFORE SUBMITTING — that it is reachable and that its contents are exactly the key — and refuses with an explanation if not, because we have no write access to a customer's web root and cannot place it for them. If the key file is moved off the site root with `keyLocation`, it authorises ONLY URLs under that directory, and URLs outside it are refused before anything is sent (IndexNow rejects the whole batch for one out-of-scope URL). Up to 10,000 URLs per submission, all on the one host. HTTP 202 is a SUCCESS (\"received, validation pending\"), not a reason to retry. 0 credits.",
      "group": "research",
      "section": "INDEXNOW",
      "annotations": {
        "title": "Tell the IndexNow search engines a page changed",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "host": {
            "type": "string",
            "description": "the bare hostname that owns these URLs, e.g. \"example.com\""
          },
          "key": {
            "type": "string",
            "description": "the IndexNow key — 8-128 letters, numbers and dashes — ALREADY hosted as <key>.txt on that site"
          },
          "urls": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "the full URLs that were added, updated or deleted, all on `host`"
          },
          "keyLocation": {
            "description": "the full URL of the key file if it is NOT at the site root. Moving it NARROWS the key to that directory only.",
            "type": "string"
          }
        },
        "required": [
          "host",
          "key",
          "urls"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_posthog_projects",
      "title": "List the PostHog projects this key can see",
      "description": "The PostHog projects the connected personal API key can see, and which ONE this brand is pointed at. Only the ACTIVE project is readable. That is deliberate: PostHog's API otherwise falls back to \"the last project you visited in the UI\", which would make every answer depend on the user's browsing history, so Hermoso pins a project at connect time instead of relying on their implicit default. To move this brand to a different project, the user reconnects PostHog under Settings ▸ Connectors ▸ PostHog with that project id. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "List the PostHog projects this key can see",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "posthog_query",
      "title": "Ask PostHog a question in HogQL",
      "description": "ASK POSTHOG A QUESTION IN HogQL — their SQL dialect over the `events`, `persons` and `sessions` tables. This is the lane for per-user funnels, retention and any breakdown GA4 cannot express, e.g. `SELECT properties.$current_url, count() FROM events WHERE event = '$pageview' AND timestamp > now() - INTERVAL 7 DAY GROUP BY 1 ORDER BY 2 DESC`. IT IS NOT AN EXPORTER AND MUST NEVER BE LOOPED — PostHog's own policy, verbatim: \"Third-party connectors must use batch exports, not /query. Connectors built on /query are not supported and will be rate-limited or rejected.\" Ask ONE bounded question. Do not paginate it, do not schedule it, and do not call it repeatedly to assemble a whole table; if the user genuinely needs bulk data, tell them to set up a PostHog batch export. Every query is capped at 1000 rows, and OFFSET is refused outright because PostHog returns HTTP 400 for it on API keys — use keyset pagination on `timestamp` (events) or `id` (persons) if a second page is truly needed. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Ask PostHog a question in HogQL",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "a HogQL (ClickHouse-flavoured SQL) query — one bounded question, never a page of an export"
          },
          "limit": {
            "description": "row cap, 1–1000 (default 1000). A LIMIT already in the query is honoured and clamped to the same ceiling",
            "type": "number"
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "posthog_insight",
      "title": "Read a saved PostHog insight (funnel / retention / trend)",
      "description": "Read back an insight the user already BUILT in their PostHog UI — funnels, retention curves, trends and paths. Call with NO id to LIST the saved insights with their names and ids; call with insightId for that insight's definition and computed result. THIS IS THE RIGHT WAY TO ANSWER A FUNNEL OR RETENTION QUESTION when the report already exists, and the reason is a documentation fact rather than a preference: PostHog's typed query kinds (FunnelsQuery, RetentionQuery, PathsQuery) are undocumented — their own docs say those \"are mostly used to power PostHog internally and are not useful for you\" and publish no request payload, no field table and no example for any of them, so building on them would be a private API that can change without notice. The two supported routes are HogQL (posthog_query) and a saved insight (this). Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Read a saved PostHog insight (funnel / retention / trend)",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "insightId": {
            "description": "omit to list the saved insights and their ids",
            "type": "string"
          },
          "refresh": {
            "description": "default true (recompute if stale); false = return the cached result even if stale",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_posthog_session_recordings",
      "title": "List PostHog session replays",
      "description": "SESSION REPLAYS — the capability GA4 has no equivalent of at all. Lists recent recordings with who they belong to, how long they ran, how many clicks / keypresses / CONSOLE ERRORS each had, the URL they started on, and a link that opens the replay in PostHog. USE THE SIGNALS RATHER THAN JUST LISTING THEM: a session with console errors and a high keypress count on a checkout page is a bug report with a video attached, and that is the insight worth surfacing. THE RAW REPLAY IS NOT AVAILABLE OVER THE API — PostHog's words: \"This endpoint does not provide the raw JSON of the replays. To get the raw JSON, you need to click Export as JSON in the replay options menu in-app.\" So describe and link; never promise a download and never claim to have watched one. Links need the user's own PostHog login. A publicly shareable link is minted in the PostHog UI and deliberately not by an agent, because it publishes a real person's session and PostHog themselves say they \"make no guarantees about sensitive information contained in the recording\". Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "List PostHog session replays",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "1–100, default 20",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "posthog_persons",
      "title": "Look up people in PostHog",
      "description": "Look up PEOPLE in PostHog by distinct id, email or a free-text search, with their properties. This is the per-user half GA4 cannot do: resolve a specific customer, then use their distinct id inside posthog_query to see exactly what that one human did. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Look up people in PostHog",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "distinctId": {
            "description": "PostHog distinct id",
            "type": "string"
          },
          "email": {
            "description": "exact email match",
            "type": "string"
          },
          "search": {
            "description": "free-text search across person properties",
            "type": "string"
          },
          "limit": {
            "description": "1–100, default 20",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "mixpanel_insights",
      "title": "Read a saved Mixpanel report",
      "description": "READ BACK A SAVED MIXPANEL REPORT by its bookmark id — and this is the PREFERRED Mixpanel lane, not a fallback. Mixpanel has put BOTH its Segmentation and its Funnels query APIs in maintenance mode and recommends in their place that the report is built in the Mixpanel UI and read programmatically through Insights, which is exactly what this does. THE BOOKMARK ID MUST COME FROM THE USER: Mixpanel publishes no endpoint that lists saved reports, so ask them to open the report in Mixpanel and copy the id out of its URL (the part after \"report-\"). Remember Mixpanel allows only 60 QUERIES PER HOUR across its entire Query API — the tightest budget of any connector here — so reuse an answer rather than re-asking. READ BACK dateRange, do not assume the window: a saved report's date range is configured in Mixpanel's own UI and not by this call, and it comes back stamped with the PROJECT's UTC offset — the only place this connector can observe that timezone at all. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Read a saved Mixpanel report",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "bookmarkId": {
            "type": "string",
            "description": "the saved report's bookmark id, from its URL in Mixpanel"
          }
        },
        "required": [
          "bookmarkId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "mixpanel_retention",
      "title": "Mixpanel retention cohorts",
      "description": "MIXPANEL RETENTION — how many of the people who did a first thing came back and did another, cohorted by day / week / month. This endpoint is FULLY SUPPORTED, unlike segmentation and funnels, which Mixpanel has put in maintenance mode — so it is the one typed Mixpanel report to reach for first. retentionType \"birth\" cohorts people by their FIRST occurrence of bornEvent (new-user retention, and Mixpanel's DEFAULT); \"compounded\" counts anyone active. ⚠️ MIXPANEL REQUIRES bornEvent WHENEVER retentionType IS \"birth\", AND BIRTH IS THE DEFAULT — so a call with neither is refused HERE, for free, rather than spending one of the sixty hourly queries on their 400; use list_mixpanel_events first to name a real event. TWO FILTERS, NOT ONE: bornWhere filters who ENTERS the cohort, where filters the RETURNING event. And interval is the WIDTH of each bucket while intervalCount is HOW MANY of them — different knobs. Dates are YYYY-MM-DD and BOTH ENDS ARE INCLUSIVE, resolved in the PROJECT's timezone (UTC unless its owner changed it) rather than in yours. 60 queries/hour across the whole Query API (5 concurrent) — the tightest budget of any connector here, so widen a range rather than looping over days. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Mixpanel retention cohorts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "fromDate": {
            "description": "YYYY-MM-DD (default 30 days ago)",
            "type": "string"
          },
          "toDate": {
            "description": "YYYY-MM-DD (default today)",
            "type": "string"
          },
          "retentionType": {
            "description": "\"birth\" = cohort by first occurrence of bornEvent; \"compounded\" = anyone active",
            "type": "string",
            "enum": [
              "birth",
              "compounded"
            ]
          },
          "bornEvent": {
            "description": "the event that puts someone into the cohort — REQUIRED by Mixpanel when retentionType is \"birth\", which is the default",
            "type": "string"
          },
          "event": {
            "description": "the returning event; omitted means any event",
            "type": "string"
          },
          "unit": {
            "description": "the interval unit (default day)",
            "type": "string",
            "enum": [
              "day",
              "week",
              "month"
            ]
          },
          "interval": {
            "description": "the WIDTH of each bucket, in DAYS. Mixpanel cannot take this together with \"unit\" — it answers HTTP 500 to the pair, so pass one or the other (default: interval 1 day)",
            "type": "number"
          },
          "intervalCount": {
            "description": "HOW MANY buckets to return (default 1). A \"0th\" bucket is always included for events inside the first interval",
            "type": "number"
          },
          "bornWhere": {
            "description": "a Mixpanel segmentation expression filtering who ENTERS the cohort (the born event)",
            "type": "string"
          },
          "where": {
            "description": "a Mixpanel segmentation expression filtering the RETURNING event",
            "type": "string"
          },
          "on": {
            "description": "a property expression to segment the returning event on — this is what breaks a retention curve down by e.g. plan or platform",
            "type": "string"
          },
          "limit": {
            "description": "top N segmentation values; does nothing unless \"on\" is set",
            "type": "number"
          },
          "unboundedRetention": {
            "description": "accumulate right-to-left, so day N means \"retained on day N or any day after\"",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "mixpanel_segmentation",
      "title": "Mixpanel event segmentation (maintenance mode)",
      "description": "One Mixpanel event over time, optionally broken down by a property (`on`) and filtered (`where`). ⚠️ MIXPANEL HAS PUT THIS ENDPOINT IN MAINTENANCE MODE — their words: \"We recommend discontinuing new use of this endpoint. To break down and filter event data, build an Insights report in-app and query it programmatically with the Insights Query API.\" It still answers today, which is why it is offered rather than withheld, but SAY SO when you use it and prefer mixpanel_insights whenever the user can build the report. It takes ONE event name and not an array (the /events endpoints take an array; this one does not — an easy and silent mistake). 60 queries/hour across the whole Query API. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Mixpanel event segmentation (maintenance mode)",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "event": {
            "type": "string",
            "description": "a SINGLE event name — not an array"
          },
          "fromDate": {
            "description": "YYYY-MM-DD (default 30 days ago)",
            "type": "string"
          },
          "toDate": {
            "description": "YYYY-MM-DD (default today)",
            "type": "string"
          },
          "on": {
            "description": "a segmentation expression to break down by, e.g. properties[\"$browser\"]",
            "type": "string"
          },
          "where": {
            "description": "a segmentation expression to filter by",
            "type": "string"
          },
          "unit": {
            "description": "NOTE: Mixpanel offers no \"week\" on segmentation, unlike retention",
            "type": "string",
            "enum": [
              "minute",
              "hour",
              "day",
              "month"
            ]
          },
          "interval": {
            "description": "the number of days each bucket covers — Mixpanel offers this in lieu of \"unit\" when \"type\" is not \"general\"",
            "type": "number"
          },
          "type": {
            "type": "string",
            "enum": [
              "general",
              "unique",
              "average"
            ]
          },
          "limit": {
            "description": "top N property values; Mixpanel defaults to 60, max 10000, and it does nothing unless \"on\" is set",
            "type": "number"
          }
        },
        "required": [
          "event"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "mixpanel_funnel",
      "title": "Read a saved Mixpanel funnel (maintenance mode)",
      "description": "A saved Mixpanel funnel. Call with NO id to LIST the saved funnels and their ids; call with funnelId to read its conversion data. ⚠️ MIXPANEL HAS PUT THE FUNNELS QUERY API IN MAINTENANCE MODE — their words: \"We recommend discontinuing new use of this endpoint. To get funnel data, build a Funnels report in-app and query it programmatically with the Insights Query API.\" So prefer mixpanel_insights with that report's bookmark id; this is offered because it still answers and because it is the only way to LIST a project's funnels. `length` IS BOUNDED AT 90 DAYS, WHICH IS NOT THE NUMBER 90: it counts lengthUnits, so 90 days is 2160 hours or 129600 minutes, and an over-long window is REFUSED BY NAME rather than trimmed — silently shortening it would answer a different question with nothing to show that it had happened. Omit both and Mixpanel uses whatever the funnel was saved with in its own UI, which is usually what you want. Dates are YYYY-MM-DD and BOTH ENDS ARE INCLUSIVE, resolved in the PROJECT's timezone (UTC unless its owner changed it) rather than in yours. 60 queries/hour across the whole Query API (5 concurrent) — the tightest budget of any connector here, so widen a range rather than looping over days. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Read a saved Mixpanel funnel (maintenance mode)",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "funnelId": {
            "description": "omit to list the saved funnels and their ids",
            "type": "string"
          },
          "fromDate": {
            "description": "YYYY-MM-DD (default 30 days ago)",
            "type": "string"
          },
          "toDate": {
            "description": "YYYY-MM-DD (default today)",
            "type": "string"
          },
          "length": {
            "description": "the conversion window, counted in lengthUnits — the TOTAL may not exceed 90 days. Omit to use the funnel own saved value",
            "type": "number"
          },
          "lengthUnit": {
            "description": "the unit \"length\" is counted in (Mixpanel offers no week or month here). Omit to use the funnel own saved value",
            "type": "string",
            "enum": [
              "second",
              "minute",
              "hour",
              "day"
            ]
          },
          "unit": {
            "description": "the bucket the results are grouped into",
            "type": "string",
            "enum": [
              "day",
              "week",
              "month"
            ]
          },
          "interval": {
            "description": "the number of days each bucket covers — an alternative to \"unit\" (default 1)",
            "type": "number"
          },
          "on": {
            "description": "a property expression to break the funnel down by",
            "type": "string"
          },
          "where": {
            "description": "a segmentation expression to filter by",
            "type": "string"
          },
          "limit": {
            "description": "top N property values; Mixpanel defaults to 255, max 10000, and it does nothing unless \"on\" is set",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_mixpanel_events",
      "title": "List Mixpanel events (or one event’s properties)",
      "description": "The EVENT VOCABULARY of the Mixpanel project — the most common event names over THE LAST 31 DAYS, so a query names something the project actually records rather than a guess. Start here before any other Mixpanel call. Pass `event` to list THAT event's top property names instead; the two are different questions with different answers, and a failure to read properties is reported AS a failure and never as \"this event has no properties\". Pass window \"today\" for Mixpanel's separate today-only endpoint, which carries counts and the percent change from yesterday — that is a DIFFERENT and far narrower question, and on a quiet project (or simply early in the project's own timezone day) it legitimately returns nothing while the project still records dozens of event types, so NEVER read an empty today answer as \"this project has no events\". 60 queries/hour across the whole Query API (5 concurrent) — the tightest budget of any connector here, so widen a range rather than looping over days. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "List Mixpanel events (or one event’s properties)",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "event": {
            "description": "pass an event name to list ITS properties instead of listing events",
            "type": "string"
          },
          "window": {
            "description": "\"vocabulary\" (default) = the most common events over the last 31 days, names only. \"today\" = today only, with counts and the change from yesterday — a much narrower question",
            "type": "string",
            "enum": [
              "vocabulary",
              "today"
            ]
          },
          "type": {
            "type": "string",
            "enum": [
              "general",
              "unique",
              "average"
            ]
          },
          "limit": {
            "description": "Mixpanel own defaults are 255 for the vocabulary, 100 for today and 10 for properties",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "amplitude_segmentation",
      "title": "Amplitude event segmentation",
      "description": "AMPLITUDE EVENT SEGMENTATION — an event over time, with filters and group-bys. metric is uniques / totals / pct_dau / average / histogram / sums / value_avg / formula (and \"formula\" additionally REQUIRES `formula`, or Amplitude refuses it). Dates take YYYY-MM-DD or YYYYMMDD. AMPLITUDE'S RATE LIMIT IS COST-BASED, not per-request: cost = days × conditions × query type, 1000 per 5-minute window, so one very wide query can exhaust the budget on its own — the fix for a 429 here is to NARROW the range or the conditions, not to retry. AN EMPTY RESULT IS A VALID ANSWER, not a failure: a new or low-traffic Amplitude project genuinely has no data, and reporting that as a broken connection sends the user to fix something that is working. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Amplitude event segmentation",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "events": {
            "description": "one or two event specs: a plain name, or {eventType, filters:[{subprop_type,subprop_key,subprop_op,subprop_value}], groupBy:[…]}",
            "type": "array",
            "items": {}
          },
          "event": {
            "description": "shorthand for a single event name",
            "type": "string"
          },
          "metric": {
            "description": "default uniques",
            "type": "string",
            "enum": [
              "uniques",
              "totals",
              "pct_dau",
              "average",
              "histogram",
              "sums",
              "value_avg",
              "formula"
            ]
          },
          "formula": {
            "description": "required when metric is \"formula\"",
            "type": "string"
          },
          "start": {
            "description": "YYYYMMDD or YYYY-MM-DD (default 30 days ago)",
            "type": "string"
          },
          "end": {
            "description": "YYYYMMDD or YYYY-MM-DD (default today)",
            "type": "string"
          },
          "interval": {
            "description": "-300000 realtime, -3600000 hourly, 1 daily, 7 weekly, 30 monthly",
            "type": "number"
          },
          "groupBy": {
            "type": "string"
          },
          "limit": {
            "description": "≤1000",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "amplitude_funnel",
      "title": "Amplitude funnel (conversion + drop-off)",
      "description": "AMPLITUDE FUNNELS — the richest documented funnel surface of any analytics connector here, and the reason Amplitude is worth reaching for on a conversion question. Pass `events` as an ORDERED array of at least two steps. mode is ordered / unordered / sequential. conversionWindowSeconds is how long a user has to complete the funnel and DEFAULTS TO 2,592,000 SECONDS (30 DAYS) — ALWAYS state which window a conversion rate was measured over, because a 30-day window makes a funnel look dramatically healthier than a same-session one and the difference is invisible in the number itself. At most ONE group-by: segmentation allows two, funnels do not, and a second is refused by name rather than silently dropped. Returns step-by-step and cumulative conversion plus median and average transition times. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Amplitude funnel (conversion + drop-off)",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "events": {
            "type": "array",
            "items": {},
            "description": "ordered steps — plain event names, or {eventType, filters:[…]} objects"
          },
          "mode": {
            "description": "default ordered",
            "type": "string",
            "enum": [
              "ordered",
              "unordered",
              "sequential"
            ]
          },
          "conversionWindowSeconds": {
            "description": "Amplitude default is 2592000 (30 days) — say which window you used",
            "type": "number"
          },
          "start": {
            "description": "YYYYMMDD or YYYY-MM-DD (default 30 days ago)",
            "type": "string"
          },
          "end": {
            "description": "YYYYMMDD or YYYY-MM-DD (default today)",
            "type": "string"
          },
          "groupBy": {
            "description": "at most ONE — a second is refused",
            "type": "string"
          }
        },
        "required": [
          "events"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "amplitude_retention",
      "title": "Amplitude retention",
      "description": "AMPLITUDE RETENTION — of the people who did startEvent, how many came back and did returnEvent. The two magic values are \"_new\" (first-time users) and \"_active\" (any active user), which is what makes \"new-user retention\" a single call. retentionMode is n-day / bracket / rolling, and \"bracket\" additionally REQUIRES `brackets` — it is refused without them rather than silently switched to another mode, because a retention curve computed under a different definition than the one asked for is a wrong answer that looks right. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Amplitude retention",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "startEvent": {
            "description": "\"_new\" (default) or an event name",
            "type": "string"
          },
          "returnEvent": {
            "description": "\"_active\" (default) or an event name",
            "type": "string"
          },
          "start": {
            "description": "YYYYMMDD or YYYY-MM-DD (default 30 days ago)",
            "type": "string"
          },
          "end": {
            "description": "YYYYMMDD or YYYY-MM-DD (default today)",
            "type": "string"
          },
          "retentionMode": {
            "type": "string",
            "enum": [
              "n-day",
              "bracket",
              "rolling"
            ]
          },
          "brackets": {
            "description": "required when retentionMode is \"bracket\"",
            "type": "array",
            "items": {
              "type": "number"
            }
          },
          "interval": {
            "description": "1 daily, 7 weekly, 30 monthly",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "amplitude_active_users",
      "title": "Amplitude active / new users",
      "description": "Amplitude ACTIVE or NEW user counts over a date range — the top-line \"is the product growing\" number, and the cheapest Amplitude call to run first after connecting because it proves the credential with no setup. metric is \"active\" or \"new\"; interval 1 daily, 7 weekly, 30 monthly. An empty series on a new project is the CORRECT answer and means the connection works and there is no data yet — never report it as a failure. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Amplitude active / new users",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "start": {
            "description": "YYYYMMDD or YYYY-MM-DD (default 30 days ago)",
            "type": "string"
          },
          "end": {
            "description": "YYYYMMDD or YYYY-MM-DD (default today)",
            "type": "string"
          },
          "metric": {
            "description": "default active",
            "type": "string",
            "enum": [
              "active",
              "new"
            ]
          },
          "interval": {
            "description": "1 daily, 7 weekly, 30 monthly",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "amplitude_user_activity",
      "title": "One person’s Amplitude activity stream",
      "description": "ONE PERSON'S ACTIVITY STREAM in Amplitude — the per-user lane, and the kind of question GA4 cannot answer at all. Pass a user id, device id or user-id PREFIX and it SEARCHES; pass a numeric Amplitude ID and it returns that user's recent events with their properties. Two steps, because Amplitude splits them across two endpoints: search resolves a human-typed identifier to an Amplitude ID, and only that id reads an activity stream. NOTE these two endpoints sit on a DIFFERENT rate limit from the rest of Amplitude — flat counts (10 concurrent, 360 queries/hour) rather than the cost model — so they do not consume the segmentation budget and are not protected by it either. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "One person’s Amplitude activity stream",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "user": {
            "type": "string",
            "description": "a numeric Amplitude ID to read activity, or a user id / device id / user-id prefix to search"
          },
          "search": {
            "description": "force a search even when the value is numeric",
            "type": "boolean"
          },
          "limit": {
            "type": "number"
          }
        },
        "required": [
          "user"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_amplitude_events",
      "title": "Amplitude taxonomy (declared events and properties)",
      "description": "The Amplitude TAXONOMY — the project's declared events, event properties, user properties or group properties, so a query names something real instead of a guess. category is event (default) / category / event-property / user-property / group-property. IF AMPLITUDE REFUSES THIS, REPORT THEIR REFUSAL AND MOVE ON. The Taxonomy API is widely believed to require an enterprise/Govern entitlement, but Amplitude documents no such gating on either the taxonomy page or the Dashboard API page (both checked), so a 403 here is surfaced as AMPLITUDE'S OWN message and is never presented as a Hermoso plan rule, as a broken connection, or as a fact about the user's plan that we cannot actually know. Every other Amplitude tool is unaffected by that refusal. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Amplitude taxonomy (declared events and properties)",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "category": {
            "description": "default \"event\"",
            "type": "string",
            "enum": [
              "category",
              "event",
              "event-property",
              "user-property",
              "group-property"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "amplitude_chart",
      "title": "Read a saved Amplitude chart",
      "description": "READ BACK A SAVED AMPLITUDE CHART by its id — the chart the user already built in Amplitude's own UI, with whatever segments, filters and date range they configured there. This is Amplitude's equivalent of posthog_insight and mixpanel_insights, and it is the right lane whenever the report already exists: you inherit their definitions instead of rebuilding them from a segmentation query, and it costs one call rather than several. THE CHART ID MUST COME FROM THE USER — Amplitude publishes no endpoint that lists a project's charts — and it is the segment after '/chart/' in the chart's URL (e.g. 'abc123' in https://analytics.amplitude.com/demo/chart/abc123); pasting the whole URL works too. THE RESPONSE SHAPE VARIES BY CHART TYPE, in Amplitude's own words, so read what comes back rather than assuming a series: it may be JSON or a text/CSV body, and BOTH are correct answers rather than one being an error. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Read a saved Amplitude chart",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "chartId": {
            "type": "string",
            "description": "the saved chart's id, or its full URL — the id is the segment after /chart/"
          }
        },
        "required": [
          "chartId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_amplitude_annotations",
      "title": "List Amplitude chart annotations",
      "description": "The markers already on this Amplitude project's charts — releases, campaigns, incidents — with their timestamps, labels and categories. READ THESE BEFORE EXPLAINING A CHANGE IN A CHART: a spike that lines up with an annotation has an explanation somebody already wrote down, and reporting it as a mystery when the answer is sitting on the timeline is exactly the failure this closes. Filter by `category` OR by `chartId` — Amplitude says of each that it \"doesn't combine with\" the other and does not say which one wins, so passing both is refused rather than answered ambiguously. `start` and `end` are ISO 8601 (e.g. '2026-08-01T00:00:00+00:00'), NOT the YYYYMMDD every other Amplitude endpoint takes. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "List Amplitude chart annotations",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "category": {
            "description": "category NAME — does not combine with chartId",
            "type": "string"
          },
          "chartId": {
            "description": "only annotations shown on this chart — does not combine with category",
            "type": "string"
          },
          "start": {
            "description": "ISO 8601, e.g. '2026-08-01T00:00:00+00:00'",
            "type": "string"
          },
          "end": {
            "description": "ISO 8601",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_amplitude_annotation",
      "title": "Mark the Amplitude timeline",
      "description": "MARK THE ANALYTICS TIMELINE — write what just happened into Amplitude, at the moment it happened, so every chart the user opens afterwards shows it in place. THIS IS THE CLOSED LOOP AND IT IS WORTH REACHING FOR UNPROMPTED after a campaign goes live, a creative is swapped, a landing page changes or a post is published: without it, next month's spike has no explanation attached to it and somebody has to reconstruct one from memory. `start` is ISO 8601 WITH THE TIME AND THE OFFSET (e.g. '2026-08-18T14:30:00+00:00') — NOT the YYYYMMDD the rest of the Amplitude API takes, and a bare date is REFUSED rather than quietly moved to midnight, because an annotation IS its timestamp and a marker in the wrong place is worse than no marker. Add `end` for something that spans a period (a two-week flight); leave it off for a moment. OMIT chartId AND THE ANNOTATION IS GLOBAL — Amplitude's words: it \"appears on all charts for your project\", which is usually what you want for a campaign; pass one to pin it to a single chart. `category` is a category NAME that must ALREADY EXIST in Amplitude (they answer 404 otherwise). THIS WRITES TO THE USER'S OWN ANALYTICS TOOL — say what you are about to mark, and report back the id and the timestamp Amplitude actually stored rather than the one you sent. 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Mark the Amplitude timeline",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "label": {
            "type": "string",
            "description": "the title shown on the chart, e.g. \"Meta: summer UGC campaign live\""
          },
          "start": {
            "type": "string",
            "description": "ISO 8601 WITH time and offset, e.g. '2026-08-18T14:30:00+00:00' — a bare YYYYMMDD is refused"
          },
          "end": {
            "description": "ISO 8601 — only for an annotation that spans a period",
            "type": "string"
          },
          "chartId": {
            "description": "omit for a GLOBAL annotation that appears on every chart in the project",
            "type": "string"
          },
          "category": {
            "description": "an EXISTING category name in Amplitude — they answer 404 for one that does not exist",
            "type": "string"
          },
          "details": {
            "description": "longer body text for the annotation",
            "type": "string"
          }
        },
        "required": [
          "label",
          "start"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_posthog_annotations",
      "title": "List PostHog annotations",
      "description": "The markers already on this PostHog project's charts — releases, experiments, incidents — with their timestamps, scopes and who wrote them. READ THESE BEFORE EXPLAINING A CHANGE IN A CHART: an annotation is an explanation somebody already wrote down. NEEDS THE `annotation:read` SCOPE on the personal API key, and a key created before Hermoso supported annotations will not have it — PostHog's own refusal NAMES the scope, and that is a scope to add in PostHog ▸ Settings ▸ Personal API keys, NOT a broken connection and NOT a reason to reconnect. Returns ONE page and hands back the next offset rather than paging for you, because PostHog's policy is that a third-party connector is not an exporter. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "List PostHog annotations",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "1–100, default 25",
            "type": "number"
          },
          "offset": {
            "description": "for the next page — this tool does not paginate for you",
            "type": "number"
          },
          "search": {
            "description": "free-text search over annotation content",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_posthog_annotation",
      "title": "Mark the PostHog timeline",
      "description": "MARK THE POSTHOG TIMELINE — write what just happened into PostHog at the moment it happened, so every chart afterwards shows it in place. The same closed loop as create_amplitude_annotation, and worth reaching for unprompted after a campaign goes live, a creative is swapped or a landing page changes. `dateMarker` is what POSITIONS the marker on the x-axis (ISO 8601); omit it and NOW is used, which is right for \"this just went live\" — and the timestamp PostHog actually STORED is reported back rather than the one sent. `scope` is project (the default — shows on everything) / organization / dashboard / dashboard_item, and `dashboard` and `dashboard_item` each need their id or the annotation attaches to nothing and appears nowhere. PostHog also publishes a `recording` scope which THEY THEMSELVES REJECT, so it is refused by name here rather than sent. `creationType` USR means a person wrote it, GIT means a bot or a deployment did. NEEDS THE `annotation:write` SCOPE on the personal API key — a key made before Hermoso supported annotations will not have it; PostHog names it in their refusal, and it is added in PostHog ▸ Settings ▸ Personal API keys without reconnecting. THIS WRITES TO THE USER'S OWN ANALYTICS TOOL. 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Mark the PostHog timeline",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "content": {
            "type": "string",
            "description": "the text shown on the chart, ≤8192 characters"
          },
          "dateMarker": {
            "description": "ISO 8601 — defaults to now, which is right for \"this just went live\"",
            "type": "string"
          },
          "scope": {
            "description": "default project. PostHog's `recording` scope is deprecated and rejected by PostHog",
            "type": "string",
            "enum": [
              "project",
              "organization",
              "dashboard",
              "dashboard_item"
            ]
          },
          "creationType": {
            "description": "USR = a person wrote it, GIT = a bot or deployment did",
            "type": "string",
            "enum": [
              "USR",
              "GIT"
            ]
          },
          "dashboardId": {
            "description": "REQUIRED when scope is \"dashboard\"",
            "type": "number"
          },
          "insightId": {
            "description": "REQUIRED when scope is \"dashboard_item\" — the numeric id of the insight",
            "type": "number"
          }
        },
        "required": [
          "content"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_mixpanel_cohorts",
      "title": "List saved Mixpanel cohorts",
      "description": "THE SAVED COHORTS in the Mixpanel project — each with its id, name, description and CURRENT MEMBER COUNT. Two uses, and both matter for advertising: the counts alone answer \"how big is our converted audience\", and the id is what mixpanel_profiles needs to read the PEOPLE in one. A cohort hidden in Mixpanel's UI is still listed and flagged rather than dropped, because hidden is a display choice and it is still queryable by id. ONE CALL, and the cheapest thing on this connector — worth calling before mixpanel_profiles rather than guessing an id. Remember Mixpanel allows only 60 QUERIES PER HOUR across its entire Query API (5 concurrent), the tightest budget of any connector here, and this shares it with every other Mixpanel tool. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "List saved Mixpanel cohorts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "mixpanel_profiles",
      "title": "Read the people in a Mixpanel cohort",
      "description": "THE PEOPLE THEMSELVES in Mixpanel — the per-user lane, and the question GA4 structurally cannot answer at all. Pass `cohortId` (from list_mixpanel_cohorts) for \"who is in the Converted cohort and what do we know about them\", or `where` for an ad-hoc filter over profile properties, or `distinctId` for one person. PASS `outputProperties` ALMOST ALWAYS: it cuts the payload to the properties you actually need, and Mixpanel says it \"can drastically reduce the amount of data returned\" and \"can speed up queries significantly\". RETURNS ONE PAGE AND DOES NOT PAGINATE FOR YOU — it hands back page, pageSize, total and a sessionId, and Mixpanel REQUIRES that sessionId alongside any page above 0, so a second page is a deliberate second call. DO NOT LOOP IT: Mixpanel allows 60 queries per hour across its ENTIRE Query API and each page spends one of them. REPORT `total` (the whole cohort) SEPARATELY FROM `count` (this page) — quoting only the page size understates the audience, which is the number a targeting decision gets made on. Read-only, 0 credits.",
      "group": "ads",
      "section": "PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10)",
      "annotations": {
        "title": "Read the people in a Mixpanel cohort",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "cohortId": {
            "description": "a numeric cohort id from list_mixpanel_cohorts",
            "type": "string"
          },
          "where": {
            "description": "a Mixpanel segmentation expression over profile properties, e.g. properties[\"$city\"] == \"London\"",
            "type": "string"
          },
          "distinctId": {
            "description": "one person",
            "type": "string"
          },
          "distinctIds": {
            "description": "several people by distinct id",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "outputProperties": {
            "description": "only return these properties — strongly recommended, it is much faster and much smaller",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "dataGroupId": {
            "description": "group key — for GROUP profiles rather than user profiles",
            "type": "string"
          },
          "page": {
            "description": "pages start at ZERO; any page above 0 REQUIRES sessionId",
            "type": "number"
          },
          "sessionId": {
            "description": "from the previous page — required for page > 0",
            "type": "string"
          },
          "includeAllUsers": {
            "description": "only applies alongside cohortId; false = only distinct ids that actually have a profile",
            "type": "boolean"
          }
        },
        "$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": {
        "title": "List Microsoft Advertising accounts / campaigns",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Microsoft Advertising performance report",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Microsoft Advertising keyword planner",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Microsoft Advertising traffic estimates",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Where Microsoft says budget is capping delivery",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Find Microsoft Advertising location ids",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Build a Microsoft Advertising campaign (paused)",
        "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": "Search (default), Shopping, DynamicSearchAds, Audience or PerformanceMax. ONLY \"Search\" builds the full tree here — the ad group, responsive search ad and keywords below are Search-shaped. Any other type is created as a campaign on its own (finish it in Microsoft Advertising), and passing `adGroup` with it is refused before anything is created.",
            "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": {
        "title": "Add a Microsoft Advertising ad group",
        "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": {
        "title": "Create a Microsoft responsive search ad",
        "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": {
        "title": "Add Microsoft Advertising keywords",
        "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": {
        "title": "Set a Microsoft Advertising daily budget",
        "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": {
        "title": "Activate or pause a Microsoft Advertising campaign / ad group / ad",
        "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": {
        "title": "Delete a Microsoft Advertising campaign / ad group / ad / keyword",
        "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_microsoft_merchant_stores",
      "title": "List Microsoft Merchant Center stores",
      "description": "List the brand's MICROSOFT MERCHANT CENTER stores — the product-feed side of Microsoft Advertising, the exact twin of Google Merchant Center. THIS IS WHERE THE merchantId COMES FROM: every other Microsoft Merchant tool needs it and there is no other way to discover it (Content API's own Store resource is closed-beta only, so Hermoso reads the list from Campaign Management instead, which is open to everyone). Each row carries the store name, its URL, and whether it is active, has a catalog and has product ads enabled. Microsoft's own caveat, which the note repeats: those flags are set inside Merchant Center and are read-only here — an inactive store can still be referenced by a Shopping campaign. An account with NO store has no product feed at all, and a store cannot be created through the API. Read-only, free, 0 credits.",
      "group": "ads",
      "section": "Microsoft Merchant Center (Content API) + Customer Match (2026-08-19). The product-feed and",
      "annotations": {
        "title": "List Microsoft Merchant Center stores",
        "readOnlyHint": true,
        "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"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_microsoft_merchant_products",
      "title": "List Microsoft Merchant Center product offers",
      "description": "Read the product offers in a Microsoft Merchant Center store — the feed a Microsoft Shopping campaign serves from. Pass productId to fetch ONE offer, or omit it to page the store (limit up to 250, then pass the returned pageToken). MICROSOFT'S PRODUCT IDS ARE COMPOSED AND CASE SENSITIVE: the id is channel:contentLanguage:targetCountry:offerId (e.g. Online:en:US:Sku123), not the bare offerId, and a re-cased id addresses nothing — always use the id this tool returned. Read-only, free, 0 credits.",
      "group": "ads",
      "section": "Microsoft Merchant Center (Content API) + Customer Match (2026-08-19). The product-feed and",
      "annotations": {
        "title": "List Microsoft Merchant Center product offers",
        "readOnlyHint": true,
        "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"
          },
          "merchantId": {
            "description": "Microsoft Merchant Center store id from list_microsoft_merchant_stores — omit when the account has exactly one store",
            "type": "string"
          },
          "productId": {
            "description": "fully qualified id (channel:contentLanguage:targetCountry:offerId, e.g. Online:en:US:Sku123) to fetch ONE offer",
            "type": "string"
          },
          "limit": {
            "description": "up to 250, default 25",
            "type": "number"
          },
          "pageToken": {
            "description": "the nextPageToken from a previous call",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "upsert_microsoft_merchant_product",
      "title": "Add or update Microsoft Merchant Center product offers",
      "description": "Add or update product offers in a Microsoft Merchant Center store. THE ONE THING TO KNOW BEFORE CALLING IT: Microsoft has NO partial update — 'because an update is an insert operation, you must include all fields of the offer in the request' — so a title-only 'update' CLEARS every other field. Always send the whole offer. Ten fields are required: availability, channel, condition, contentLanguage, imageLink, link, offerId, price, targetCountry, title; brand/gtin/mpn are strongly recommended and their absence is set as identifierExists:false for you. Pass product for one offer or products[] for a batch (Hermoso caps a batch at 300 — Microsoft publishes two contradictory ceilings for this and we take the lower). Pass dryRun:true to validate against Microsoft without writing anything; Microsoft returns no ids on a dry run, so none is claimed. Feed edits cannot spend and cannot start an auction, but an offer goes through editorial review — being STORED is not the same as being served. Everything is read back from Microsoft. 0 credits.",
      "group": "ads",
      "section": "Microsoft Merchant Center (Content API) + Customer Match (2026-08-19). The product-feed and",
      "annotations": {
        "title": "Add or update Microsoft Merchant Center product offers",
        "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"
          },
          "merchantId": {
            "description": "Microsoft Merchant Center store id from list_microsoft_merchant_stores — omit when the account has exactly one store",
            "type": "string"
          },
          "product": {
            "description": "ONE whole offer — availability, channel, condition, contentLanguage, imageLink, link, offerId, price{value,currency}, targetCountry, title are required; brand/gtin/mpn strongly recommended",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "products": {
            "description": "several whole offers in one batch, maximum 300",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "catalogId": {
            "description": "write into a specific catalog instead of the store default",
            "type": "string"
          },
          "dryRun": {
            "description": "validate against Microsoft and write nothing — Microsoft returns no ids for a dry run, so none is claimed",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_microsoft_merchant_product",
      "title": "Delete a Microsoft Merchant Center product offer",
      "description": "Delete one product offer from a Microsoft Merchant Center store. Pass productId as the FULLY QUALIFIED id (channel:contentLanguage:targetCountry:offerId), not the offerId. Call without confirm first — nothing is deleted and you get Microsoft's own better advice back: deleted products can take UP TO 12 HOURS to stop delivering, so if the goal is to stop showing it today, upsert it with availability 'out of stock' instead. The outcome is READ BACK by re-fetching the offer, and says 'deleted', 'not confirmed' or 'could not tell' — repeat that verbatim rather than claiming success from the delete's own empty response. 0 credits.",
      "group": "ads",
      "section": "Microsoft Merchant Center (Content API) + Customer Match (2026-08-19). The product-feed and",
      "annotations": {
        "title": "Delete a Microsoft Merchant Center product offer",
        "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"
          },
          "merchantId": {
            "description": "Microsoft Merchant Center store id from list_microsoft_merchant_stores — omit when the account has exactly one store",
            "type": "string"
          },
          "productId": {
            "type": "string",
            "description": "the FULLY QUALIFIED id (channel:contentLanguage:targetCountry:offerId), not the offerId"
          },
          "confirm": {
            "description": "REQUIRED true — call without it first to see Microsoft’s own advice about the 12-hour delivery tail",
            "type": "boolean"
          }
        },
        "required": [
          "productId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_microsoft_merchant_issues",
      "title": "Why Microsoft Merchant Center products are not serving",
      "description": "Why a Microsoft Merchant Center store's products are NOT serving — the approved / disapproved / expiring counts plus the per-offer disapprovals and warnings with Microsoft's own issue codes. This is the tool to reach for when a Microsoft Shopping campaign is live and showing nothing. TWO CAVEATS THAT LOOK LIKE FAILURES AND ARE NOT, both repeated in the note: Microsoft returns detail rows ONLY for offers that are Disapproved or in Warning, so an EMPTY detail list is the healthy state; and a status change takes up to two hours to reach the summary, so a fresh upload legitimately shows as nothing yet. Read-only, free, 0 credits.",
      "group": "ads",
      "section": "Microsoft Merchant Center (Content API) + Customer Match (2026-08-19). The product-feed and",
      "annotations": {
        "title": "Why Microsoft Merchant Center products are not serving",
        "readOnlyHint": true,
        "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"
          },
          "merchantId": {
            "description": "Microsoft Merchant Center store id from list_microsoft_merchant_stores — omit when the account has exactly one store",
            "type": "string"
          },
          "limit": {
            "description": "up to 250, default 25",
            "type": "number"
          },
          "pageToken": {
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_microsoft_merchant_catalogs",
      "title": "List Microsoft Merchant Center catalogs",
      "description": "List the catalogs inside a Microsoft Merchant Center store. Catalogs logically group products, and a Shopping campaign's product scope points at them. Each row says whether publishing is ENABLED — products in a catalog with publishing off do not serve at all, which is a common and invisible reason a feed appears healthy and delivers nothing. Products go into the store's default catalog unless a call names catalogId. Read-only, free, 0 credits.",
      "group": "ads",
      "section": "Microsoft Merchant Center (Content API) + Customer Match (2026-08-19). The product-feed and",
      "annotations": {
        "title": "List Microsoft Merchant Center catalogs",
        "readOnlyHint": true,
        "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"
          },
          "merchantId": {
            "description": "Microsoft Merchant Center store id from list_microsoft_merchant_stores — omit when the account has exactly one store",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_microsoft_merchant_catalog",
      "title": "Create, update or delete a Microsoft Merchant Center catalog",
      "description": "Create, update or delete a catalog in a Microsoft Merchant Center store. Creating needs name + market + isPublishingEnabled; updating needs catalogId + name + isPublishingEnabled (market is not settable after creation). isPublishingEnabled is NEVER defaulted on — products serve only when it is true, so publishing a catalogue is always an explicit choice. Names must be unique within the store and are capped at 70 characters. Pass deleteIt:true with catalogId and confirm:true to remove one. Every outcome is READ BACK from Microsoft. 0 credits.",
      "group": "ads",
      "section": "Microsoft Merchant Center (Content API) + Customer Match (2026-08-19). The product-feed and",
      "annotations": {
        "title": "Create, update or delete a Microsoft Merchant Center catalog",
        "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"
          },
          "merchantId": {
            "description": "Microsoft Merchant Center store id from list_microsoft_merchant_stores — omit when the account has exactly one store",
            "type": "string"
          },
          "catalogId": {
            "description": "set to UPDATE or DELETE an existing catalog; omit to create a new one",
            "type": "string"
          },
          "name": {
            "description": "unique within the store, maximum 70 characters",
            "type": "string"
          },
          "market": {
            "description": "REQUIRED when creating — where the products are served, e.g. en-US. Not settable afterwards",
            "type": "string"
          },
          "isPublishingEnabled": {
            "description": "products serve ONLY when this is true — never defaulted on",
            "type": "boolean"
          },
          "deleteIt": {
            "description": "true to delete the catalog named by catalogId",
            "type": "boolean"
          },
          "confirm": {
            "description": "REQUIRED true to delete",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_microsoft_ads_audiences",
      "title": "List Microsoft Advertising audiences (Customer Match lists)",
      "description": "List a Microsoft Advertising account's audiences — by default the CUSTOMER LISTS (Customer Match), with each row's membership duration and its current Search and Audience-network sizes. TWO MICROSOFT CAVEATS THAT LOOK LIKE FAILURES AND ARE NOT: a size is nil or empty for UP TO 48 HOURS while a list is being built, and Microsoft will not use an audience of fewer than 300 people at all — so never report a fresh list's 0 as a failed upload. Pass types[] to ask for other audience types. Read-only, free, 0 credits.",
      "group": "ads",
      "section": "Microsoft Merchant Center (Content API) + Customer Match (2026-08-19). The product-feed and",
      "annotations": {
        "title": "List Microsoft Advertising audiences (Customer Match lists)",
        "readOnlyHint": true,
        "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"
          },
          "audienceIds": {
            "description": "up to 100 specific ids — omit for all of the requested type",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "types": {
            "description": "audience types, default CustomerList",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_microsoft_ads_customer_list",
      "title": "Create an empty Microsoft Advertising Customer Match list",
      "description": "Create an EMPTY Customer Match list on a Microsoft Advertising account, ready for apply_microsoft_ads_customer_list to fill. membershipDuration is 1–390 days, or -1 for no expiration (Microsoft's default is 30). scope 'Account' (the default) makes it usable only by that ad account; scope 'Customer' makes it usable by every account under the manager — Hermoso derives the right parent id for whichever you choose, because getting that pair wrong mis-parents the list silently. An audience is a definition: it cannot spend and it cannot serve on its own. Read back from Microsoft. 0 credits.",
      "group": "ads",
      "section": "Microsoft Merchant Center (Content API) + Customer Match (2026-08-19). The product-feed and",
      "annotations": {
        "title": "Create an empty Microsoft Advertising Customer Match list",
        "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"
          },
          "name": {
            "type": "string",
            "description": "maximum 128 characters"
          },
          "description": {
            "description": "maximum 1024 characters",
            "type": "string"
          },
          "membershipDuration": {
            "description": "1–390 days, or -1 for no expiration. Microsoft’s default is 30",
            "type": "number"
          },
          "scope": {
            "description": "Account (default) = this ad account only; Customer = every account under the manager",
            "type": "string",
            "enum": [
              "Account",
              "Customer"
            ]
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "apply_microsoft_ads_customer_list",
      "title": "Upload customer emails into a Microsoft Customer Match list",
      "description": "Upload customer emails into a Microsoft Advertising Customer Match list so campaigns can target or exclude them. YOU PASS PLAIN EMAIL ADDRESSES — Hermoso normalizes and SHA-256 hashes them locally and sends ONLY the digests, so no plaintext ever leaves the server; values that arrive already hashed (64 hex characters) are passed through untouched. Microsoft's own normalization is applied exactly as published: trim, remove all dots from the user portion, remove any +alias, lowercase, then SHA-256. YOU MUST GET THE USER'S AGREEMENT FIRST: show them https://about.ads.microsoft.com/en-us/legal/customer-match-terms and only then call with acceptTerms:true, which Microsoft says 'eliminates the need to accept terms through the Microsoft Advertising UI' — without it nothing is uploaded and nothing is hashed. action is Add (additive, the default), Remove or Replace. Maximum 1000 items per call; page larger lists. Email is the only supported identifier — Microsoft publishes Phone and CRM as 'Not currently supported' and both are refused by name. DO NOT read a size of 0 afterwards as a failure: Microsoft leaves it empty for up to 48 hours while the audience builds. 0 credits.",
      "group": "ads",
      "section": "Microsoft Merchant Center (Content API) + Customer Match (2026-08-19). The product-feed and",
      "annotations": {
        "title": "Upload customer emails into a Microsoft Customer Match list",
        "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"
          },
          "audienceId": {
            "type": "string",
            "description": "the customer list to write into"
          },
          "emails": {
            "description": "PLAIN email addresses — normalized and SHA-256 hashed inside Hermoso, never sent as plaintext. Already-hashed 64-hex values pass through untouched. Maximum 1000 per call",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "action": {
            "description": "Add (default) is additive across calls",
            "type": "string",
            "enum": [
              "Add",
              "Remove",
              "Replace"
            ]
          },
          "subType": {
            "description": "Email is the only identifier Microsoft supports here — it publishes Phone and CRM as “Not currently supported”",
            "type": "string",
            "enum": [
              "Email"
            ]
          },
          "acceptTerms": {
            "description": "REQUIRED true — the user must agree to Microsoft’s Customer Match terms first. Without it nothing is uploaded and nothing is hashed",
            "type": "boolean"
          }
        },
        "required": [
          "audienceId"
        ],
        "$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": {
        "title": "List ChatGPT Ads account / campaigns / ad groups / ads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "ChatGPT Ads performance report",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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 AND CUSTOM AUDIENCES ARE THE ONLY LIST-BASED 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. Custom audiences are targeted with customAudienceIds / excludedCustomAudienceIds (see list_openai_ads_audiences). 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": {
        "title": "Find ChatGPT Ads location ids",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "list_x_ads_accounts",
      "title": "List X ad accounts",
      "description": "List the X (Twitter) ad accounts this brand can act on, with the PERMISSION LEVEL held on each so you can tell an admin grant from a read-only one before attempting a write. X grants API access PER AD ACCOUNT, not per app: the customer adds Hermoso’s X user to their ad account at business.x.com → Account access, and it appears here. Read-only, free.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "title": "List X ad accounts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_x_ads_campaigns",
      "title": "List X ads campaigns",
      "description": "List campaigns on an X ad account — status, budgets, and whether X considers each servable. Omit accountId when only one account is reachable and it resolves itself. Read-only, free.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "title": "List X ads campaigns",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "limit": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_x_ads_campaign",
      "title": "Build an X ads campaign (paused)",
      "description": "Create a campaign on X (Twitter). ALWAYS CREATED PAUSED with no override — it spends NOTHING until set_x_ads_status(confirm:true). A CAMPAIGN ALONE CANNOT SERVE ON X: it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad. Requires a funding instrument (a payment method on the X ad account) — omit fundingInstrumentId to be shown the usable ones, and if there are none this refuses with that reason instead of failing at X. Budgets are in the ad account’s own currency. Everything is READ BACK from X before you are told it exists; print the returned note verbatim.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "title": "Build an X ads campaign (paused)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string",
            "description": "from list_x_ads_accounts"
          },
          "name": {
            "type": "string"
          },
          "fundingInstrumentId": {
            "description": "omit to be shown the account’s usable funding instruments",
            "type": "string"
          },
          "dailyBudget": {
            "description": "in the ad account’s currency",
            "type": "number"
          },
          "totalBudget": {
            "type": "number"
          },
          "startTime": {
            "description": "ISO 8601",
            "type": "string"
          },
          "endTime": {
            "type": "string"
          }
        },
        "required": [
          "accountId",
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_x_ads_status",
      "title": "Pause or activate an X campaign or line item",
      "description": "Pause or ACTIVATE an X ads CAMPAIGN (campaignId) or ONE LINE ITEM inside it (lineItemId) — pass exactly one. ACTIVATING STARTS REAL SPEND on the next auction, so it requires confirm:true — this is the only switch on X that arms money. Tell the user the budget and what will start spending BEFORE you pass confirm. DELIVERY ON X IS THE AND OF BOTH LEVELS: an ACTIVE line item under a PAUSED campaign serves nothing, so the result reads the PARENT back too and states whether anything can actually spend rather than letting you infer it. Pausing a line item is the REVERSIBLE way to take one ad group out of delivery — deleting it is not.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "title": "Pause or activate an X campaign or line item",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "campaignId": {
            "description": "the whole campaign — pass this OR lineItemId",
            "type": "string"
          },
          "lineItemId": {
            "description": "one ad group — pass this OR campaignId",
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "ACTIVE",
              "PAUSED"
            ]
          },
          "confirm": {
            "description": "required to set ACTIVE — real money",
            "type": "boolean"
          }
        },
        "required": [
          "accountId",
          "status"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "microsoft_ads_bulk_download",
      "title": "Export a Microsoft Advertising account as a bulk file",
      "description": "Export a Microsoft Advertising account as a BULK FILE — one spreadsheet holding whatever slice of the account you ask for. This is the ONLY way to read most of a Microsoft account: roughly 185 of Microsoft’s record types are reachable here and nowhere else in Hermoso — sitelink / callout / structured-snippet / image / logo / price / promotion ad extensions, labels, shared negative keyword lists, bid strategies, audiences and their associations, experiments, seasonality adjustments, conversion goals, asset groups and listing groups, feeds. Pass entities[] (default Campaigns, AdGroups, Ads, Keywords), campaignIds[] to narrow it, or since for a DELTA of what changed (Microsoft refuses anything older than 30 days). The reply carries the WHOLE FILE as a string in `file` — edit that and pass it straight to microsoft_ads_bulk_upload, which is the round trip this exists for. A bulk export is a SNAPSHOT of what Microsoft held when it ran, never a live view, so do not quote a status from it as current after a change. Microsoft builds it asynchronously: a large account can come back pending:true with a downloadRequestId — call again with that id rather than resubmitting, which would export the whole account a second time. Read-only, 0 credits.",
      "group": "ads",
      "section": "MICROSOFT BULK SERVICE + MEASUREMENT (2026-08-20)",
      "annotations": {
        "title": "Export a Microsoft Advertising account as a bulk file",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "entities": {
            "description": "Microsoft DownloadEntity names — Campaigns, AdGroups, Ads, Keywords (the default), plus SitelinkAdExtensions, CalloutAdExtensions, Labels, Budgets, BidStrategies, AssetGroups, ConversionGoal, Experiments, Audiences and ~180 more. An unknown one is refused BY NAME, because Microsoft answers it with a deserialization error that names no field and reads like an outage",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "campaignIds": {
            "description": "export only these campaigns (max 1000) instead of the whole account",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "since": {
            "description": "ISO date — export only what CHANGED since then. Microsoft refuses a delta older than 30 days, and refuses one at all alongside QualityScoreData / BidSuggestionsData",
            "type": "string"
          },
          "fileType": {
            "description": "default Csv",
            "type": "string",
            "enum": [
              "Csv",
              "Tsv"
            ]
          },
          "compression": {
            "description": "default GZip",
            "type": "string",
            "enum": [
              "GZip",
              "Zip"
            ]
          },
          "dataScope": {
            "description": "EntityData (default), QualityScoreData, BidSuggestionsData — the last two force a FULL export",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "limit": {
            "description": "how many rows to return inline, default 200 (the full file is still in `file`)",
            "type": "number"
          },
          "allowUnlistedEntities": {
            "description": "send an entity name we do not recognise verbatim — for a record type Microsoft added after our copy of its list was read",
            "type": "boolean"
          },
          "downloadRequestId": {
            "description": "pick up an export that came back pending. Valid 2 days, and far cheaper than exporting the account again",
            "type": "string"
          },
          "waitMs": {
            "description": "how long to wait for Microsoft to build the file before returning pending, max 120000",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "microsoft_ads_bulk_upload",
      "title": "Apply a Microsoft Advertising bulk file (spend-gated)",
      "description": "Apply a Microsoft Advertising BULK FILE — edit hundreds of campaigns, ad groups, ads, keywords, extensions or labels in one request. Pass `file` as the CSV/TSV string; microsoft_ads_bulk_download returns exactly that shape in its `file` field. THE SPEND GATE, AND IT IS UNLIKE EVERY OTHER TOOL HERE. A bulk file carries a Status column, so one upload can turn campaigns ON — and confirm:true ALONE IS NOT ENOUGH for a file: it proves you meant to upload something, not that you know what THIS file does, and it reads identically for a file that changes two bids and one that activates fifty campaigns. So: call it ONCE with no confirmation. Nothing is uploaded, and you are told exactly how many objects the file would ACTIVATE (they can then spend real money) and DELETE (which Microsoft never undoes — a deleted object is never returned by any read again), each named with its row number, type, id and name. SHOW THAT LIST TO THE USER, get an explicit yes, then call again with confirm:true, confirmActivations:<n> and confirmDeletions:<n>. If the activations are incidental — a file you round-tripped out of a download and only meant to edit bids in — pass pauseInstead:true instead and those rows are written as Paused, so everything else lands with nothing armed; it is refused when the file also deletes, because pausing is a safe substitute for activating and there is none for deleting. Partial success is normal on this API: rows Microsoft rejects come back listed individually and the correct ones ARE applied, so never describe a CompletedWithErrors upload as a failure. 0 credits.",
      "group": "ads",
      "section": "MICROSOFT BULK SERVICE + MEASUREMENT (2026-08-20)",
      "annotations": {
        "title": "Apply a Microsoft Advertising bulk file (spend-gated)",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "file": {
            "type": "string",
            "description": "the bulk file itself, CSV or TSV, whose first column header must be \"Type\""
          },
          "fileType": {
            "description": "inferred from the file when omitted",
            "type": "string",
            "enum": [
              "Csv",
              "Tsv"
            ]
          },
          "confirm": {
            "description": "the user has SEEN what this file activates and deletes and said yes. Not sufficient on its own — the two echo counts below are also required",
            "type": "boolean"
          },
          "confirmActivations": {
            "description": "echo back how many objects this file ACTIVATES — the exact count the un-confirmed call reported",
            "type": "number"
          },
          "confirmDeletions": {
            "description": "echo back how many objects this file DELETES — the exact count the un-confirmed call reported",
            "type": "number"
          },
          "pauseInstead": {
            "description": "rewrite every activation to Paused so the rest of the file uploads with nothing armed. Never applied unless asked: an unrequested rewrite would PAUSE A LIVE CAMPAIGN",
            "type": "boolean"
          },
          "dryRun": {
            "description": "report what the file would do and upload nothing at all",
            "type": "boolean"
          },
          "responseMode": {
            "description": "default ErrorsAndResults, so the outcome can be read back rather than assumed",
            "type": "string",
            "enum": [
              "ErrorsAndResults",
              "ErrorsOnly"
            ]
          },
          "uploadRequestId": {
            "description": "read the outcome of an upload that came back pending. NEVER re-upload the file — it is already queued, and a second upload applies every row twice",
            "type": "string"
          },
          "waitMs": {
            "description": "how long to wait for Microsoft to apply the file before returning pending, max 120000",
            "type": "number"
          }
        },
        "required": [
          "file"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_microsoft_ads_conversion_goals",
      "title": "Microsoft Advertising conversion goals",
      "description": "The conversion goals on a Microsoft Advertising account — what the account COUNTS as a conversion, and therefore what every conversion-optimised bid strategy on it is optimising toward. Flags which ones are OFFLINE goals: those are the only goals send_microsoft_ads_offline_conversions can post to, and its conversionName must match one of their names EXACTLY, including case and spacing. An account with no goals at all has no conversion tracking — say that plainly rather than reporting an empty list, because it means smart bidding there has nothing to aim at. Read-only, 0 credits.",
      "group": "ads",
      "section": "MICROSOFT BULK SERVICE + MEASUREMENT (2026-08-20)",
      "annotations": {
        "title": "Microsoft Advertising conversion goals",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "goalIds": {
            "description": "specific goal ids — omit for all of them",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "goalType": {
            "description": "filter to one Microsoft ConversionGoalType, e.g. OfflineConversion",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "send_microsoft_ads_offline_conversions",
      "title": "Send offline + enhanced conversions to Microsoft",
      "description": "Tell Microsoft Advertising about conversions that happened OFF the website — a phone sale, an in-store purchase, a lead that closed weeks after the click — so smart bidding on that account stops optimising against only what converted online. Each row needs conversionName (an existing OFFLINE conversion goal, matched by name — list_microsoft_ads_conversion_goals shows them), conversionTime (within the last 90 days AND after the click), and an identifier: either microsoftClickId (the msclkid Microsoft appends to the landing-page URL) or, for ENHANCED conversions, a plain email and/or phone. YOU PASS PLAIN VALUES AND THE HASHING HAPPENS SERVER-SIDE: emails are normalized and SHA-256’d to Microsoft’s own published five-step spec, which is verified against Microsoft’s own worked digests. A PHONE MUST ALREADY CARRY ITS COUNTRY CODE (+14255551234) and is refused otherwise — Microsoft’s spec says \"normalize to E.164 format with country code\" and stops there, and guessing which country a bare 4255551234 belongs to is not a mistake you would ever see: the upload succeeds and matches nobody. SAY THIS WHEN REPORTING: accepted is not the same as visible. Microsoft takes up to 6 hours to show offline conversion data and applies nothing at all in the first 2 hours after a goal is created, and a conversion only counts inside the goal’s own conversion window. Duplicates are silently ignored (the first wins), so re-sending a batch is safe and changes nothing. 0 credits.",
      "group": "ads",
      "section": "MICROSOFT BULK SERVICE + MEASUREMENT (2026-08-20)",
      "annotations": {
        "title": "Send offline + enhanced conversions to Microsoft",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "conversionName": {
            "description": "default goal name for every row that does not carry its own",
            "type": "string"
          },
          "conversions": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "conversionName": {
                  "description": "must match an existing Microsoft OFFLINE conversion goal exactly",
                  "type": "string"
                },
                "conversionTime": {
                  "type": "string",
                  "description": "ISO 8601 — within the last 90 days, and later than the click"
                },
                "microsoftClickId": {
                  "description": "the msclkid. Optional only when an email or phone is supplied",
                  "type": "string"
                },
                "email": {
                  "description": "a PLAIN email address (hashed here), or a SHA-256 digest you already hold",
                  "type": "string"
                },
                "phone": {
                  "description": "E.164 WITH the country code, e.g. +14255551234, or a digest. A number without a country code is refused rather than guessed",
                  "type": "string"
                },
                "conversionValue": {
                  "description": "defaults to the goal’s own revenue setting",
                  "type": "number"
                },
                "conversionCurrencyCode": {
                  "description": "3-letter ISO code; defaults to the goal’s currency",
                  "type": "string"
                },
                "externalAttributionCredit": {
                  "description": "the fraction of the conversion this click gets, >0 and ≤1. Only for goals configured for external attribution, and only together with externalAttributionModel",
                  "type": "number"
                },
                "externalAttributionModel": {
                  "type": "string"
                }
              },
              "required": [
                "conversionTime"
              ]
            },
            "description": "up to 1000 per request — Microsoft applies each request independently"
          }
        },
        "required": [
          "conversions"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "microsoft_ads_auction_insights",
      "title": "Who else is bidding on the same Microsoft auctions",
      "description": "Who ELSE is competing for the same Microsoft Advertising auctions — the other advertisers’ domains with their impression share, overlap rate, position-above rate, top-of-page rate and outranking share. microsoft_ads_report tells you what the account did; this tells you who it did it against, which is the input to a bid or a creative decision rather than a report. Scope it with entityType Account (default, the whole account) / Campaign / AdGroup / Keyword plus entityIds (max 200), and optionally segment by up to three of Day, DayOfWeek, Device. SAY THIS WHEN REPORTING: every figure is a share of the auctions YOUR ads entered, never a measure of a competitor’s whole account or budget — a competitor missing from the list did not necessarily not bid. An empty result means there was no delivery to compare in that window, which is MISSING data; never report it as \"nobody is bidding against you\". Read-only, 0 credits.",
      "group": "ads",
      "section": "MICROSOFT BULK SERVICE + MEASUREMENT (2026-08-20)",
      "annotations": {
        "title": "Who else is bidding on the same Microsoft auctions",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "entityType": {
            "description": "default Account",
            "type": "string",
            "enum": [
              "Account",
              "Campaign",
              "AdGroup",
              "Keyword"
            ]
          },
          "entityIds": {
            "description": "REQUIRED for Campaign / AdGroup / Keyword, max 200. Microsoft only defaults to the whole account for entityType Account",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "since": {
            "description": "YYYY-MM-DD, default 30 days back",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD, default today",
            "type": "string"
          },
          "segments": {
            "description": "at most three — Microsoft’s own limit",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "Day",
                "DayOfWeek",
                "Device"
              ]
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "microsoft_ads_recommendations",
      "title": "What Microsoft suggests changing on the account",
      "description": "What Microsoft Advertising ITSELF suggests changing on the account: budget raises, new keywords, broad-match widenings, conflicting negative keywords to remove, and responsive search ads or extra headlines Microsoft has written. Each row carries the campaign and ad group it touches plus Microsoft’s OWN estimate of what applying it would add to clicks, impressions, conversions and COST; a budget recommendation states the current and the recommended daily amount, so the money is a vendor fact rather than a guess. SAY THIS WHEN REPORTING: every Microsoft recommendation INCREASES what the account buys, that is what they are for, so none of them is a free win, and an empty list means Microsoft has no ADVICE (usually too little delivery to compute any), never that the account is optimal. Dismissed recommendations are hidden unless you ask for them. Read-only, 0 credits.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT RECOMMENDATIONS + AUTO-APPLY (2026-08-20)",
      "annotations": {
        "title": "What Microsoft suggests changing on the account",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id, omit to use the brand’s single shared account",
            "type": "string"
          },
          "types": {
            "description": "narrow to some of ADD_BROAD_MATCH_KEYWORD, CAMPAIGN_BUDGET, KEYWORD, REMOVE_CONFLICTING_NEGATIVE_KEYWORD, RESPONSIVE_SEARCH_AD, RESPONSIVE_SEARCH_AD_ASSET, all six by default. An unknown name is refused BY NAME, because Microsoft answers one with a deserialization error that names no field",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "limit": {
            "description": "Microsoft’s MaxCount, omit for everything",
            "type": "number"
          },
          "includeDismissed": {
            "description": "also show recommendations already dismissed",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "apply_microsoft_ads_recommendations",
      "title": "Apply Microsoft recommendations (spend-gated)",
      "description": "Apply Microsoft Advertising recommendations by id. THIS SPENDS REAL MONEY AND HAS NO UNDO. It raises daily budgets, adds keywords, widens keywords to broad match, DELETES negative keywords so the account buys more searches, and publishes Microsoft-written ads under the brand’s name, all live at the next auction. THE GATE IS UNLIKE THE OTHER MICROSOFT SPEND SWITCHES, AND confirm:true ALONE IS REFUSED: it proves you meant to apply something, not that you know what THESE ones do, and it reads identically for one keyword suggestion and forty budget raises. So call it ONCE with no confirmation. Nothing is applied, and you get every recommendation named with its campaign, exactly what it changes, the current → recommended budget where there is one, and Microsoft’s own cost estimate. SHOW THAT LIST TO THE USER, get an explicit yes, then call again with confirm:true, confirmCount:<n> and confirmCostIncrease:<n>. BOTH NUMBERS ARE RECOMPUTED FROM A FRESH READ at that moment, so a recommendation Microsoft has withdrawn, already applied or re-priced since you looked fails the check instead of being applied unseen. If ANY id you name is no longer on offer, NOTHING is applied, not even the ones that still are, because applying part of a set you inspected whole is not what you asked for. Microsoft can reject individual items and apply the rest, so the result reports per item: never call a partial apply a failure, and never call it a success. 0 credits to us; the spend lands on the ad account.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT RECOMMENDATIONS + AUTO-APPLY (2026-08-20)",
      "annotations": {
        "title": "Apply Microsoft recommendations (spend-gated)",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id, omit to use the brand’s single shared account",
            "type": "string"
          },
          "recommendationIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "ids from microsoft_ads_recommendations, max 100. There is deliberately no \"apply everything\""
          },
          "confirm": {
            "description": "the user has SEEN the pre-flight list and said yes. Not sufficient on its own, both echo numbers below are also required",
            "type": "boolean"
          },
          "confirmCount": {
            "description": "echo back how many recommendations this applies: the exact count the un-confirmed call reported",
            "type": "number"
          },
          "confirmCostIncrease": {
            "description": "echo back Microsoft’s own summed cost estimate for them: the exact number the un-confirmed call reported",
            "type": "number"
          },
          "dryRun": {
            "description": "report what would happen and apply nothing at all",
            "type": "boolean"
          }
        },
        "required": [
          "recommendationIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "dismiss_microsoft_ads_recommendations",
      "title": "Dismiss Microsoft recommendations",
      "description": "Dismiss Microsoft Advertising recommendations by id so Microsoft stops offering them. IT CANNOT SPEND and it changes nothing about what the account runs (it takes advice off the list), so it takes no confirmation at all, which is the point: reach for this rather than applying something just to clear it. Nothing is destroyed either: Microsoft still returns a dismissed recommendation from a read, flagged as dismissed, and microsoft_ads_recommendations will show it again with includeDismissed. Ids Microsoft has already withdrawn are reported and are not an error, they are already off the list, which is the outcome you wanted. 0 credits.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT RECOMMENDATIONS + AUTO-APPLY (2026-08-20)",
      "annotations": {
        "title": "Dismiss Microsoft recommendations",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id, omit to use the brand’s single shared account",
            "type": "string"
          },
          "recommendationIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "ids from microsoft_ads_recommendations, max 100"
          }
        },
        "required": [
          "recommendationIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "microsoft_ads_auto_apply",
      "title": "Is Microsoft changing this ad account by itself?",
      "description": "Whether Microsoft is allowed to change this ad account BY ITSELF, unattended: the auto-apply opt-in status for each of the five recommendation types Microsoft supports. THIS IS THE READ THAT ANSWERS \"is something changing my account when nobody is looking?\", and it is worth running on any Microsoft account you inherit: an account can already be opted in without anyone at the brand having done it, and there is no other way to find out from here. A type Microsoft returns no status for is reported as UNKNOWN, never as off. TWO THINGS THAT CATCH PEOPLE OUT AND ARE STATED IN THE REPLY: these five type names are a DIFFERENT, case-sensitive vocabulary from the six microsoft_ads_recommendations uses (Microsoft cannot auto-apply a budget recommendation at all, there is no such type), and three of the five have no readable recommendation, so there is no way to preview what auto-apply would do for those. Read-only, 0 credits.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT RECOMMENDATIONS + AUTO-APPLY (2026-08-20)",
      "annotations": {
        "title": "Is Microsoft changing this ad account by itself?",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id, omit to use the brand’s single shared account",
            "type": "string"
          },
          "types": {
            "description": "narrow to some of ResponsiveSearchAdsOpportunity, MultiMediaAdsOpportunity, RemoveConflictingNegativeKeywordOpportunity, FixConversionGoalSettingsOpportunity, CreateConversionGoalOpportunity, all five by default",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_microsoft_ads_auto_apply",
      "title": "Let Microsoft change the account by itself, or stop it (per type)",
      "description": "Switch Microsoft’s auto-apply on or off, per recommendation type. SWITCHING ONE ON IS UNLIKE EVERY OTHER WRITE IN HERMOSO: it is STANDING PERMISSION FOR MICROSOFT TO CHANGE THIS AD ACCOUNT ON ITS OWN, indefinitely, with nobody watching, publishing search and multimedia ads it wrote under the brand’s name, DELETING negative keywords so the account starts buying searches it currently blocks, or creating and changing conversion goals so every smart-bidding strategy on the account re-aims. It has no expiry and no schedule you can inspect, and nothing in Hermoso will ever turn it off for the user. AND THERE IS NOTHING TO PREVIEW: Microsoft decides later, on evidence that does not exist yet, so no count, no dry run and no list can tell you what it will do, which is exactly why a blast-radius echo would be meaningless here and each type has to be named instead. To turn any type ON: confirm:true AND confirmTypes listing every type being turned on, individually. There is no \"all\" and no default; naming four of five leaves the fifth refused BY NAME. TURNING IT OFF IS NEVER GATED, set those types to false and it goes straight through, because that only ever reduces what Microsoft does unattended. A type already in the state you asked for is not sent at all. THE ANSWER IS THE READ-BACK: the result re-reads Microsoft and says done / not done / could not tell PER TYPE, and you report that, never the fact that the call returned. 0 credits.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT RECOMMENDATIONS + AUTO-APPLY (2026-08-20)",
      "annotations": {
        "title": "Let Microsoft change the account by itself, or stop it (per type)",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id, omit to use the brand’s single shared account",
            "type": "string"
          },
          "optIns": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {
              "type": "boolean"
            },
            "description": "map of Microsoft auto-apply type name to true/false, e.g. {\"RemoveConflictingNegativeKeywordOpportunity\": false}. Case-sensitive keys: ResponsiveSearchAdsOpportunity, MultiMediaAdsOpportunity, RemoveConflictingNegativeKeywordOpportunity, FixConversionGoalSettingsOpportunity, CreateConversionGoalOpportunity"
          },
          "confirm": {
            "description": "required to turn ANY type ON, after the user has been told what Microsoft will be allowed to do unattended. Never needed to turn one off",
            "type": "boolean"
          },
          "confirmTypes": {
            "description": "every type you are turning ON, named individually. That is the echo which proves you know WHICH permission you are granting, where a count could not",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "optIns"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_campaigns",
      "title": "List Apple Ads campaigns",
      "description": "Read the brand’s connected Apple Ads (Apple Search Ads) campaigns — App Store search ads, on Apple’s Platform API. Each row carries status, the system-computed displayStatus, the daily budget, the bid strategy, the countries and placements it runs in and, when a campaign cannot run, systemStatusReasons stating exactly why. Read-only, free, zero spend risk. Needs Apple Ads connected (Settings ▸ Connectors ▸ Apple Ads): there is no OAuth — Hermoso generates an EC signing key, the user pastes the public half into Apple Ads ▸ Account Settings ▸ API and pastes back clientId / teamId / keyId. To BUILD on this account, use create_apple_ads_campaign / create_apple_ads_ad_group / add_apple_ads_keywords: everything is created PAUSED and only set_apple_ads_status(confirm:true) can arm spend.",
      "group": "ads",
      "section": "APPLE ADS (Apple Search Ads) — READS on the Campaign Management API v5 (2026-08-14)",
      "annotations": {
        "title": "List Apple Ads campaigns",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "page size, default 100, Apple’s max is 1000",
            "type": "number"
          },
          "offset": {
            "description": "offset pagination",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_ad_groups",
      "title": "List Apple Ads ad groups",
      "description": "Ad groups on the brand’s Apple Ads account — status, the system-computed displayStatus, pricing model, the bid off the ad group’s bidStrategy, and whether Search Match (Apple’s automated keyword matching) is on. campaignId is OPTIONAL: with it, the ad groups of that one campaign; WITHOUT it, every ad group in the ad account, each row carrying its own campaignId. (Apple’s older API had no account-wide ad-group list and required the campaign id; its Platform API does, and this now uses it.) Read-only, free.",
      "group": "ads",
      "section": "APPLE ADS (Apple Search Ads) — READS on the Campaign Management API v5 (2026-08-14)",
      "annotations": {
        "title": "List Apple Ads ad groups",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "campaignId": {
            "description": "optional — from list_apple_ads_campaigns. Omit for every ad group in the ad account.",
            "type": "string"
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_keywords",
      "title": "List Apple Ads targeting or negative keywords",
      "description": "Targeting or negative keywords with their match type, status and bid. THE TWO RESOURCES SCOPE DIFFERENTLY and Apple enforces it: TARGETING keywords (the default) need campaignId OR adGroupId — campaignId alone now returns every targeting keyword in the campaign, which Apple’s older API had no way to ask for. NEGATIVE keywords (negative:true) always need one of the two as well, and Apple splits them by LEVEL: campaignId alone returns the CAMPAIGN-level negatives that suppress the whole campaign (scope ‘campaign’, the default), scope ‘adgroup’ returns every AD-GROUP-level negative across that campaign in one call, and naming an adGroupId returns just that ad group’s. The reply states which level it counted, because the row shapes are identical and the number means different things. There is no account-wide keyword list. Apple allows up to 5000 keywords per ad group. Read-only, free.",
      "group": "ads",
      "section": "APPLE ADS (Apple Search Ads) — READS on the Campaign Management API v5 (2026-08-14)",
      "annotations": {
        "title": "List Apple Ads targeting or negative keywords",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "campaignId": {
            "description": "the campaign — required unless you pass adGroupId",
            "type": "string"
          },
          "adGroupId": {
            "description": "narrow to one ad group. Outranks scope on both resources.",
            "type": "string"
          },
          "negative": {
            "description": "list negative keywords instead of targeting keywords",
            "type": "boolean"
          },
          "scope": {
            "description": "NEGATIVE keywords only, with campaignId and no adGroupId: \"campaign\" (default) = the campaign-level negatives; \"adgroup\" = every ad-group-level negative across the campaign.",
            "type": "string",
            "enum": [
              "campaign",
              "adgroup"
            ]
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "apple_ads_report",
      "title": "Apple Ads performance report",
      "description": "Apple Ads performance — impressions, taps, installs, spend, TTR, CPT, CPA. level is campaign / adgroup / ad / keyword / searchterm; promotedObjectType is apps (App Store, the default) or business-brands (Ads on Apple Maps). startTime and endTime are REQUIRED, as YYYY-MM-DD. campaignId is REQUIRED for every level except campaign — it moved from the URL into a filter, but Apple still enforces it (measured: “campaignId filter is required for AD_GROUP reports when promotedObjectType is APPS”), so only a campaign-level report may be account-wide. adGroupId narrows it further. groupBy is restricted PER LEVEL and an unsupported dimension is refused by name, never dropped: campaign and adgroup take deviceClass/ageRange/gender/countryCode/adminArea/locality/storefront/countryOrRegion, keyword and searchterm take only deviceClass/storefront/countryOrRegion, and ad takes only storefront/countryOrRegion. granularity is optional and carries Apple’s own date rules (HOURLY reaches back 7 days and is unavailable on ad and searchterm reports; DAILY 90 days and needs a range longer than one day; WEEKLY 365 days with an end date at least 14 days ago; MONTHLY needs an end date at least 90 days ago). FOR A SINGLE DAY, OMIT granularity — the totals come back in each row’s totalMetrics. Search-term reports are ORTZ-only. grandTotals adds a summary row; emptyMetrics includes entities with no delivery — App Store reports only, never together with groupBy, and never on a search-term report. A report with NO rows genuinely means there was NO delivery in that window and scope: say exactly that, and never present zeros as measured performance. Read-only and free, so run it first after connecting — it proves the credentials work with zero spend risk.",
      "group": "ads",
      "section": "APPLE ADS (Apple Search Ads) — READS on the Campaign Management API v5 (2026-08-14)",
      "annotations": {
        "title": "Apple Ads performance report",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "level": {
            "description": "default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "adgroup",
              "keyword",
              "searchterm",
              "ad"
            ]
          },
          "promotedObjectType": {
            "description": "apps = App Store (default), business-brands = Ads on Apple Maps",
            "type": "string",
            "enum": [
              "apps",
              "business-brands"
            ]
          },
          "campaignId": {
            "description": "REQUIRED for every level except campaign. It moved from the URL into a filter in the Platform API, but Apple still enforces it — only a campaign-level report may be account-wide.",
            "type": "string"
          },
          "adGroupId": {
            "description": "optional second filter — v5 had no path for this",
            "type": "string"
          },
          "startTime": {
            "type": "string",
            "description": "YYYY-MM-DD (required)"
          },
          "endTime": {
            "type": "string",
            "description": "YYYY-MM-DD (required)"
          },
          "granularity": {
            "description": "omit entirely for a single day, or for totals only",
            "type": "string",
            "enum": [
              "HOURLY",
              "DAILY",
              "WEEKLY",
              "MONTHLY"
            ]
          },
          "groupBy": {
            "description": "restricted per level — see the description; an unsupported value is refused by name",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "timeZone": {
            "description": "ORTZ = the organization time zone, Apple’s default. Search-term reports accept ORTZ only.",
            "type": "string",
            "enum": [
              "ORTZ",
              "UTC"
            ]
          },
          "grandTotals": {
            "description": "add a summary row across all result rows",
            "type": "boolean"
          },
          "emptyMetrics": {
            "description": "include entities with no delivery. App Store reports only, and never together with groupBy.",
            "type": "boolean"
          },
          "fields": {
            "description": "limit the returned fields. Omit for all of them.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "offset": {
            "description": "row to start at (default 0). hasMore is now MEASURED against Apple’s own pagination.totalCount rather than inferred from a full page, and the default ordering is localSpend DESCENDING — so omitted rows are the LOW-SPEND tail.",
            "type": "number"
          },
          "limit": {
            "description": "rows per page, up to 5000 (default 100)",
            "type": "number"
          }
        },
        "required": [
          "startTime",
          "endTime"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_orgs",
      "title": "List Apple Ads organizations",
      "description": "The Apple Ads ad accounts these credentials can act as, with the roles held on each, plus the organization’s currency, time zone and payment model. NAMING: Apple’s older API called these “organizations” (campaign groups) and its Platform API calls them AD ACCOUNTS — the id is the same number, and `orgId` and `adAccountId` on each row are equal. One login can cover several: an agency managing multiple clients has one per client. Reading this does NOT switch account: Apple Ads is pinned to the ONE chosen when the connection was made, so an agent can never act as another client’s. To use a different one, pick it in Settings ▸ Connectors ▸ Apple Ads ▸ Manage accounts, with set_connector_accounts, or by reconnecting. A payment model of null is worth reporting: Apple states that without one, campaigns cannot run. Read-only, free.",
      "group": "ads",
      "section": "APPLE ADS (Apple Search Ads) — READS on the Campaign Management API v5 (2026-08-14)",
      "annotations": {
        "title": "List Apple Ads organizations",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_apple_ads_campaign",
      "title": "Create an Apple Ads campaign",
      "description": "Create an Apple Ads (Apple Search Ads) campaign promoting an iOS app on the App Store. CREATED PAUSED ALWAYS — there is no override, so nothing can spend until you separately call set_apple_ads_status(status:\"ENABLED\", confirm:true). REQUIRED: name (unique across the whole Apple Ads ORGANIZATION, not just this ad account — Apple 400s DUPLICATE_CAMPAIGN_NAME, and the org can contain campaigns you cannot see), appAdamId (the App Store id, e.g. \"6752439949\" — read it off any existing campaign’s promotedObjectId), dailyBudget (decimal string, e.g. \"25.00\"), countries (ISO 3166-1 alpha-2). placements defaults to APPSTORE_SEARCH_RESULTS, the classic Search Ads placement, which needs NO creative — Apple renders your App Store product page. bidStrategyType defaults to MANUAL_CPT (a fixed price per tap); MAX_CONVERSIONS optimizes for installs and is the only strategy that takes a campaign-level bid, where it means the target CPA. A MANUAL_CPT per-tap bid does NOT belong here: Apple refuses it at campaign level with an error naming a field you never sent — pass defaultBid to create_apple_ads_ad_group instead. Free.",
      "group": "ads",
      "section": "APPLE ADS: BUILDING, NOT JUST READING (2026-08-14)",
      "annotations": {
        "title": "Create an Apple Ads campaign",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "REQUIRED. Unique across the entire Apple Ads organization."
          },
          "appAdamId": {
            "type": "string",
            "description": "REQUIRED. App Store id of the promoted app, e.g. \"6752439949\"."
          },
          "dailyBudget": {
            "type": "string",
            "description": "REQUIRED. Decimal string, e.g. \"25.00\"."
          },
          "countries": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "REQUIRED. ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"]."
          },
          "placements": {
            "description": "Default [\"APPSTORE_SEARCH_RESULTS\"]. Also APPSTORE_SEARCH_TAB, APPSTORE_TODAY_TAB, APPSTORE_PRODUCT_PAGES, or MAPS_SEARCH_RESULTS / MAPS_SEARCH_HOME. One campaign cannot mix App Store and Maps placements.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "bidStrategyType": {
            "description": "Default MANUAL_CPT.",
            "type": "string",
            "enum": [
              "MANUAL_CPT",
              "MAX_CONVERSIONS"
            ]
          },
          "bid": {
            "description": "MAX_CONVERSIONS only — the target CPA. Refused on MANUAL_CPT.",
            "type": "string"
          },
          "currency": {
            "description": "Defaults to the connected ad account’s currency; an account cannot mix currencies.",
            "type": "string"
          },
          "startTime": {
            "type": "string"
          },
          "endTime": {
            "description": "Omit to run indefinitely.",
            "type": "string"
          },
          "budgetOrderIds": {
            "description": "budget order id(s) from list_apple_ads_budget_orders to assign this campaign to — a budget order is a spend CEILING SHARED across every campaign assigned to it, and it works ALONGSIDE dailyBudget rather than instead of it. Without this a budget order can never be drawn from.",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "name",
          "appAdamId",
          "dailyBudget",
          "countries"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_apple_ads_ad_group",
      "title": "Create an Apple Ads ad group",
      "description": "Create an ad group inside an Apple Ads campaign — this is where the bid and the keywords live. CREATED PAUSED ALWAYS. REQUIRED: campaignId, name. defaultBid is the most you will pay per tap (decimal string, e.g. \"1.50\") and applies to every keyword with no bid of its own. startTime is optional here and defaults to now: Apple documents it as optional and then REJECTS the create without it, so Hermoso always supplies one. searchMatch:true opts into Apple’s automated keyword matching, which targets relevant search terms beyond your keyword list. pricingModel defaults to CPT and must match the campaign’s billing event. Keywords cannot be created inline — make the ad group, then call add_apple_ads_keywords. targeting is who and where it reaches: FOR AN APPLE MAPS AD GROUP, targeting.locationGroupIds is the only field that says WHICH of the brand’s shops this ad group promotes, and an ad group with none stops serving rather than reaching everywhere — build the group first with create_apple_ads_location_group. Radius and geo targeting cannot be combined in one ad group and that is refused here rather than delivered on terms nobody chose. Like the campaign it lives in, it stays PAUSED until set_apple_ads_status(status:\"ENABLED\", confirm:true) arms it. Free.",
      "group": "ads",
      "section": "APPLE ADS: BUILDING, NOT JUST READING (2026-08-14)",
      "annotations": {
        "title": "Create an Apple Ads ad group",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "campaignId": {
            "type": "string",
            "description": "REQUIRED."
          },
          "name": {
            "type": "string",
            "description": "REQUIRED."
          },
          "defaultBid": {
            "description": "Max cost per tap, e.g. \"1.50\".",
            "type": "string"
          },
          "currency": {
            "type": "string"
          },
          "startTime": {
            "description": "Defaults to now.",
            "type": "string"
          },
          "endTime": {
            "description": "Omit to inherit the campaign end date.",
            "type": "string"
          },
          "searchMatch": {
            "description": "Opt into Apple’s automated keyword matching.",
            "type": "boolean"
          },
          "pricingModel": {
            "description": "Default CPT; must match the campaign billing event.",
            "type": "string",
            "enum": [
              "CPT",
              "CPM",
              "CPA"
            ]
          },
          "targeting": {
            "description": "Who and where this ad group reaches. For Apple Maps, locationGroupIds is what says which shops it promotes.",
            "type": "object",
            "properties": {
              "locationGroupIds": {
                "description": "APPLE MAPS — the location groups whose shops this ad group promotes. Max 25.",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "radius": {
                "description": "Apple Maps — how near a person must be. Cannot be combined with adminArea/locality/postalCode.",
                "type": "string",
                "enum": [
                  "CLOSE",
                  "MEDIUM",
                  "FAR"
                ]
              },
              "adminAreaIds": {
                "description": "State/province ids from the Geo API.",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "localityIds": {
                "description": "City ids from the Geo API.",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "postalCodeIds": {
                "description": "Postal code ids from the Geo API (Apple Maps).",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "countryIds": {
                "description": "Country ids from the Geo API (App Store).",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "deviceClass": {
                "description": "App Store.",
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "IPHONE",
                    "IPAD"
                  ]
                }
              },
              "minAge": {
                "description": "App Store. 18-64.",
                "type": "number"
              },
              "maxAge": {
                "description": "App Store. 18-64; omit entirely to reach 65+.",
                "type": "number"
              },
              "gender": {
                "description": "App Store.",
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "M",
                    "F"
                  ]
                }
              },
              "appCategoryIds": {
                "description": "App Store. 100 means \"the same category as my app\".",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "excludeAppCategoryIds": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "appDownloaderIds": {
                "description": "App Store — reach people who downloaded these Adam IDs.",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "excludeAppDownloaderIds": {
                "description": "App Store — suppress people who already have your app.",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "daypart": {
                "description": "Hour slots 0-167, slot 0 = Sunday 12am, in the ad account time zone.",
                "type": "array",
                "items": {
                  "type": "number"
                }
              }
            }
          }
        },
        "required": [
          "campaignId",
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "add_apple_ads_keywords",
      "title": "Add Apple Ads keywords",
      "description": "Add targeting keywords to an Apple Ads ad group. keywords is [{text, matchType, bid}] — matchType EXACT (default), BROAD, PHRASE or CATEGORY, and bid optionally overrides the ad group default for that one term. Up to 1000 per call and 5000 per ad group. They are sent ONE AT A TIME on purpose: a single term Apple refuses cannot take the rest of the batch down, and the reply names every refusal with Apple’s own reason, then reads the ad group’s keywords back to prove what actually landed. Keyword text and match type are IMMUTABLE — to change either, delete the keyword and add it again. A keyword is created ENABLED because it cannot serve unless its ad group and campaign are enabled too; if the ad group is ALREADY live, adding keywords serves on the next auction and therefore takes confirm:true. Free.",
      "group": "ads",
      "section": "APPLE ADS: BUILDING, NOT JUST READING (2026-08-14)",
      "annotations": {
        "title": "Add Apple Ads keywords",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adGroupId": {
            "type": "string",
            "description": "REQUIRED."
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "text": {
                  "type": "string"
                },
                "matchType": {
                  "type": "string"
                },
                "bid": {
                  "type": "string"
                }
              },
              "required": [
                "text"
              ]
            },
            "description": "REQUIRED. Up to 1000."
          },
          "matchType": {
            "description": "Default for entries that do not set one.",
            "type": "string",
            "enum": [
              "EXACT",
              "BROAD",
              "PHRASE",
              "CATEGORY"
            ]
          },
          "currency": {
            "type": "string"
          },
          "confirm": {
            "description": "Required ONLY when the ad group is already ENABLED.",
            "type": "boolean"
          }
        },
        "required": [
          "adGroupId",
          "keywords"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "add_apple_ads_negative_keywords",
      "title": "Add Apple Ads negative keywords",
      "description": "Add negative keywords to an Apple Ads campaign or ad group — search terms your ads must NOT show for. Pass exactly one of campaignId (campaign-wide) or adGroupId (that ad group only); Apple rejects a request carrying both. keywords is [{text, matchType}] with matchType BROAD (default) or EXACT. Never confirm-gated and never a spend risk, because a negative keyword only ever RESTRICTS where you show. Text and match type are immutable — delete and re-add to change either. Free.",
      "group": "ads",
      "section": "APPLE ADS: BUILDING, NOT JUST READING (2026-08-14)",
      "annotations": {
        "title": "Add Apple Ads negative keywords",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "campaignId": {
            "description": "Campaign-wide. Pass this OR adGroupId, never both.",
            "type": "string"
          },
          "adGroupId": {
            "description": "One ad group. Pass this OR campaignId, never both.",
            "type": "string"
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "text": {
                  "type": "string"
                },
                "matchType": {
                  "type": "string"
                }
              },
              "required": [
                "text"
              ]
            },
            "description": "REQUIRED."
          },
          "matchType": {
            "description": "Default BROAD.",
            "type": "string",
            "enum": [
              "EXACT",
              "BROAD",
              "PHRASE",
              "CATEGORY"
            ]
          }
        },
        "required": [
          "keywords"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_apple_ads_object",
      "title": "Edit an Apple Ads campaign, ad group, keyword or location group",
      "description": "Edit an existing Apple Ads campaign, ad group, keyword, creative or ad. Pass level and id plus ONLY the fields you want to change — Apple leaves every field you omit exactly as it is. Campaign: name, dailyBudget, startTime, endTime, countries, bidStrategyType. Ad group: name, defaultBid, startTime, endTime, searchMatch. Keyword: bid only — its text and match type are immutable, so delete and re-add to change either. Creative: name, plus (Apple Maps creatives only) a new creativeSpec — a creative’s type and destination are locked at creation, so pointing at a different app or product page means creating a NEW creative. Ad: name only — its creative and ad group are locked too, and Apple’s own instruction for serving a different creative is to create a new ad and delete the old one. Location group: name, description, and locationIds or rules. WARNING, AND IT APPLIES TO EVERY ARRAY HERE: AN ARRAY REPLACES RATHER THAN MERGES. The countries you send become the campaign’s entire geographic targeting, the targeting dimensions you send become the ad group’s entire setting for those dimensions, and Apple states it for location groups in terms — to add ONE shop to a group, read its current locationIds, append, and send the whole list back, or you have just removed every other shop. Send everything you want to keep, never just the new one. This tool deliberately CANNOT change a status: enabling arms real spend, so it lives behind set_apple_ads_status and its confirmation. Free.",
      "group": "ads",
      "section": "APPLE ADS: BUILDING, NOT JUST READING (2026-08-14)",
      "annotations": {
        "title": "Edit an Apple Ads campaign, ad group, keyword or location group",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "level": {
            "type": "string",
            "enum": [
              "campaign",
              "adgroup",
              "keyword",
              "creative",
              "ad",
              "location_group"
            ],
            "description": "REQUIRED."
          },
          "id": {
            "type": "string",
            "description": "REQUIRED."
          },
          "name": {
            "type": "string"
          },
          "dailyBudget": {
            "description": "Campaign only.",
            "type": "string"
          },
          "defaultBid": {
            "description": "Ad group only.",
            "type": "string"
          },
          "bid": {
            "description": "Keyword only.",
            "type": "string"
          },
          "countries": {
            "description": "Campaign only. REPLACES the whole list.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "bidStrategyType": {
            "description": "Campaign only.",
            "type": "string",
            "enum": [
              "MANUAL_CPT",
              "MAX_CONVERSIONS"
            ]
          },
          "searchMatch": {
            "description": "Ad group only.",
            "type": "boolean"
          },
          "targeting": {
            "description": "Ad group only. Same shape as create_apple_ads_ad_group.targeting — locationGroupIds, radius, adminAreaIds, daypart and the rest. REPLACES the dimensions you name.",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "locationIds": {
            "description": "Location group only (STATIC). REPLACES the whole membership.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "rules": {
            "description": "Location group only (DYNAMIC). REPLACES every rule and puts the group back to PENDING.",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "field": {
                  "type": "string"
                },
                "operator": {
                  "type": "string"
                },
                "value": {}
              },
              "required": [
                "field",
                "operator",
                "value"
              ]
            }
          },
          "description": {
            "description": "Location group only.",
            "type": "string"
          },
          "startTime": {
            "type": "string"
          },
          "endTime": {
            "type": "string"
          },
          "currency": {
            "type": "string"
          },
          "budgetOrderIds": {
            "description": "budget order id(s) from list_apple_ads_budget_orders to assign this campaign to — a budget order is a spend CEILING SHARED across every campaign assigned to it, and it works ALONGSIDE dailyBudget rather than instead of it. Without this a budget order can never be drawn from.",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "level",
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_apple_ads_status",
      "title": "Pause or enable an Apple Ads object",
      "description": "Pause or enable an Apple Ads campaign, ad group, keyword or ad. PAUSING is immediate and needs no confirmation. ENABLING IS THE ONE SWITCH THAT ARMS REAL MONEY: it requires confirm:true, and without it nothing changes and the refusal names the object Apple actually holds under that id — read back from Apple, never echoed from your input, because aiming at the wrong campaign is invisible until money moves. An object serves only when it AND every parent above it are ENABLED, so enabling a keyword inside a paused campaign spends nothing. Prefer pausing to deleting: pausing is reversible and Apple’s delete is not. A CREATIVE, AN ASSET AND A LOCATION GROUP ARE DELIBERATELY ABSENT from the levels here and that is not an oversight: none of them has an advertiser status — a creative and a location group carry only a read-only systemStatus Apple computes — so to stop a creative serving, pause the ADS that reference it, and to stop promoting a set of shops, pause the AD GROUP that targets the location group. The reply reports the status Apple STORED, which is a different claim from the one it accepted. Free.",
      "group": "ads",
      "section": "APPLE ADS: BUILDING, NOT JUST READING (2026-08-14)",
      "annotations": {
        "title": "Pause or enable an Apple Ads object",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "level": {
            "type": "string",
            "enum": [
              "campaign",
              "adgroup",
              "keyword",
              "negative_keyword",
              "ad"
            ],
            "description": "REQUIRED."
          },
          "id": {
            "type": "string",
            "description": "REQUIRED."
          },
          "status": {
            "type": "string",
            "enum": [
              "ENABLED",
              "PAUSED"
            ],
            "description": "REQUIRED."
          },
          "confirm": {
            "description": "REQUIRED to ENABLE — it arms real spend. Pausing needs none.",
            "type": "boolean"
          }
        },
        "required": [
          "level",
          "id",
          "status"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_apple_ads_object",
      "title": "Delete an Apple Ads object",
      "description": "Delete an Apple Ads campaign, ad group, keyword, negative keyword, ad, creative, asset, location group or budget order. Requires confirm:true, and optionally confirmName echoed back to prove you aimed at the right object. THIS CASCADES AND CANNOT BE UNDONE: Apple soft-deletes with no undelete, and deleting a campaign takes every ad group, keyword and ad underneath it. THE BLAST RADIUS DIFFERS BY LEVEL and the refusal states the right one for the object you named: deleting a CREATIVE does not delete the ads that use it — it makes every one of them stop serving, permanently, with no way back but a new creative; deleting an ASSET makes any Apple Maps creative using it INVALID; deleting an AD leaves its creative untouched and available; deleting a LOCATION GROUP is permanent with no restore, and every ad group targeting it loses that constraint immediately — an ad group left targeting no location group STOPS serving rather than reaching everywhere. In almost every case set_apple_ads_status(status:\"PAUSED\") is what you actually want — it stops all spend and is reversible — but a creative, an asset, a location group and a budget order have NO status, so for those the reversible move is pausing whatever references them. DELETING A BUDGET ORDER CASCADES TO NOTHING — it is a spend ceiling, not a parent — but every campaign assigned to it loses that ceiling, and Apple REFUSES the delete outright while any campaign is still assigned, or once the order has started, or if it is cancelled, completed or exhausted; the refusal names whichever applies before anything is sent. The reply is confirmed by RE-READING the object: for a delete, an absent or deleted-flagged row is the proof, never the HTTP 200. Free.",
      "group": "ads",
      "section": "APPLE ADS: BUILDING, NOT JUST READING (2026-08-14)",
      "annotations": {
        "title": "Delete an Apple Ads object",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "level": {
            "type": "string",
            "enum": [
              "campaign",
              "adgroup",
              "keyword",
              "negative_keyword",
              "ad",
              "creative",
              "asset",
              "location_group",
              "budget_order"
            ],
            "description": "REQUIRED."
          },
          "id": {
            "type": "string",
            "description": "REQUIRED."
          },
          "confirm": {
            "description": "REQUIRED. Nothing is deleted without it.",
            "type": "boolean"
          },
          "confirmName": {
            "description": "Optional — echo the object’s exact name to prove you aimed at the right one.",
            "type": "string"
          }
        },
        "required": [
          "level",
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_apple_ads_apps",
      "title": "Find an App Store app to advertise",
      "description": "Find the App Store app to advertise and its adamId — the id every other Apple Ads tool needs (create_apple_ads_campaign’s appAdamId, a creative’s destination, an eligibility check). Call it with NO arguments to list the apps THIS Apple Ads organization owns, which is what a first-time advertiser wants: only an app you own can be promoted, so a public app you merely found by name cannot be used as promotedObjectId. Pass query to search the App Store catalogue by name or developer, or adamId to fetch one app’s full details — including availableStorefronts, the definitive list of countries the app can be advertised in and the set a campaign’s countries must be a subset of. Read-only, free.",
      "group": "ads",
      "section": "APPLE ADS: CREATIVES AND ADS (2026-08-15)",
      "annotations": {
        "title": "Find an App Store app to advertise",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "description": "App name or developer to search the App Store for.",
            "type": "string"
          },
          "adamId": {
            "description": "Fetch one app’s full details instead of searching.",
            "type": "string"
          },
          "ownedOnly": {
            "description": "Default true — only apps this organization owns (the only ones that can be promoted). false searches the whole catalogue. Apple’s own default is false; Hermoso flips it because only an owned app can be advertised.",
            "type": "boolean"
          },
          "storeFronts": {
            "description": "App Store country/region codes to search in, e.g. [\"US\",\"GB\"].",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "cpids": {
            "description": "Comma-separated iTunes content provider ids to scope the search to.",
            "type": "string"
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "check_apple_ads_app_eligibility",
      "title": "Check whether an app may be advertised",
      "description": "Ask Apple whether an app may be advertised at all, PER PLACEMENT AND PER COUNTRY — the check Apple tells you to run before building a campaign around an app. Each row is ELIGIBLE or INELIGIBLE for one combination of placement, country or region and device class, with the minimum age rating for that market. A campaign targeting a country the app is INELIGIBLE in simply will not deliver there and nothing in the campaign build says so, so this is how you find out for free beforehand. adamId is REQUIRED. NO ROWS IS NOT THE SAME AS INELIGIBLE — it usually means the app is not owned by this organization; report that distinction rather than presenting it as a refusal. Read-only, free.",
      "group": "ads",
      "section": "APPLE ADS: CREATIVES AND ADS (2026-08-15)",
      "annotations": {
        "title": "Check whether an app may be advertised",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adamId": {
            "type": "string",
            "description": "REQUIRED — from search_apple_ads_apps."
          },
          "supplyPlacement": {
            "description": "Narrow to placements, e.g. [\"APPSTORE_SEARCH_RESULTS\",\"APPSTORE_SEARCH_TAB\"].",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "countryOrRegion": {
            "description": "ISO 3166-1 alpha-2 codes.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "deviceClass": {
            "description": "IPHONE and/or IPAD.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "limit": {
            "type": "number"
          }
        },
        "required": [
          "adamId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_product_pages",
      "title": "List App Store product pages",
      "description": "The App Store product pages this ad account can advertise — the post-tap destination a creative points at. Every app has exactly one DEFAULT product page (its standard listing, needing no setup) plus any CUSTOM PRODUCT PAGES the developer built in App Store Connect, each with its own productPageId (a UUID, not an integer) that a CUSTOM_PRODUCT_PAGE creative requires. Filter by adamId. Pass localeDetails:true to read the per-language content instead — appName, subtitle, promotional text and the screenshots and preview videos by device class — for a custom page (productPageId) or for the app’s default listing (adamId); Hermoso picks the right one of Apple’s two endpoints for you. READ-ONLY BY DESIGN: product pages are authored in App Store Connect, not through any API, and a page you just published appears here only after a short propagation delay. Free.",
      "group": "ads",
      "section": "APPLE ADS: CREATIVES AND ADS (2026-08-15)",
      "annotations": {
        "title": "List App Store product pages",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adamId": {
            "description": "App Store id — one app’s pages, or its DEFAULT listing’s locale details.",
            "type": "string"
          },
          "productPageId": {
            "description": "A specific custom product page (UUID).",
            "type": "string"
          },
          "localeDetails": {
            "description": "Read per-language content instead of the page list.",
            "type": "boolean"
          },
          "languageCode": {
            "description": "With localeDetails — one locale, e.g. \"en-US\". Omit for all.",
            "type": "string"
          },
          "language": {
            "description": "With localeDetails — a language, e.g. \"en\".",
            "type": "string"
          },
          "state": {
            "description": "Filter by page state; the typical live value is PUBLISHED.",
            "type": "string"
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_creatives",
      "title": "List Apple Ads creatives",
      "description": "The ad creatives on this Apple Ads account, each with its type, the app and product page it points at, its systemStatus (VALID / INVALID / PENDING) and its eligibility per ad placement. A creative is reusable — one can back ads in several ad groups and campaigns. Filter by creativeType, systemStatus, a name prefix or eligibility, pass id for a single one, or includeDeleted:true to see deleted records (Apple excludes them by default). AN EMPTY LIST IS NORMAL on an account that only runs classic Search Results campaigns: those need no creative at all, because Apple renders the App Store product page itself. Read-only, free.",
      "group": "ads",
      "section": "APPLE ADS: CREATIVES AND ADS (2026-08-15)",
      "annotations": {
        "title": "List Apple Ads creatives",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "description": "Fetch one creative.",
            "type": "string"
          },
          "creativeType": {
            "type": "string",
            "enum": [
              "DEFAULT_PRODUCT_PAGE",
              "CUSTOM_PRODUCT_PAGE",
              "LOCAL_ADS_SEARCH_CREATIVE"
            ]
          },
          "systemStatus": {
            "type": "string",
            "enum": [
              "VALID",
              "INVALID",
              "PENDING"
            ]
          },
          "name": {
            "description": "Name prefix.",
            "type": "string"
          },
          "eligibility": {
            "type": "string",
            "enum": [
              "ELIGIBLE",
              "INELIGIBLE"
            ]
          },
          "includeDeleted": {
            "type": "boolean"
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_apple_ads_creative",
      "title": "Create an Apple Ads creative",
      "description": "Create an Apple Ads creative — what a person sees before and after tapping the ad. Pick creativeType: DEFAULT_PRODUCT_PAGE renders the app’s standard App Store listing and needs only adamId; CUSTOM_PRODUCT_PAGE renders a custom page built in App Store Connect and needs adamId AND productPageId (from list_apple_ads_product_pages); LOCAL_ADS_SEARCH_CREATIVE is Apple Maps and needs brandId (from list_apple_ads_brands) plus assetIds you uploaded with upload_apple_ads_asset — that is the one creative type where YOUR OWN IMAGE is the ad. YOU CANNOT SUPPLY YOUR OWN IMAGERY FOR AN APP STORE AD — Apple renders the pre-tap ad from the product page itself, so the way to change how an App Store ad LOOKS is to edit that page (or build a Custom Product Page) in App Store Connect; say that plainly rather than looking for a parameter that does not exist. The destination type is derived from the creative type and never asked for. A creative SPENDS NOTHING and has no status of its own: it serves only once an ad references it and that ad, its ad group and its campaign are all ENABLED. Apple validates asynchronously, so a fresh creative is usually PENDING for a moment and an ad may only reference a VALID one — that is a wait, not a failure. Type and destination are immutable afterwards. Free.",
      "group": "ads",
      "section": "APPLE ADS: CREATIVES AND ADS (2026-08-15)",
      "annotations": {
        "title": "Create an Apple Ads creative",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "REQUIRED."
          },
          "creativeType": {
            "description": "Default DEFAULT_PRODUCT_PAGE.",
            "type": "string",
            "enum": [
              "DEFAULT_PRODUCT_PAGE",
              "CUSTOM_PRODUCT_PAGE",
              "LOCAL_ADS_SEARCH_CREATIVE"
            ]
          },
          "adamId": {
            "description": "REQUIRED for App Store creatives — the App Store id.",
            "type": "string"
          },
          "productPageId": {
            "description": "REQUIRED for CUSTOM_PRODUCT_PAGE; refused on DEFAULT_PRODUCT_PAGE, which is the app’s standard listing.",
            "type": "string"
          },
          "brandId": {
            "description": "Apple Maps only — the brand this creative belongs to.",
            "type": "string"
          },
          "creativeSubtype": {
            "description": "Apple Maps only. Default BUSINESS_LOGO.",
            "type": "string",
            "enum": [
              "BUSINESS_LOGO",
              "BUSINESS_ASSET"
            ]
          },
          "assetIds": {
            "description": "Apple Maps only — ids from upload_apple_ads_asset.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "localizedText": {
            "description": "Apple Maps only — promo copy per locale, e.g. {\"en-US\":\"Visit us today\"}.",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "defaultLocale": {
            "description": "Apple Maps only, e.g. \"en-US\".",
            "type": "string"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_ads",
      "title": "List Apple Ads ads",
      "description": "The ads on this Apple Ads account — the serving units that put a creative into an ad group. Each row carries the advertiser status, the rolled-up displayStatus, Apple’s own systemStatus and, when an ad is not serving, systemStatusReasons saying exactly why: AD_APPROVAL_PENDING while Apple reviews it, CREATIVE_PENDING, CREATIVE_LOCALE_INCOMPATIBLE, PRODUCT_PAGE_HIDDEN, PAUSED_BY_USER and more. READ THAT FIELD BEFORE CONCLUDING ANYTHING IS BROKEN — a brand-new ad awaiting Apple’s review is NOT_RUNNING by design, and systemStatusLimitingReasons means it IS running but throttled. Filter by adGroupId, campaignId, creativeId or status, or pass id for one. Read-only, free.",
      "group": "ads",
      "section": "APPLE ADS: CREATIVES AND ADS (2026-08-15)",
      "annotations": {
        "title": "List Apple Ads ads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "description": "Fetch one ad.",
            "type": "string"
          },
          "adGroupId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string"
          },
          "creativeId": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "ENABLED",
              "PAUSED"
            ]
          },
          "includeDeleted": {
            "type": "boolean"
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_apple_ads_ad",
      "title": "Create an Apple Ads ad",
      "description": "Create an Apple Ads ad — the object that shows one creative in one ad group. REQUIRED: adGroupId, creativeId (from create_apple_ads_creative) and name. CREATED PAUSED ALWAYS, with no override, so it cannot spend until set_apple_ads_status(level:\"ad\", status:\"ENABLED\", confirm:true) and its parents are enabled too. THE PLACEMENT RULE, which Apple documents nowhere and which is checked FOR FREE before anything is written: an ad attaches only to a campaign whose placement is APPSTORE_SEARCH_TAB. Apple refuses ads on APPSTORE_SEARCH_RESULTS, APPSTORE_TODAY_TAB and APPSTORE_PRODUCT_PAGES campaigns with AD_CAMPAIGN_SUPPLY_SOURCES_NOT_SUPPORTED — and a SEARCH RESULTS campaign needs no ad and no creative at all, because Apple renders your App Store product page itself, so such a campaign is already complete. The creative must be VALID; a PENDING one is a wait, not a failure. Only ONE ad per ad group can be ENABLED at a time, so enabling this one means pausing whichever ad serves there now. Its creative and ad group are locked at creation: to serve a different creative, create a NEW ad and delete this one, which is Apple’s own instruction so delivery history stays traceable. NO CONFIRMATION IS NEEDED even inside a live ad group, and that is deliberate rather than an oversight: a paused ad cannot serve and does not displace whichever ad is serving there, so gating it would be friction with no safety behind it. Free.",
      "group": "ads",
      "section": "APPLE ADS: CREATIVES AND ADS (2026-08-15)",
      "annotations": {
        "title": "Create an Apple Ads ad",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adGroupId": {
            "type": "string",
            "description": "REQUIRED."
          },
          "creativeId": {
            "type": "string",
            "description": "REQUIRED — from create_apple_ads_creative."
          },
          "name": {
            "type": "string",
            "description": "REQUIRED."
          }
        },
        "required": [
          "adGroupId",
          "creativeId",
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "upload_apple_ads_asset",
      "title": "Upload an Apple Ads image asset",
      "description": "Upload an image into the Apple Ads asset library for an APPLE MAPS brand creative. APPLE MAPS ONLY, and that is Apple’s rule rather than ours: it refuses uploads for App Store apps outright, because an App Store ad is rendered from the app’s product page in App Store Connect and not from an image anyone uploads. That refusal is stated up front instead of being sent and rejected, so if you want a different-looking App Store ad the answer is App Store Connect, not this tool. PNG, JPG or HEIC. Pass imageUrl (upload_file turns a local file into one) and brandId, which list_apple_ads_brands returns. Apple processes the upload asynchronously, so re-read it with list_apple_ads_assets until its eligibility says it is ready before referencing it in a creative. Uploading spends nothing. Free.",
      "group": "ads",
      "section": "APPLE ADS: CREATIVES AND ADS (2026-08-15)",
      "annotations": {
        "title": "Upload an Apple Ads image asset",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "imageUrl": {
            "type": "string",
            "description": "REQUIRED — a PNG, JPG or HEIC image Hermoso can fetch."
          },
          "brandId": {
            "type": "string",
            "description": "REQUIRED — the Apple Maps brand this asset belongs to."
          },
          "filename": {
            "description": "Decides the format Apple is told; defaults to the name in the URL.",
            "type": "string"
          },
          "promotedObjectType": {
            "description": "Only BUSINESS_BRAND is accepted.",
            "type": "string",
            "enum": [
              "BUSINESS_BRAND"
            ]
          }
        },
        "required": [
          "imageUrl",
          "brandId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_assets",
      "title": "List Apple Ads image assets",
      "description": "Images in the Apple Ads asset library with format, orientation, dimensions and eligibility — use it to check whether an uploaded asset has finished processing before referencing it in a creative. Filter by brandId (Apple’s promotedObjectId), promotedObjectType or assetType, or pass id for one. CROPS AND OTHER VARIANTS NEVER APPEAR IN A LIST: Apple omits them from query results, so a variant has to be fetched by its own id. Querying covers both App Store apps and Apple Maps brands even though only Maps assets can be uploaded. Read-only, free.",
      "group": "ads",
      "section": "APPLE ADS: CREATIVES AND ADS (2026-08-15)",
      "annotations": {
        "title": "List Apple Ads image assets",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "description": "Fetch one asset — the only way to see a crop or variant.",
            "type": "string"
          },
          "brandId": {
            "description": "Scope to one brand or app (promotedObjectId).",
            "type": "string"
          },
          "promotedObjectType": {
            "type": "string",
            "enum": [
              "APPSTORE_APP",
              "BUSINESS_BRAND"
            ]
          },
          "assetType": {
            "type": "string",
            "enum": [
              "IMAGE"
            ]
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_brands",
      "title": "List Apple Maps brands",
      "description": "The Apple Maps brands on this Apple Ads account. A BRAND IS THE ROOT OF EVERY ADS-ON-APPLE-MAPS CAMPAIGN and its id is what three other calls need: it is the promotedObjectId of a BUSINESS_BRAND campaign, the brandId upload_apple_ads_asset uploads against, and the brandId a LOCAL_ADS_SEARCH_CREATIVE carries. A brand must read eligibility ELIGIBLE before Apple will let a campaign use it. YOU CANNOT CREATE A BRAND HERE and that is Apple’s design, not a gap: brands come from Apple Business Connect, and Ads on Apple Maps additionally need the ad account to carry productFeatures BUSINESS_BRAND_MANUAL plus a BUSINESS_BRAND delegation, neither of which any API can grant. AN EMPTY LIST IS THE NORMAL ANSWER FOR AN APP STORE ADVERTISER and does not mean anything is broken — App Store campaigns need no brand at all. Read-only, free.",
      "group": "ads",
      "section": "APPLE ADS: APPLE MAPS — THE LANE WHERE THE USER'S OWN IMAGE IS THE CREATIVE (2026-08-16)",
      "annotations": {
        "title": "List Apple Maps brands",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "brandId": {
            "description": "Fetch one brand by id.",
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "countryOrRegion": {
            "description": "Two-letter code, e.g. \"US\".",
            "type": "string"
          },
          "eligibilityStatus": {
            "description": "Usually ELIGIBLE — only an ELIGIBLE brand can be used in a campaign.",
            "type": "string"
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_locations",
      "title": "List Apple Maps business locations",
      "description": "The physical business locations behind an Apple Maps brand — the shops, restaurants or service centres an Ads-on-Apple-Maps campaign promotes. Filter by brandId (do: without it Apple returns every location across every brand, which can be a very large page), plus status, eligibility or address. LOCATIONS ARE READ-ONLY THROUGH THIS API BY APPLE’S DESIGN: Apple Business Connect creates and maintains them, so a missing shop, a wrong address or a closure is fixed there and appears here afterwards — there is no create, update or delete to look for. Only a location with status OPEN and eligibility ELIGIBLE is targetable. You do not target locations directly: collect the ids you want, put them in a location group with create_apple_ads_location_group, then point an ad group at that group. Read-only, free.",
      "group": "ads",
      "section": "APPLE ADS: APPLE MAPS — THE LANE WHERE THE USER'S OWN IMAGE IS THE CREATIVE (2026-08-16)",
      "annotations": {
        "title": "List Apple Maps business locations",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "description": "Fetch one location.",
            "type": "string"
          },
          "brandId": {
            "description": "Strongly recommended — scopes to one brand.",
            "type": "string"
          },
          "status": {
            "description": "Usually OPEN — only OPEN locations are targetable.",
            "type": "string",
            "enum": [
              "OPEN",
              "OPENING_SOON",
              "CLOSED",
              "MOVED",
              "TEMPORARILY_CLOSED"
            ]
          },
          "eligibilityStatus": {
            "type": "string",
            "enum": [
              "ELIGIBLE",
              "INELIGIBLE",
              "LIMITED",
              "PENDING",
              "UNDEFINED"
            ]
          },
          "countryOrRegion": {
            "description": "Two-letter code.",
            "type": "string"
          },
          "adminArea": {
            "description": "Full state or province name, e.g. \"California\".",
            "type": "string"
          },
          "locality": {
            "description": "City name.",
            "type": "string"
          },
          "postalCode": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_location_groups",
      "title": "List Apple Maps location groups",
      "description": "The location groups on this Apple Ads account — named sets of a brand’s shops that an ad group targets. Each row carries its type (STATIC or DYNAMIC), Apple’s systemStatus and groupTotal, the number of locations currently in it. READ groupTotal BEFORE TARGETING A GROUP: a DYNAMIC group sits at PENDING while Apple evaluates its rules, and a group that evaluated to ZERO is Apple’s one silent failure here — it reports no error, so a rule spelled \"CA\" instead of \"California\" builds an empty group that looks fine. Soft-deleted groups are excluded unless you pass includeDeleted. There is no filter for \"which groups contain this location\": query by brandId and inspect membership. Read-only, free.",
      "group": "ads",
      "section": "APPLE ADS: APPLE MAPS — THE LANE WHERE THE USER'S OWN IMAGE IS THE CREATIVE (2026-08-16)",
      "annotations": {
        "title": "List Apple Maps location groups",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "description": "Fetch one group.",
            "type": "string"
          },
          "brandId": {
            "type": "string"
          },
          "name": {
            "description": "Substring match.",
            "type": "string"
          },
          "groupType": {
            "type": "string",
            "enum": [
              "STATIC",
              "DYNAMIC"
            ]
          },
          "includeDeleted": {
            "description": "Deleted groups are hidden by default.",
            "type": "boolean"
          },
          "eligibilityStatus": {
            "type": "string"
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_apple_ads_location_group",
      "title": "Create an Apple Maps location group",
      "description": "Create a location group — the set of a brand’s shops an Apple Maps ad group promotes. TWO KINDS. STATIC takes an explicit locationIds list (from list_apple_ads_locations) and is VALID immediately. DYNAMIC takes rules and maintains itself as the brand opens and closes shops, but starts PENDING while Apple evaluates them — wait for systemStatus VALID before targeting it. A RULE THAT MATCHES NOTHING IS APPLE’S ONE SILENT FAILURE HERE: it returns no error and simply builds a group with groupTotal 0, so adminArea must be the FULL English name (\"California\", never \"CA\") and locality must be countryOrRegion|adminArea|locality (\"US|New York|Brooklyn\", never \"Brooklyn\") — both shapes are refused here before the group is built. A group SPENDS NOTHING and does not decide who sees the ad or how near they are: it only scopes WHICH shops the ad group promotes, while radius and geo targeting on the ad group decide reach. brandId and the ad account are locked in permanently at creation. Target it afterwards with create_apple_ads_ad_group(targeting.locationGroupIds) or update_apple_ads_object(level:\"adgroup\"). Free.",
      "group": "ads",
      "section": "APPLE ADS: APPLE MAPS — THE LANE WHERE THE USER'S OWN IMAGE IS THE CREATIVE (2026-08-16)",
      "annotations": {
        "title": "Create an Apple Maps location group",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "REQUIRED."
          },
          "brandId": {
            "type": "string",
            "description": "REQUIRED — from list_apple_ads_brands. Immutable afterwards."
          },
          "groupType": {
            "description": "Default STATIC. Immutable afterwards.",
            "type": "string",
            "enum": [
              "STATIC",
              "DYNAMIC"
            ]
          },
          "locationIds": {
            "description": "REQUIRED for STATIC — ids from list_apple_ads_locations.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "rules": {
            "description": "REQUIRED for DYNAMIC. value is a string for EQUALS/NOT_EQUALS and an array for IN/NOT_IN.",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "field": {
                  "type": "string",
                  "enum": [
                    "adminArea",
                    "locality",
                    "postalCode",
                    "locationId"
                  ]
                },
                "operator": {
                  "type": "string",
                  "enum": [
                    "EQUALS",
                    "NOT_EQUALS",
                    "IN",
                    "NOT_IN"
                  ]
                },
                "value": {}
              },
              "required": [
                "field",
                "operator",
                "value"
              ]
            }
          },
          "description": {
            "type": "string"
          }
        },
        "required": [
          "name",
          "brandId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "apple_ads_keyword_suggestions",
      "title": "Apple Ads keyword, phrase and category suggestions",
      "description": "Apple’s OWN search data for an app you advertise — the fastest way to build a real Apple Search Ads keyword list instead of guessing. THE THREE KINDS DO NOT SHARE A FILTER VOCABULARY and Apple 400s any field that is not an attribute of the one you query, so each takes different parameters. kind:\"keyword\" (the default) returns suggested keyword strings and accepts `terms` + `countriesOrRegions` — it has NO queryType, and passing one is refused. kind:\"phrase\" returns longer search phrases and REQUIRES `phrases` (the phrases to look up); kind:\"category\" returns App Store categories and REQUIRES `categories`. Neither accepts `terms`. POPULARITY IS RELATIVE, NOT A VOLUME: it ranks terms against each other and cannot be read as searches per month. queryType defaults to \"SEARCH\", which looks up phrases/categories you name; \"SUGGESTION\" asks Apple to discover new ones and IS CURRENTLY BROKEN AT APPLE — measured 2026-08-17, it hangs ~34s and returns a server error for a request identical to Apple’s own published example, so retrying will not clear it. REQUIRED: promotedObjectId — the app’s adamId (search_apple_ads_apps with no arguments lists the apps this organization owns) or an Apple Maps brandId. An empty result is about the APP, not the account. Feed the winners straight into add_apple_ads_keywords or bulk_apple_ads_keywords. Read-only, free, spends nothing.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Apple Ads keyword, phrase and category suggestions",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "kind": {
            "description": "Default keyword. Each kind takes DIFFERENT filters — see the description.",
            "type": "string",
            "enum": [
              "keyword",
              "phrase",
              "category"
            ]
          },
          "promotedObjectId": {
            "type": "string",
            "description": "REQUIRED — the app’s adamId, or an Apple Maps brandId."
          },
          "promotedObjectType": {
            "description": "Default APPSTORE_APP.",
            "type": "string",
            "enum": [
              "APPSTORE_APP",
              "BUSINESS_BRAND"
            ]
          },
          "terms": {
            "description": "kind:\"keyword\" ONLY. Seed search terms to expand from, e.g. [\"productivity\",\"task management\"]. Refused on phrase/category.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "phrases": {
            "description": "kind:\"phrase\" ONLY, and REQUIRED there on the SEARCH route — the phrases to look up, e.g. [\"best productivity app\"].",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "categories": {
            "description": "kind:\"category\" ONLY, and REQUIRED there on the SEARCH route — the App Store categories to look up, e.g. [\"Utilities\"].",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "countriesOrRegions": {
            "description": "ISO 3166-1 alpha-2, e.g. [\"US\",\"GB\"].",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "queryType": {
            "description": "phrase/category ONLY (refused on keyword). Default SEARCH. SUGGESTION is measured BROKEN at Apple — ~34s then a server error.",
            "type": "string",
            "enum": [
              "SUGGESTION",
              "SEARCH"
            ]
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "required": [
          "promotedObjectId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "apple_ads_target_cpa_suggestion",
      "title": "Apple Ads suggested target CPA",
      "description": "What Apple thinks an install should cost you. Returns the suggested target CPA for an app — the MAXIMUM tap-install CPI Apple observed across that app’s eligible markets over the last 28 days, together with the market that produced it. Only markets with at least 10 installs in that window qualify, so a brand-new or low-volume app legitimately has no suggestion yet and that is an answer, not a failure. This is the number to put on a MAX_CONVERSIONS campaign, where the campaign-level bid IS the target CPA — it is NOT a per-tap bid and does not belong on a MANUAL_CPT campaign, which refuses a campaign-level bid outright. Scope it with countriesOrRegions to ask about specific markets. Read-only, free, spends nothing.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Apple Ads suggested target CPA",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "promotedObjectId": {
            "type": "string",
            "description": "REQUIRED — the app’s adamId."
          },
          "promotedObjectType": {
            "description": "Default APPSTORE_APP.",
            "type": "string",
            "enum": [
              "APPSTORE_APP",
              "BUSINESS_BRAND"
            ]
          },
          "countriesOrRegions": {
            "description": "ISO 3166-1 alpha-2 markets to consider, e.g. [\"US\",\"GB\",\"CA\"].",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "promotedObjectId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_recommendations",
      "title": "List Apple Ads recommendations",
      "description": "Apple’s own optimisation engine for this account: campaigns it thinks are budget-capped, and campaigns whose target CPA it thinks should move. type:\"daily_budget\" returns DAILYCAP recommendations, each carrying the current and suggested budget plus the historical installs/taps/spend behind it AND Apple’s projection if you raise it; type:\"target_cpa\" returns TCPA recommendations, which Apple produces ONLY for campaigns on a Maximize Conversions bid strategy — a MANUAL_CPT campaign will never have one. Defaults to state AVAILABLE because that is the only actionable state: APPLIED and DISMISSED are terminal and can never be re-actioned (pass state:\"ALL\" to see the history). Apple names only two recommendation categories with endpoints; its enum also mentions keyword and bid categories but this API version publishes no way to query or act on those. Read-only, free — reading changes nothing.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "List Apple Ads recommendations",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "type": {
            "description": "Default daily_budget.",
            "type": "string",
            "enum": [
              "daily_budget",
              "target_cpa"
            ]
          },
          "promotedObjectId": {
            "type": "string",
            "description": "REQUIRED — the app’s adamId, or an Apple Maps brandId."
          },
          "promotedObjectType": {
            "description": "Default APPSTORE_APP.",
            "type": "string",
            "enum": [
              "APPSTORE_APP",
              "BUSINESS_BRAND"
            ]
          },
          "state": {
            "description": "AVAILABLE (default), APPLIED, DISMISSED, DELETE, or \"ALL\" to skip the filter.",
            "type": "string"
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "required": [
          "promotedObjectId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "apply_apple_ads_recommendation",
      "title": "Apply an Apple Ads recommendation",
      "description": "THIS CHANGES WHAT A LIVE CAMPAIGN MAY SPEND, and it is the only tool in the Apple Ads suggestion/recommendation family that touches money. Applying a daily-budget recommendation raises that campaign’s daily budget to Apple’s suggested amount; applying a target-CPA recommendation moves the CPA its auto-bidder chases. REQUIRES confirm:true, and the refusal you get without it is built from the recommendation READ BACK FROM APPLE — it names the campaign, states current → recommended, and quotes Apple’s own projected spend at the new level — so you are confirming a specific change to a specific campaign rather than confirming that you pressed a button. Pass amount to apply a DIFFERENT number than Apple suggested; omit it to take Apple’s. The campaign’s STATUS IS NOT TOUCHED: this moves a ceiling, it never enables or pauses anything, and it does not make a paused campaign start spending. Only an AVAILABLE recommendation can be applied and applying is TERMINAL. The reply is confirmed by re-reading the CAMPAIGN, not by Apple’s acknowledgement. Free to call; the spending it enables is real.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Apply an Apple Ads recommendation",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "type": {
            "description": "Default daily_budget.",
            "type": "string",
            "enum": [
              "daily_budget",
              "target_cpa"
            ]
          },
          "id": {
            "type": "string",
            "description": "REQUIRED — the recommendation id from list_apple_ads_recommendations."
          },
          "promotedObjectId": {
            "type": "string",
            "description": "REQUIRED — the same app or brand id the recommendation was queried for."
          },
          "promotedObjectType": {
            "description": "Default APPSTORE_APP.",
            "type": "string",
            "enum": [
              "APPSTORE_APP",
              "BUSINESS_BRAND"
            ]
          },
          "amount": {
            "description": "Override Apple’s suggestion with your own decimal amount, e.g. \"500.00\". Omit to apply what Apple suggested.",
            "type": "string"
          },
          "currency": {
            "description": "ISO 4217; defaults to the ad account’s currency.",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true. Without it nothing changes and the refusal states the campaign and the exact budget or CPA change.",
            "type": "boolean"
          }
        },
        "required": [
          "id",
          "promotedObjectId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "dismiss_apple_ads_recommendation",
      "title": "Dismiss an Apple Ads recommendation",
      "description": "Decline an Apple Ads recommendation. NOTHING ABOUT THE CAMPAIGN CHANGES — no budget moves, no CPA moves, no status changes — it records that you reviewed the recommendation and said no, and Apple stops offering that particular one (it may generate a new one later). No confirmation is needed and that is deliberate rather than an oversight: dismissing cannot spend money, and gating the safe direction is what teaches people to pass confirm reflexively on the direction that matters. Only an AVAILABLE recommendation can be dismissed; APPLIED and DISMISSED are terminal, and trying to re-action one is refused for free. Free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Dismiss an Apple Ads recommendation",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "type": {
            "description": "Default daily_budget.",
            "type": "string",
            "enum": [
              "daily_budget",
              "target_cpa"
            ]
          },
          "id": {
            "type": "string",
            "description": "REQUIRED — the recommendation id."
          },
          "promotedObjectId": {
            "type": "string",
            "description": "REQUIRED — the same app or brand id the recommendation was queried for."
          },
          "promotedObjectType": {
            "description": "Default APPSTORE_APP.",
            "type": "string",
            "enum": [
              "APPSTORE_APP",
              "BUSINESS_BRAND"
            ]
          }
        },
        "required": [
          "id",
          "promotedObjectId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "apple_ads_impression_share",
      "title": "Apple Ads impression share",
      "description": "COMPETITIVE INTELLIGENCE: what fraction of the available impressions your app actually captures for each search term, by market and day or week. This is the report that finds where you are being out-bid or out-budgeted — a LOW share on a term with high search popularity means budget, bid or eligibility is capping your reach on a term people are really searching, which is the most actionable row Apple publishes. READ THE ENCODING BEFORE QUOTING A NUMBER: lowImpressionShare and highImpressionShare are TIERED, not continuous. Below 91% the two are equal and read as a single percentage; a row where they diverge to 0.91 and 1.0 means \">90%\", not \"91 to 100\". granularity DAILY covers at most 30 days; WEEKLY_SUN_SAT covers at most 4 weeks and its start date MUST be a Sunday (both are refused for free before any request). reportType FIRST_SLOT (default) is the top ad position only; ALL_SLOTS aggregates every position. Timezone is fixed to UTC by Apple. Read-only, free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Apple Ads impression share",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "promotedObjectId": {
            "type": "string",
            "description": "REQUIRED — the app’s adamId. Apple answers 400 without it."
          },
          "countryOrRegion": {
            "description": "ISO 3166-1 alpha-2, e.g. [\"US\"].",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "start": {
            "type": "string",
            "description": "REQUIRED YYYY-MM-DD. Must be a SUNDAY when granularity is WEEKLY_SUN_SAT."
          },
          "end": {
            "type": "string",
            "description": "REQUIRED YYYY-MM-DD."
          },
          "granularity": {
            "description": "Default DAILY (max 30-day window). WEEKLY_SUN_SAT allows at most 4 weeks.",
            "type": "string",
            "enum": [
              "DAILY",
              "WEEKLY_SUN_SAT"
            ]
          },
          "reportType": {
            "description": "Default FIRST_SLOT.",
            "type": "string",
            "enum": [
              "FIRST_SLOT",
              "ALL_SLOTS"
            ]
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "required": [
          "promotedObjectId",
          "start",
          "end"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "apple_ads_search_term_popularity",
      "title": "Apple Ads search term popularity",
      "description": "The most-searched App Store terms in a genre and market — MARKET-WIDE DEMAND, not your own performance, so it works for finding terms to target before you have spent anything. Cross-read it against apple_ads_impression_share: a term ranking high here where your impression share is low is exactly where budget or bid is leaving installs on the table. genre is a FREE-TEXT App Store genre name (e.g. \"PRODUCTIVITY_UTILITIES\", \"TRAVEL\") and Apple publishes no fixed enum, so it is forwarded as given rather than validated against a list that would go stale. granularity WEEKLY_SUN_SAT uses fixed Sunday–Saturday weeks generated Mondays at 07:00 UTC with 65 weeks of retention; MONTHLY uses calendar months refreshed on the 5th UTC with 15 months of retention — an empty result for a very recent window usually means Apple has not generated that period yet rather than that there is no demand. NOTE THE GRANULARITY SETS DIFFER between the two insights reports: DAILY exists on impression share and NOT here. Read-only, free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Apple Ads search term popularity",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "countryOrRegion": {
            "description": "ISO 3166-1 alpha-2, e.g. [\"US\",\"GB\",\"DE\"].",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "genre": {
            "description": "App Store genre names, free text, e.g. [\"PRODUCTIVITY_UTILITIES\"].",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "start": {
            "type": "string",
            "description": "REQUIRED YYYY-MM-DD. Must be a SUNDAY when granularity is WEEKLY_SUN_SAT."
          },
          "end": {
            "type": "string",
            "description": "REQUIRED YYYY-MM-DD."
          },
          "granularity": {
            "description": "Default WEEKLY_SUN_SAT. DAILY does NOT exist on this report.",
            "type": "string",
            "enum": [
              "WEEKLY_SUN_SAT",
              "MONTHLY"
            ]
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "required": [
          "start",
          "end"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "apple_ads_change_history",
      "title": "Apple Ads change history",
      "description": "Who changed what on this Apple Ads account, and when. Call it with start and/or end to get one row per TRANSACTION GROUP — a single user action that may have touched several fields, which is what changeCount counts — with the user, the entity and the event type (CREATE / UPDATE / DELETE). Then pass a row’s detailId back as detailId to get the field-by-field before and after. THE TIME BOUND IS MANDATORY: Apple answers 400 without one, so pass start (meaning \"since then\"), end (\"up to then\"), or both. Narrow with campaignId, adGroupId, entityType, eventType or userId. Useful for \"why did this campaign’s budget change overnight\" and for reconstructing what an agent did on the account. On a CREATE event the old values are empty by design; a DELETE is implemented as a record update so its old values ARE populated. Read-only, free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Apple Ads change history",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "detailId": {
            "description": "Fetch field-level detail for ONE change. This is a COMPOSITE id ({transactionId}-{entityType}-{entityId}) that appears on each summary row — not a campaign or ad group id.",
            "type": "string"
          },
          "start": {
            "description": "YYYY-MM-DD. Required unless end or an explicit eventTime filter is given.",
            "type": "string"
          },
          "end": {
            "description": "YYYY-MM-DD.",
            "type": "string"
          },
          "filters": {
            "description": "Extra conditions, e.g. [{\"field\":\"campaignId\",\"operator\":\"EQUALS\",\"value\":\"123\"}]. Non-time fields accept only EQUALS and IN.",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_budget_orders",
      "title": "List Apple Ads budget orders",
      "description": "Budget orders on this Apple Ads account — a spend CEILING SHARED ACROSS CAMPAIGNS, which is how an agency caps a client for a quarter: total spend across every campaign assigned to one cannot exceed its value, however generous the individual daily budgets are. Each row carries the value, the active window, the systemStatus and, when it is blocking, the systemStatusReasons saying why (EXHAUSTED, EXPIRED, CANCELLED). Pass id for one. AN EMPTY LIST IS NORMAL: a budget order is optional, and without one each campaign is capped only by its own dailyBudget. Read-only, free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "List Apple Ads budget orders",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "description": "Fetch one budget order, including its invoice details and assigned ad accounts.",
            "type": "string"
          },
          "filters": {
            "description": "Query conditions, e.g. [{\"field\":\"name\",\"operator\":\"CONTAINS\",\"value\":\"Q3\"}].",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_apple_ads_budget_order",
      "title": "Create an Apple Ads budget order",
      "description": "A BUDGET ORDER NEEDS A LINE OF CREDIT ORGANIZATION. Measured on prod 2026-08-17 against org 19534840 (PAYG): Apple answers 403 “The organization does not have a Line of Credit(LOC). Creating Budget Order is permissible only for LOC Organizations. [FORBIDDEN]” — so on a pay-as-you-go account this whole family is unusable and no request shape fixes it. That is Apple’s account tier, not a Hermoso limit and not something to reconnect over. Create a shared spending ceiling that campaigns can draw from. A BUDGET ORDER SPENDS NOTHING AND CANNOT MAKE ANYTHING SERVE — it is a cap, so creating one can only ever reduce what campaigns may spend, never raise it, which is why it takes no confirmation. Nothing draws from it until you set a campaign’s budgetOrderId. TWO APPLE RULES ARE ENFORCED FOR FREE BEFORE ANYTHING IS SENT, because Apple’s own 400s name the field rather than the rule: startTime must be TOMORROW OR LATER at midnight UTC (today is rejected outright), and exactly ONE ad account may be attached — which is why adAccountId is a single value and defaults to the account this connection is pinned to. Omit endTime for an open-ended budget. Apple requires invoice contact details, so primaryBuyerEmail and billingEmail are required and are never invented. Free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Create an Apple Ads budget order",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "REQUIRED."
          },
          "startTime": {
            "type": "string",
            "description": "REQUIRED. Must be TOMORROW or later (midnight UTC) — Apple rejects today."
          },
          "endTime": {
            "description": "Omit for an open-ended budget.",
            "type": "string"
          },
          "amount": {
            "type": "string",
            "description": "REQUIRED total budget as a decimal string, e.g. \"50000.00\"."
          },
          "currency": {
            "description": "ISO 4217; defaults to the ad account’s currency, which it must match.",
            "type": "string"
          },
          "primaryBuyerEmail": {
            "type": "string",
            "description": "REQUIRED — Apple bills and notifies this address."
          },
          "billingEmail": {
            "type": "string",
            "description": "REQUIRED."
          },
          "invoiceName": {
            "type": "string"
          },
          "orderNumber": {
            "type": "string"
          },
          "clientName": {
            "type": "string"
          },
          "primaryBuyerName": {
            "type": "string"
          }
        },
        "required": [
          "name",
          "startTime",
          "amount",
          "primaryBuyerEmail",
          "billingEmail"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_apple_ads_budget_order",
      "title": "Update an Apple Ads budget order",
      "description": "Change a budget order: its name, window, total value, attached ad account or invoice details. Only the fields you send change; everything omitted keeps its value. RAISING `amount` IS HOW YOU EXTEND A BUDGET APPROACHING EXHAUSTION — an exhausted budget order stops every campaign drawing from it, so this is the tool to reach for when campaigns stop serving with budget left on their own daily caps. AN END DATE ON AN ACTIVE BUDGET ORDER CAN ONLY BE SHORTENED, NEVER EXTENDED; the one exception is openEnded:true, which clears the end date entirely and makes the budget run indefinitely. startTime, if changed, must still be tomorrow or later. Free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Update an Apple Ads budget order",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "REQUIRED."
          },
          "name": {
            "type": "string"
          },
          "startTime": {
            "description": "Must still be TOMORROW or later.",
            "type": "string"
          },
          "endTime": {
            "description": "Can only SHORTEN an active budget order’s window.",
            "type": "string"
          },
          "openEnded": {
            "description": "true clears the end date entirely, making the budget open-ended. This is the only way to extend rather than shorten.",
            "type": "boolean"
          },
          "amount": {
            "description": "New total, e.g. \"75000.00\". Raise it to extend a budget nearing exhaustion.",
            "type": "string"
          },
          "currency": {
            "type": "string"
          },
          "adAccountId": {
            "description": "Exactly one — Apple rejects more.",
            "type": "string"
          },
          "orderNumber": {
            "type": "string"
          },
          "invoiceName": {
            "type": "string"
          },
          "clientName": {
            "type": "string"
          },
          "primaryBuyerName": {
            "type": "string"
          },
          "primaryBuyerEmail": {
            "type": "string"
          },
          "billingEmail": {
            "type": "string"
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "bulk_apple_ads_keywords",
      "title": "Bulk create or update Apple Ads keywords",
      "description": "Create or update up to 1,000 Apple Ads keywords or negative keywords in ONE call — how you seed a whole campaign structure from apple_ads_keyword_suggestions, or re-bid a list after a performance review, instead of one call per keyword. A single request can span DIFFERENT AD GROUPS. op:\"create\" takes full keyword rows; op:\"update\" takes ids and can change ONLY bid and status, because a keyword’s text and matchType are immutable after creation — changing either means deleting it and creating a new one. ALLOW-PARTIAL-SUCCESS MATTERS AND APPLE’S DEFAULT IS ALL-OR-NOTHING: with it unset, one bad item (a duplicate text/matchType, say) rejects the ENTIRE batch and nothing is written; pass allowPartialSuccess:true to let the good ones land and read the per-item errors. The reply is the per-item read-back Apple returns — each row carries the stored id, text, matchType and status, so you can see exactly which items landed rather than trusting a 200. Adding keywords into an ENABLED ad group can serve on the next auction and therefore takes confirm:true, the same guard add_apple_ads_keywords uses. Apple caps 1,000 per call and 5,000 per ad group in total. Free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Bulk create or update Apple Ads keywords",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "kind": {
            "description": "Default keyword.",
            "type": "string",
            "enum": [
              "keyword",
              "negative_keyword"
            ]
          },
          "op": {
            "description": "Default create.",
            "type": "string",
            "enum": [
              "create",
              "update"
            ]
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            },
            "description": "REQUIRED. create: [{adGroupId,text,matchType,bid?}] for keywords, or [{campaignId|adGroupId,text,matchType}] for negatives. update: [{id,bid?,status?}]."
          },
          "allowPartialSuccess": {
            "description": "true keeps the items that succeeded when others fail. Apple’s default is all-or-nothing.",
            "type": "boolean"
          },
          "currency": {
            "description": "ISO 4217 for bids; defaults to the ad account’s currency.",
            "type": "string"
          },
          "confirm": {
            "description": "Required only when the target ad group or campaign is already ENABLED, where the new keywords could serve immediately.",
            "type": "boolean"
          }
        },
        "required": [
          "items"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_apple_ads_geo",
      "title": "Search Apple Ads geo locations",
      "description": "Resolve a place NAME into the geo id an Apple Ads ad group actually targets. THIS IS THE ONLY WAY TO GET THOSE IDS: an ad group’s targeting takes adminAreaIds / localityIds / postalCodeIds / countryIds, it does NOT accept place names, and nothing else in Hermoso produces one — so without this tool those targeting fields cannot be used at all. (A campaign’s own `countries` field is a different thing and takes plain ISO country codes.) supplySource is REQUIRED and is not a formality: APPSTORE has NO PostalCode entity (postal codes are a Maps-level geo), and MAPS has NO Country entity and covers only the US and Canada — both are refused for free with the reason. Pass query with at least 2 characters, or \"*\" to list everything at an entity level. Rows flagged soft-blocked (low search volume, sparse coverage) are still targetable — Apple returns them by default with their eligibility attached. To go the other way and look UP ids you already have, pass geos:[{id|legacyId, entity}] instead of query. Read-only, free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Search Apple Ads geo locations",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "supplySource": {
            "type": "string",
            "enum": [
              "APPSTORE",
              "MAPS"
            ],
            "description": "REQUIRED. APPSTORE for App Store campaigns, MAPS for Apple Maps. Decides which entity types exist."
          },
          "query": {
            "description": "Name to search, minimum 2 characters. \"*\" returns everything at the entity level.",
            "type": "string"
          },
          "entity": {
            "description": "Narrow to one granularity.",
            "type": "string",
            "enum": [
              "Country",
              "AdminArea",
              "Locality",
              "PostalCode"
            ]
          },
          "countryCode": {
            "description": "ISO 3166-1 alpha-2 to scope the search.",
            "type": "string"
          },
          "eligible": {
            "description": "false excludes soft-blocked geos entirely. Default includes them with eligibility data.",
            "type": "boolean"
          },
          "geos": {
            "description": "Look UP known geos instead of searching by name: [{\"id\":\"12345\",\"entity\":\"Locality\"}] or [{\"legacyId\":\"US|CA|SanFrancisco\",\"entity\":\"Locality\"}]. Exactly one of id or legacyId per entry.",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "required": [
          "supplySource"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_supported_languages",
      "title": "List Apple Ads supported languages",
      "description": "Every country and region Apple Ads serves, with the languages supported in each market. Use it to VALIDATE A LOCALE BEFORE setting it on an Apple Maps creative’s localizedText or defaultLocale, or on an ad group — a locale a market does not support makes the creative INVALID after it is built, which surfaces as a serving failure long after the mistake was made. Also the definitive answer to \"can we advertise in this market and in what language\" when expanding a campaign. Read-only, free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "List Apple Ads supported languages",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "filters": {
            "description": "Scope to specific markets, e.g. [{\"field\":\"countryCode\",\"operator\":\"IN\",\"value\":[\"US\",\"DE\"]}].",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "limit": {
            "type": "number"
          },
          "offset": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_apple_ads_ad_account",
      "title": "Get an Apple Ads ad account",
      "description": "The full record for an Apple Ads ad account — its name, currency, timezone, payment model, productFeatures and the advertiser resources delegated to it. Called with no id it returns the account this connection is pinned to. THE FIELD THAT DECIDES WHAT THIS ACCOUNT CAN DO IS productFeatures, and it is IMMUTABLE: APPSTORE_APP_MANUAL can never run Apple Maps ads and BUSINESS_BRAND_MANUAL can never run App Store ads, so an organization that needs both keeps a separate ad account for each. currency, timeZone and paymentModel are inherited from the organization and are immutable too — which is why a campaign’s amounts must be in this currency and cannot be mixed. Read-only, free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Get an Apple Ads ad account",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "description": "Defaults to the ad account this Apple Ads connection resolved.",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_apple_ads_advertiser_resources",
      "title": "List Apple Ads advertiser resources",
      "description": "The brands and content providers this Apple Ads ORGANIZATION can delegate to an ad account — the ids create_apple_ads_ad_account and update_apple_ads_ad_account need. resourceType CONTENT_PROVIDER returns App Store Connect content providers, each identified by its CPID, which is what links an ad account to App Store advertising; BUSINESS_BRAND returns Apple Maps brands by Brand ID. resourceType is REQUIRED — Apple errors without it. This list is ORGANIZATION-WIDE and is deliberately not scoped to any one ad account, which is why a resource can appear here that no account has claimed yet. Read-only, free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "List Apple Ads advertiser resources",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "resourceType": {
            "type": "string",
            "enum": [
              "CONTENT_PROVIDER",
              "BUSINESS_BRAND"
            ],
            "description": "REQUIRED. CONTENT_PROVIDER = App Store (CPID); BUSINESS_BRAND = Apple Maps (Brand ID)."
          }
        },
        "required": [
          "resourceType"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_apple_ads_ad_account",
      "title": "Create an Apple Ads ad account",
      "description": "Create a new ad account under this Apple Ads organization — the container an agency opens per client, or a business opens to run Apple Maps ads alongside App Store ones. AN AD ACCOUNT SPENDS NOTHING: it holds campaigns, and every campaign inside it is still born PAUSED. productFeatures is REQUIRED, is a CHOICE OF ONE, and is IMMUTABLE FOREVER — APPSTORE_APP_MANUAL for App Store advertising or BUSINESS_BRAND_MANUAL for Apple Maps, never both, and an organization needing both keeps two accounts. Delegations must MATCH that choice: an App Store account delegates a CONTENT_PROVIDER (a CPID from list_apple_ads_advertiser_resources), a Maps account delegates a BUSINESS_BRAND — a mismatch is refused for free here, because Apple accepts it and you get an account that cannot advertise anything. currency, timeZone and paymentModel are inherited from the organization and cannot be set. NOTE: Hermoso keeps writing to the ONE ad account this connection is pinned to — reconnect Apple Ads and choose the new account to build inside it. Free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Create an Apple Ads ad account",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "REQUIRED."
          },
          "productFeatures": {
            "type": "string",
            "enum": [
              "APPSTORE_APP_MANUAL",
              "BUSINESS_BRAND_MANUAL"
            ],
            "description": "REQUIRED and IMMUTABLE. App Store or Apple Maps, never both."
          },
          "delegations": {
            "description": "[{\"resourceType\":\"CONTENT_PROVIDER\",\"resourceId\":\"<CPID>\"}] for App Store, or BUSINESS_BRAND + Brand ID for Maps. Must match productFeatures.",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          }
        },
        "required": [
          "name",
          "productFeatures"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_apple_ads_ad_account",
      "title": "Update an Apple Ads ad account",
      "description": "Rename an Apple Ads ad account or change which advertiser resources it delegates — how you link App Store Connect (a CONTENT_PROVIDER / CPID) to an account that cannot yet advertise an app, or attach a brand for Apple Maps. WARNING: DELEGATIONS REPLACE RATHER THAN MERGE. The list you send becomes the account’s ENTIRE delegation set and anything omitted is removed, so to ADD one, read the current set with get_apple_ads_ad_account, append to it, and send the whole list back. productFeatures cannot be changed and Apple ignores it on update — an App Store account can never be converted into an Apple Maps one; create a second ad account instead. currency, timeZone and paymentModel are immutable, inherited from the organization. Free.",
      "group": "ads",
      "section": "SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS",
      "annotations": {
        "title": "Update an Apple Ads ad account",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "description": "Defaults to the ad account this connection resolved.",
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "delegations": {
            "description": "REPLACES the entire set. [{\"resourceType\":\"CONTENT_PROVIDER\",\"resourceId\":\"<CPID>\"}].",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_x_ads_campaign",
      "title": "Change an X campaign budget or settings",
      "description": "Change a LIVE X campaign — budget, name, delivery pacing. THIS IS HOW YOU THROTTLE OR RAISE SPEND on a running campaign without rebuilding it, and lowering dailyBudget is the fastest way to slow money down short of pausing. Budgets are in the ad account's own currency. Only fields X actually applies are offered: startTime/endTime are DEPRECATED on an X campaign (its schedule lives on the LINE ITEM — use update_x_ads_line_item) and frequency capping needs an X account feature we cannot enable, so both are refused BY NAME with the reason instead of being sent and silently ignored. THE READ-BACK IS A DIFF against the before-state: a field X did not move is reported as REFUSED, never counted as applied. Print the returned note verbatim.",
      "group": "ads",
      "section": "X: managing what you built — update, remove, and the reads both depend on (2026-08-05)",
      "annotations": {
        "title": "Change an X campaign budget or settings",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "from list_x_ads_campaigns"
          },
          "name": {
            "type": "string"
          },
          "dailyBudget": {
            "description": "in the ad account's currency",
            "type": "number"
          },
          "totalBudget": {
            "type": "number"
          },
          "standardDelivery": {
            "description": "false = accelerated: spend the budget as fast as the auction allows",
            "type": "boolean"
          },
          "purchaseOrderNumber": {
            "type": "string"
          }
        },
        "required": [
          "accountId",
          "campaignId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_x_ads_line_item",
      "title": "Change an X line item (ad group)",
      "description": "Change an X line item — BID, bid strategy, SCHEDULE, goal or name. An X campaign's start and end times are deprecated, so the schedule genuinely lives HERE. Lowering `bidAmount` is also the fix when X refuses a campaign budget with “Bid is too close to Budget”. NOT changeable after creation: `objective` and `productType` — X answers 200 and silently keeps the old value (measured), so both are refused BY NAME here and a different objective means a NEW line item. Changing `goal` also requires `bidAmount` (X refuses the goal alone) and that is stated up front rather than relayed. A line-item dailyBudget/totalBudget is only legal when the parent campaign is NOT budget-optimised — that is the campaign's setting, so those two are forwarded and X's own refusal names the remedy rather than Hermoso blocking a legal edit. THE READ-BACK IS A DIFF against an independent re-read: a field X accepted but did not move is reported as REFUSED. Print the returned note verbatim.",
      "group": "ads",
      "section": "X: managing what you built — update, remove, and the reads both depend on (2026-08-05)",
      "annotations": {
        "title": "Change an X line item (ad group)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "lineItemId": {
            "type": "string",
            "description": "from list_x_ads_line_items"
          },
          "name": {
            "type": "string"
          },
          "bidAmount": {
            "description": "in the ad account's currency",
            "type": "number"
          },
          "bidStrategy": {
            "type": "string",
            "enum": [
              "AUTO",
              "GUARANTEED",
              "MAX",
              "TARGET"
            ]
          },
          "goal": {
            "description": "requires bidAmount too",
            "type": "string",
            "enum": [
              "APP_CLICKS",
              "APP_INSTALLS",
              "APP_PURCHASES",
              "ENGAGEMENT",
              "FOLLOWERS",
              "LINK_CLICKS",
              "MAX_REACH",
              "PREROLL",
              "PREROLL_STARTS",
              "REACH_WITH_ENGAGEMENT",
              "SITE_VISITS",
              "SOCIAL_ENGAGEMENT",
              "VIDEO_VIEW",
              "VIEW_15S",
              "VIEW_3S_100PCT",
              "VIEW_6S",
              "WEBSITE_CONVERSIONS",
              "WEBSITE_CONVERSIONS_V2"
            ]
          },
          "startTime": {
            "description": "ISO 8601",
            "type": "string"
          },
          "endTime": {
            "type": "string"
          },
          "dailyBudget": {
            "description": "only if the campaign is not budget-optimised",
            "type": "number"
          },
          "totalBudget": {
            "description": "only if the campaign is not budget-optimised",
            "type": "number"
          }
        },
        "required": [
          "accountId",
          "lineItemId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_x_ads_promoted_tweets",
      "title": "List X promoted posts",
      "description": "List the promoted posts (the CREATIVES) attached to an X line item, or across the whole ad account. Two things only this can tell you: each post's APPROVAL STATUS, so an ad X rejected — which can never serve however the statuses are set — is visible rather than mysterious; and the promoted-tweet ID, which is the only way to remove one. NOTE a promoted post cannot be PAUSED on X (its PUT accepts only an approval appeal), so the reversible way to stop it is to pause its LINE ITEM. Read-only, free.",
      "group": "ads",
      "section": "X: managing what you built — update, remove, and the reads both depend on (2026-08-05)",
      "annotations": {
        "title": "List X promoted posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "lineItemId": {
            "description": "scope to one line item",
            "type": "string"
          },
          "limit": {
            "type": "number"
          }
        },
        "required": [
          "accountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_x_ads_targeting",
      "title": "List an X line item’s targeting",
      "description": "List every targeting criterion an X line item carries, WITH THE ID of each — the id needed to remove one with delete_x_ads_object. READ THE EMPTY CASE CORRECTLY: no targeting criteria on X means the line item is UNRESTRICTED and will reach the broadest possible audience once ACTIVE — it does NOT mean it cannot serve. Read-only, free.",
      "group": "ads",
      "section": "X: managing what you built — update, remove, and the reads both depend on (2026-08-05)",
      "annotations": {
        "title": "List an X line item’s targeting",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "lineItemId": {
            "type": "string"
          }
        },
        "required": [
          "accountId",
          "lineItemId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_x_ads_funding_instruments",
      "title": "List X ad account payment methods",
      "description": "List the funding instruments (payment methods) on an X ad account — type, currency, credit limit, credit remaining, and whether each can currently fund a campaign. THIS IS THE ANSWER TO “why is my X campaign not delivering?” whenever the cause is a cancelled card or an exhausted credit line, which is invisible from the campaign itself, and it shows what a campaign will spend against BEFORE anyone activates it. Hermoso cannot add a payment method — that is done at ads.x.com. Read-only, free.",
      "group": "ads",
      "section": "X: managing what you built — update, remove, and the reads both depend on (2026-08-05)",
      "annotations": {
        "title": "List X ad account payment methods",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          }
        },
        "required": [
          "accountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "x_ads_targeting_search",
      "title": "Find X targeting ids (interests, devices, languages…)",
      "description": "Resolve X targeting ids by name for every vocabulary BEYOND location — INTEREST, CONVERSATION, PLATFORM, DEVICE, LANGUAGE, APP_STORE_CATEGORY, NETWORK_OPERATOR, TV_MARKET, TV_SHOW, EVENT. X's targeting ids are opaque (an interest is a 19-digit number, a device is “96”) and X has NO name-based targeting parameter, so this is the only way to obtain one — without it add_x_ads_targeting accepts vocabularies nobody can supply a value for. Pass a result as criteria:[{targetingType, targetingValue}]. Locations have their own tool: x_ads_geo_search. TV_SHOW requires a `locale`, which comes from kind:\"TV_MARKET\". Read-only, free.",
      "group": "ads",
      "section": "X: managing what you built — update, remove, and the reads both depend on (2026-08-05)",
      "annotations": {
        "title": "Find X targeting ids (interests, devices, languages…)",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "INTEREST",
              "CONVERSATION",
              "PLATFORM",
              "DEVICE",
              "LANGUAGE",
              "APP_STORE_CATEGORY",
              "NETWORK_OPERATOR",
              "TV_MARKET",
              "TV_SHOW",
              "EVENT"
            ]
          },
          "query": {
            "description": "filter by name",
            "type": "string"
          },
          "osType": {
            "description": "APP_STORE_CATEGORY",
            "type": "string",
            "enum": [
              "ANDROID",
              "IOS"
            ]
          },
          "countryCode": {
            "description": "NETWORK_OPERATOR, e.g. US",
            "type": "string"
          },
          "locale": {
            "description": "TV_SHOW — required; get one from kind:\"TV_MARKET\"",
            "type": "string"
          },
          "eventTypes": {
            "description": "EVENT",
            "type": "string",
            "enum": [
              "CONFERENCE",
              "HOLIDAY",
              "MOVIE_RELEASE",
              "MUSIC_AND_ENTERTAINMENT",
              "OLYMPICS",
              "OTHER",
              "POLITICS",
              "RECURRING",
              "SPORTS"
            ]
          },
          "limit": {
            "type": "number"
          }
        },
        "required": [
          "kind"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_x_ads_object",
      "title": "Delete an X ads object (permanent, cascades)",
      "description": "PERMANENTLY delete an X campaign, line item, promoted post or targeting criterion. X CASCADES AND PUBLISHES NO UNDO: deleting a campaign destroys its line items and their promoted posts too — verified live, the children answer 404 the moment the parent is deleted. RUN IT WITHOUT confirm FIRST: that deletes nothing and reports the REAL blast radius read back off X (what is underneath it, whether it is live, whether it has spent). Show the user exactly that, get an unambiguous yes, then call again with confirm:true — and, for anything with children / live delivery / real spend, also confirmName set to its exact name and confirmChildren set to the real count, because confirm:true alone proves you meant to delete SOMETHING and cannot prove you aimed at the right object. TO STOP DELIVERY WITHOUT DESTROYING ANYTHING use set_x_ads_status PAUSED, which is reversible — except for a promoted post, which cannot be paused on X at all, so pause its LINE ITEM instead. Removing a targeting criterion WIDENS the audience rather than narrowing it. The result is confirmed by re-reading the object, never by X's 200.",
      "group": "ads",
      "section": "X: managing what you built — update, remove, and the reads both depend on (2026-08-05)",
      "annotations": {
        "title": "Delete an X ads object (permanent, cascades)",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "campaign",
              "line_item",
              "promoted_tweet",
              "targeting_criterion"
            ]
          },
          "id": {
            "type": "string",
            "description": "campaign/line-item id, or the id from list_x_ads_promoted_tweets / list_x_ads_targeting"
          },
          "lineItemId": {
            "description": "REQUIRED for type \"targeting_criterion\" — X cannot look one up without its line item",
            "type": "string"
          },
          "confirm": {
            "description": "omit on the first call to see the blast radius",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the target's exact name — required once it has children, is live, or has spent",
            "type": "string"
          },
          "confirmChildren": {
            "description": "the real number of children, from the unconfirmed call",
            "type": "number"
          }
        },
        "required": [
          "accountId",
          "type",
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_x_ads_line_items",
      "title": "List X ads line items",
      "description": "List the line items on an X ad account — X's name for an ad group, and the level that carries the objective, the placements, the bid, the targeting and the creatives. Pass campaignId to scope it to one campaign. `servable` is X's own verdict on whether the line item could run. Read-only, free.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "List X ads line items",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string",
            "description": "from list_x_ads_accounts"
          },
          "campaignId": {
            "type": "string"
          },
          "limit": {
            "type": "number"
          }
        },
        "required": [
          "accountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_x_ads_line_item",
      "title": "Build an X ads line item (paused)",
      "description": "Create a line item — X's ad group — under a campaign. ALWAYS CREATED PAUSED with no override. THE TREE ON X IS campaign → line item → promoted post, and a campaign ALONE CANNOT SERVE: this is the middle level, and it still cannot serve until you attach a post with create_x_ads_promoted_tweet. Targeting attaches HERE (add_x_ads_targeting), never to the campaign. `objective` is validated before dispatch because X answers an invalid one with a 500 that reads like an outage; note that WEBSITE_CONVERSIONS and SITE_VISITS are `goal` values and are NOT objectives. `bidStrategy` MAX/TARGET require a bidAmount; AUTO lets X set it. Omitting startTime records now, and the read-back says so. Everything is READ BACK from X before you are told it exists; print the returned note verbatim.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Build an X ads line item (paused)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string",
            "description": "from list_x_ads_accounts"
          },
          "campaignId": {
            "type": "string",
            "description": "from create_x_ads_campaign or list_x_ads_campaigns"
          },
          "name": {
            "type": "string"
          },
          "objective": {
            "type": "string",
            "enum": [
              "APP_ENGAGEMENTS",
              "APP_INSTALLS",
              "ENGAGEMENTS",
              "FOLLOWERS",
              "LEAD_GENERATION",
              "PREROLL_VIEWS",
              "REACH",
              "VIDEO_VIEWS",
              "WEBSITE_CLICKS"
            ]
          },
          "productType": {
            "description": "default PROMOTED_TWEETS",
            "type": "string",
            "enum": [
              "MEDIA",
              "PROMOTED_ACCOUNT",
              "PROMOTED_TWEETS"
            ]
          },
          "placements": {
            "description": "default ALL_ON_TWITTER",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "ALL_ON_TWITTER",
                "PUBLISHER_NETWORK",
                "TAP_BANNER",
                "TAP_FULL",
                "TAP_FULL_LANDSCAPE",
                "TAP_MRECT",
                "TAP_NATIVE",
                "TWITTER_MEDIA_VIEWER",
                "TWITTER_PROFILE",
                "TWITTER_REPLIES",
                "TWITTER_SEARCH",
                "TWITTER_TIMELINE"
              ]
            }
          },
          "goal": {
            "type": "string",
            "enum": [
              "APP_CLICKS",
              "APP_INSTALLS",
              "APP_PURCHASES",
              "ENGAGEMENT",
              "FOLLOWERS",
              "LINK_CLICKS",
              "MAX_REACH",
              "PREROLL",
              "PREROLL_STARTS",
              "REACH_WITH_ENGAGEMENT",
              "SITE_VISITS",
              "SOCIAL_ENGAGEMENT",
              "VIDEO_VIEW",
              "VIEW_15S",
              "VIEW_3S_100PCT",
              "VIEW_6S",
              "WEBSITE_CONVERSIONS",
              "WEBSITE_CONVERSIONS_V2"
            ]
          },
          "bidStrategy": {
            "type": "string",
            "enum": [
              "AUTO",
              "GUARANTEED",
              "MAX",
              "TARGET"
            ]
          },
          "bidAmount": {
            "description": "required for MAX/TARGET; in the ad account's currency",
            "type": "number"
          },
          "totalBudget": {
            "type": "number"
          },
          "startTime": {
            "description": "ISO 8601; defaults to now",
            "type": "string"
          },
          "endTime": {
            "type": "string"
          }
        },
        "required": [
          "accountId",
          "campaignId",
          "objective"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_x_ads_promoted_tweet",
      "title": "Attach a post to an X line item (paused)",
      "description": "Attach an existing X post to a line item so the line item has a creative — the LAST piece of the tree, and without it nothing can ever serve however the statuses are set. The tweet id is the number at the end of the post URL (x.com/<handle>/status/<id>), not the URL. Several ids may be attached at once; X creates one promoted-tweet row per post. ATTACHMENT IS VERIFIED by re-reading the line item's own promoted posts rather than by trusting X's echo, and if that read cannot run the note says UNCONFIRMED instead of claiming success. Everything Hermoso built above this is PAUSED, so attaching a post starts no spend. Print the returned note verbatim.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Attach a post to an X line item (paused)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "lineItemId": {
            "type": "string",
            "description": "from create_x_ads_line_item"
          },
          "tweetIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "post ids — digits only, from the end of the post URL"
          }
        },
        "required": [
          "accountId",
          "lineItemId",
          "tweetIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "add_x_ads_targeting",
      "title": "Target an X ads line item",
      "description": "Add targeting criteria to an X LINE ITEM. Targeting does NOT attach to a campaign on X — a campaign carries only budget and funding. Pass locationIds resolved with x_ads_geo_search, and/or criteria[] for any of X's other 37 targeting vocabularies when you already hold the ids. X takes ONE criterion per API call, so a set is several calls: each is reported individually, a partial failure NAMES what did not apply, and the line item's FULL targeting is read back afterwards so the answer is what the line item carries rather than what was sent. Adds no spend — the line item stays PAUSED.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Target an X ads line item",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "lineItemId": {
            "type": "string"
          },
          "locationIds": {
            "description": "opaque ids from x_ads_geo_search",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "criteria": {
            "description": "[{targetingType, targetingValue, operatorType?}] — operatorType defaults to EQ",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          }
        },
        "required": [
          "accountId",
          "lineItemId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "x_ads_geo_search",
      "title": "Find X ads location ids",
      "description": "Look up X targeting location ids by name — countries, regions, metros, cities and postal codes. X location ids are opaque hashes (Canada is 3376992a082d67c7), so this is the ONLY way to obtain one and there is no name-based targeting parameter to fall back on. Pass the ids to add_x_ads_targeting as locationIds. Read-only, free.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Find X ads location ids",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "a place name, e.g. \"Canada\" or \"Austin\""
          },
          "locationType": {
            "type": "string",
            "enum": [
              "COUNTRIES",
              "REGIONS",
              "METROS",
              "CITIES",
              "POSTAL_CODES"
            ]
          },
          "limit": {
            "type": "number"
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "x_ads_report",
      "title": "X ads performance report",
      "description": "Performance stats for X campaigns, line items or promoted posts. `placement` here is SINGULAR and comes from a FOUR-value set (ALL_ON_TWITTER / PUBLISHER_NETWORK / SPOTLIGHT / TREND) — deliberately not the twelve placements a line item accepts; do not carry one across. THE TRAP THIS REPORTS: an entity id that does not exist on the account answers 200 with every metric null, which is indistinguishable from a real zero, so an all-null response is FLAGGED rather than narrated as zero performance. Max 20 ids per call. Read-only, free.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "X ads performance report",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "entity": {
            "description": "default CAMPAIGN",
            "type": "string",
            "enum": [
              "ACCOUNT",
              "CAMPAIGN",
              "FUNDING_INSTRUMENT",
              "LINE_ITEM",
              "MEDIA_CREATIVE",
              "ORGANIC_TWEET",
              "PROMOTED_ACCOUNT",
              "PROMOTED_TWEET"
            ]
          },
          "entityIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "max 20"
          },
          "startTime": {
            "description": "ISO 8601; defaults to 7 days before endTime",
            "type": "string"
          },
          "endTime": {
            "description": "ISO 8601; defaults to now",
            "type": "string"
          },
          "granularity": {
            "description": "default TOTAL",
            "type": "string",
            "enum": [
              "HOUR",
              "DAY",
              "TOTAL"
            ]
          },
          "placement": {
            "description": "default ALL_ON_TWITTER",
            "type": "string",
            "enum": [
              "ALL_ON_TWITTER",
              "PUBLISHER_NETWORK",
              "SPOTLIGHT",
              "TREND"
            ]
          },
          "metricGroups": {
            "description": "default ENGAGEMENT",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "BILLING",
                "ENGAGEMENT",
                "LIFE_TIME_VALUE_MOBILE_CONVERSION",
                "MEDIA",
                "MOBILE_CONVERSION",
                "VIDEO",
                "WEB_CONVERSION"
              ]
            }
          }
        },
        "required": [
          "accountId",
          "entityIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_openai_ads_conversion_events",
      "title": "List ChatGPT Ads conversion events",
      "description": "List the conversion event settings on the connected ChatGPT Ads account. Their ids are what a campaign points at (conversionEventSettingIds) so it optimises for CONVERSIONS rather than raw clicks — without one, conversion-optimised bidding has nothing to optimise toward. Read-only, free.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "List ChatGPT Ads conversion events",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_openai_ads_pixel",
      "title": "Create a ChatGPT Ads pixel",
      "description": "Create a ChatGPT Ads web pixel — the thing that observes actions on the site. IT RECORDS NOTHING until its snippet is installed on the site, so say that rather than implying tracking is live. A pixel is a measurement definition and cannot spend. The next step is create_openai_ads_conversion_event, which says WHICH observed action counts as a conversion. AUTOMATIC ADVANCED MATCHING IS ON BY DEFAULT — that is OpenAI’s own default for pixels created through the Ads API since 2026-08-17, and it means the Pixel COLLECTS AND HASHES CUSTOMER INFORMATION (email, phone) from the page. Tell the user that in plain words, and pass automaticAdvancedMatching:false if they want it off. The reply reports what OpenAI actually stored: there is no endpoint to read a pixel back afterwards, so that one reply is the only record you will ever get.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Create a ChatGPT Ads pixel",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "a name for the pixel"
          },
          "automaticAdvancedMatching": {
            "description": "default true (OpenAI’s own default). false stops the Pixel collecting and hashing customer information from the page.",
            "type": "boolean"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_openai_ads_conversion_event",
      "title": "Define a ChatGPT Ads conversion",
      "description": "Define what counts as a conversion on ChatGPT Ads, measured from one or more pixels. THIS IS THE PREREQUISITE for a conversion-optimised campaign: pass the returned id as conversionEventSettingIds to create_openai_ads_campaign. Creating one cannot spend and cannot serve — it is a definition, so it is not confirm-gated.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Define a ChatGPT Ads conversion",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "eventType": {
            "type": "string",
            "description": "e.g. order_created, lead_created, registration_completed — the plausible words \"purchase\", \"lead\" and \"signup\" are all REFUSED by ChatGPT Ads"
          },
          "sourceIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "pixel id(s) this event is measured from — from create_openai_ads_pixel"
          },
          "customEventName": {
            "description": "for a non-standard event",
            "type": "string"
          },
          "attributionWindowDays": {
            "description": "1-90",
            "type": "number"
          }
        },
        "required": [
          "name",
          "eventType",
          "sourceIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_openai_ads_audiences",
      "title": "List ChatGPT Ads custom audiences",
      "description": "List the custom audiences on the connected ChatGPT Ads account. Read-only, free.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "List ChatGPT Ads custom audiences",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_openai_ads_audience",
      "title": "Create a ChatGPT Ads custom audience",
      "description": "Create a ChatGPT Ads custom audience from a customer list. Pass plain emails and/or phone numbers: Hermoso NORMALISES AND SHA-256 HASHES THEM LOCALLY and uploads only the digests, so no plaintext personal data leaves Hermoso. MEASURED 2026-08-09: OpenAI’s ads file endpoint only accepts IMAGE mimetypes (gif/jpeg/png/webp) and rejects a customer-list CSV under every upload purpose, so audiences are UI-only for now — this tool reports OpenAI’s verbatim refusal and points the user at ChatGPT Ads Manager, and will start working unchanged the day a data-file path opens. Values that are neither an email nor a phone number are skipped and counted, never silently dropped. An audience is a definition and cannot spend.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Create a ChatGPT Ads custom audience",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "members": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "emails and/or phone numbers (already-SHA256-hashed emails are passed through as-is)"
          },
          "description": {
            "type": "string"
          }
        },
        "required": [
          "name",
          "members"
        ],
        "$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). Geo (countries / locationIds) and PLATFORMS (which of the iOS app, Android app and web the ad runs on) are the only other dimensions — leave platforms out to run on all three. 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": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Build a ChatGPT Ads campaign (paused)",
        "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 — ChatGPT Ads’ own minimum for a DAILY budget is 25.00",
            "type": "number"
          },
          "lifetimeBudget": {
            "description": "lifetime cap in the account currency — no 25.00 floor applies here, so use this to spend less than that in total. 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. \"conversions\" is oCPC — you still pay per click, but ChatGPT Ads optimises toward a conversion event, and it REQUIRES conversionEventSettingIds naming exactly one active event setting.",
            "type": "string",
            "enum": [
              "impressions",
              "clicks",
              "conversions"
            ]
          },
          "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"
            }
          },
          "platforms": {
            "description": "WHICH CHATGPT SURFACES THIS CAMPAIGN RUNS ON — OpenAI’s “Eligible platforms”: any of ios_app (the ChatGPT iOS app), android_app (the Android app) and web (chatgpt.com in a browser). OMIT IT to run on all three, which is the default and almost always right; naming a subset STOPS the ad serving everywhere else. There is no empty state — ChatGPT Ads refuses an empty list — so widening back means naming all three.",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "ios_app",
                "android_app",
                "web"
              ]
            }
          },
          "customAudienceIds": {
            "description": "TARGET a CUSTOM AUDIENCE — ids from list_openai_ads_audiences (created with create_openai_ads_audience, then filled with members). Until 2026-08-12 an audience could be created AND uploaded and then pointed at nothing: this is the field that consumes them. Combines with geo — the ad reaches people in the named locations who are ALSO in these audiences.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "excludedCustomAudienceIds": {
            "description": "EXCLUDE custom audiences — same ids, opposite effect (suppressing existing customers, say). An id in BOTH lists is refused rather than resolved by a guess, because OpenAI does not document which side wins.",
            "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": {
                "description": "max bid in the account currency — REQUIRED unless bidStrategy is a maximize_* one, which sets the bid itself",
                "type": "number"
              },
              "bidStrategy": {
                "description": "HOW THIS AD GROUP BIDS — OpenAI’s “Maximize results”. fixed_bid (default) uses your maxBid as a hard cap; maximize_clicks and maximize_conversions let ChatGPT Ads set the bid to get the most of that outcome for the budget, and with either of those maxBid is OPTIONAL. maximize_conversions additionally needs the CAMPAIGN on biddingType \"conversions\" with a conversion event setting attached.",
                "type": "string",
                "enum": [
                  "fixed_bid",
                  "maximize_clicks",
                  "maximize_conversions"
                ]
              },
              "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"
            ]
          }
        },
        "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. Bidding: pass maxBid for a fixed bid, or bidStrategy \"maximize_clicks\" / \"maximize_conversions\" to let ChatGPT Ads set the bid for the budget (OpenAI’s \"Maximize results\"), in which case maxBid is optional. 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": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Add a ChatGPT Ads ad group",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "campaignId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "maxBid": {
            "description": "max bid in the account currency — REQUIRED unless bidStrategy is a maximize_* one, which sets the bid itself",
            "type": "number"
          },
          "bidStrategy": {
            "description": "HOW THIS AD GROUP BIDS — OpenAI’s “Maximize results”. fixed_bid (default) uses your maxBid as a hard cap; maximize_clicks and maximize_conversions let ChatGPT Ads set the bid to get the most of that outcome for the budget, and with either of those maxBid is OPTIONAL. maximize_conversions additionally needs the CAMPAIGN on biddingType \"conversions\" with a conversion event setting attached.",
            "type": "string",
            "enum": [
              "fixed_bid",
              "maximize_clicks",
              "maximize_conversions"
            ]
          },
          "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"
        ],
        "$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": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Create a ChatGPT Ads ad",
        "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": "openai_ads_bulk",
      "title": "ChatGPT Ads bulk create/update job",
      "description": "Create or update up to 1,000 ChatGPT Ads campaigns, ad groups and ads in ONE asynchronous job — the direct analogue of a Google Ads atomic mutate, and the only way to build a whole tree in a single call. Each operation is {operation_id, type, idempotency_key|target_resource_id, input}. Types: campaign.create, campaign.update, ad_group.create, ad_group.update, ad.create, ad.update. FORWARD REFERENCES are the point: set input.campaign_idempotency_key / input.ad_group_idempotency_key to another CREATE operation’s idempotency_key and the child attaches to the parent made in the same job (a key no operation mints is refused here, for free, rather than failing the whole job at OpenAI). EVERYTHING CREATED IS PAUSED and spends nothing — status:\"active\" on a create is overridden, and an UPDATE that would set \"active\" is REFUSED BY NAME, because one job could otherwise arm a thousand objects in a single call that no confirmation ever saw; turn things on one at a time with set_openai_ads_status. Use validateOnly:true for a FREE dry run (it checks fields and dependencies but NOT update-target existence, image fetching or entity limits, so a validated job can still fail for real). Returns a jobId — poll it with openai_ads_bulk_job; an operation’s result is only final once the job is completed, partially_failed or failed. LIMITS (OpenAI’s own): 1–1000 operations, 16 MiB body, 512 KiB per operation, 10 job creates per 10 seconds per ad account, campaign budget ≥ 1000000 micros, names 3–1000 chars, ad titles 3–50, bodies ≤100, URLs ≤2048, ≤2500 location ids, ≤2000 context hints. THE BULK API IS IN LIMITED PREVIEW AND ENABLED PER AD ACCOUNT: a 404 means this account has not been granted it (not a wrong path), and the refusal says so and names the per-object tools that do the same work.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "ChatGPT Ads bulk create/update job",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "operations": {
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            },
            "description": "1–1000 operations: {operation_id, type, idempotency_key (creates) or target_resource_id (updates), input:{…}}"
          },
          "validateOnly": {
            "description": "true = FREE dry run; nothing is created or changed",
            "type": "boolean"
          },
          "partialFailure": {
            "description": "default true (independent operations continue after an error). false skips later operations after a failure and does NOT roll back what already completed.",
            "type": "boolean"
          },
          "idempotencyKey": {
            "description": "request-level key that makes an uncertain retry safe. Reusing it with a DIFFERENT body is an error at OpenAI. To rerun a failed job, submit the same body with a NEW request-level key.",
            "type": "string"
          }
        },
        "required": [
          "operations"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "openai_ads_bulk_job",
      "title": "Read a ChatGPT Ads bulk job",
      "description": "Poll a ChatGPT Ads bulk job and read the result of EVERY operation in it. Reports the job status (pending, in_progress, completed, partially_failed, failed — the last three are terminal) plus a per-operation verdict: created, updated, validated, failed or skipped, each with the new resource id or the error. THE VERDICT COMES FROM THE OPERATIONS, NEVER FROM THE JOB STATUS — \"the job finished\" and \"your ad was created\" are different questions, and a partially_failed job answers yes to one and no to the other. While a job is still running the results are an INCOMPLETE SNAPSHOT and there is no cursor to page with; once complete is true, page with `after` set to the last operation_id. A failed operation that reports retryable names retry_after_seconds — reuse the ORIGINAL create idempotency_key when resubmitting or the retry creates a duplicate. Free, read-only.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Read a ChatGPT Ads bulk job",
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "jobId": {
            "type": "string",
            "description": "the id openai_ads_bulk returned"
          },
          "limit": {
            "description": "1–100 results per page, default 100",
            "type": "number"
          },
          "after": {
            "description": "the last operation_id from the previous page — only available once complete is true",
            "type": "string"
          }
        },
        "required": [
          "jobId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_openai_ads_feed_products",
      "title": "Update ChatGPT Ads product-feed variants",
      "description": "Update the PRICE, TITLE or AVAILABILITY of variants already in a ChatGPT Ads product feed — send only what changed instead of re-uploading the catalog. This is what stops a stale feed serving ads for out-of-stock items. Pass feedId and products:[{id, variants:[{id, title?, price?:{amount,currency}, availability?:{available|status}}]}], where products[].id is the PARENT product id and variants[].id the existing variant id, both from the catalog already in the feed. price.amount is an INTEGER IN MINOR UNITS — 8999 means $89.99 — so a decimal is a hundredfold error in the price you advertise, and is refused. IT UPDATES EXISTING VARIANTS ONLY: it never creates a feed, uploads a catalog, or adds a product that is not already there. ACCEPTED IS NOT LIVE — OpenAI applies the change asynchronously and returns no completion timestamp and no downstream result, so an out-of-stock product keeps serving until it propagates; say that rather than reporting it as done. A 403 naming product_feed_api_disabled / product_feed_delta_api_disabled means the ACCOUNT lacks Feeds access — nothing about the request was wrong and retrying it unchanged will not help. A 404 means the feed id is wrong, the feed is not linked to this ad account, or Feeds is not enabled.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Update ChatGPT Ads product-feed variants",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "feedId": {
            "type": "string",
            "description": "the product feed already linked to this ad account"
          },
          "products": {
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            },
            "description": "[{id, variants:[{id, title?, price?:{amount:int minor units, currency}, availability?:{available:bool}|{status:\"in_stock\"|\"out_of_stock\"}}]}]"
          }
        },
        "required": [
          "feedId",
          "products"
        ],
        "$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, TARGETING and the creative are REPLACED WHOLESALE rather than merged, so send the complete list. A patch that would silently DESTROY something is refused by name with what would have been lost, rather than going through: dropping a campaign’s geo, custom audiences or PLATFORM targeting, deleting an ad group’s max bid, or demoting a maximize_* bid strategy to a fixed bid. 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": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Edit a ChatGPT Ads campaign / ad group / ad",
        "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"
          },
          "platforms": {
            "description": "REPLACES which ChatGPT surfaces the campaign runs on (ios_app / android_app / web). Wholesale like the rest of targeting: a patch that changes geo or audiences on a campaign that already restricts platforms is REFUSED by name rather than silently widening it back to every surface. There is no [] — name all three to go back to everywhere.",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "ios_app",
                "android_app",
                "web"
              ]
            }
          },
          "customAudienceIds": {
            "description": "REPLACES the campaign’s targeted custom audiences. TARGETING IS REPLACED WHOLESALE, not merged — a patch that omits something the campaign already targets is REFUSED by name rather than silently dropping it, so restate it here or pass [] to clear it deliberately.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "excludedCustomAudienceIds": {
            "description": "REPLACES the campaign’s excluded custom audiences — same wholesale rule as customAudienceIds.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "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"
            ]
          },
          "bidStrategy": {
            "description": "CHANGE HOW THIS AD GROUP BIDS (OpenAI’s “Maximize results”). BIDDING IS REPLACED WHOLESALE, so a patch that moves the bid without restating the strategy would DEMOTE a maximize_* ad group to a fixed bid, and one that sets a strategy without restating maxBid DELETES the cap — both are refused by name with what would have been lost.",
            "type": "string",
            "enum": [
              "fixed_bid",
              "maximize_clicks",
              "maximize_conversions"
            ]
          },
          "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. ChatGPT Ads’ own minimum for a DAILY budget is 25.00 (measured live 2026-08-05; a LIFETIME budget has no such floor, so use one to spend less than that in total). 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": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Set a ChatGPT Ads campaign budget",
        "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": "openai_ads_conversions",
      "title": "ChatGPT Ads attributed conversions",
      "description": "ATTRIBUTED CONVERSIONS for ChatGPT Ads — the number the whole pixel + conversion-event setup exists to produce, and the one openai_ads_report structurally cannot give you (that endpoint family carries impressions, clicks, spend, CTR, CPC and CPM and no conversions at all). Pass entityIds — the campaign / ad group / ad ids to report on — with a matching level; the default window is the last 30 days. NEVER ADD conversions AND viewThroughConversions TOGETHER: OpenAI states that \"conversions is always equal to click_through_conversions\" and that view-through is \"a separate, supplemental metric\" NOT added to that total, and that view-through is reporting-only because CPA, post-click CVR, bidding, billing and conversion optimization all remain click-through-based. NO ROWS means no attributed conversion was recorded, not that data is missing — say exactly that, and check that an event setting exists (list_openai_ads_conversion_events) and that its pixel snippet is actually live on the site. Read-only, free.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "ChatGPT Ads attributed conversions",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "level": {
            "description": "inferred from which id you pass — default ad_account",
            "type": "string",
            "enum": [
              "ad_account",
              "campaign",
              "ad_group",
              "ad"
            ]
          },
          "entityIds": {
            "description": "the ids to report on — REQUIRED (ChatGPT Ads has no \"everything\" mode)",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "campaignId": {
            "type": "string"
          },
          "adGroupId": {
            "type": "string"
          },
          "adId": {
            "type": "string"
          },
          "since": {
            "description": "YYYY-MM-DD",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "preview_openai_ads_ad",
      "title": "Preview a ChatGPT Ads ad",
      "description": "Render a real preview of an existing ChatGPT Ads ad, so a human can LOOK at what will run instead of reading a list of ids back. Returns a hosted preview URL. THE LINK EXPIRES 24 HOURS AFTER IT IS CREATED — state that whenever you hand it to anyone, and generate a fresh one rather than re-sending an old one, because a dead link given to a client is worse than no link. Changes nothing about the ad; free.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Preview a ChatGPT Ads ad",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adId": {
            "type": "string",
            "description": "the ad to preview — list_openai_ads_campaigns with an adGroupId lists them"
          }
        },
        "required": [
          "adId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "send_openai_ads_conversions",
      "title": "Send server-side conversion events to ChatGPT Ads",
      "description": "Send conversion events to ChatGPT Ads from a SERVER (the Conversions API). This is the only way a conversion that did not happen in the browser — an offline sale, a webhook, a mobile backend, a CRM — is ever counted, and it is the half of the measurement loop that create_openai_ads_pixel and create_openai_ads_conversion_event exist to set up. TWO IDS LOOK ALIKE AND ONLY ONE WORKS: pass `pixelSnippetId`, which is OpenAI’s `pixel_id` and is what create_openai_ads_pixel returns under that name — NOT the `pixelId` (their `clidsrc_…` value), which is the conversion SOURCE id that an event setting takes as sourceIds. OpenAI’s own words: \"Use `id` as a `source_ids` value when you create an event setting. Use `pixel_id` … when you send Conversions API events.\" THE KEY IS ALSO NOT THE ONE YOU THINK: `apiKey` is the CONVERSIONS API key from create_openai_ads_conversion_api_key, not the Advertiser API key this workspace is connected with — OpenAI return it exactly once so Hermoso holds no copy and it must be passed in. Each event needs an `id` and a `type`, plus `source_url` for a web event; the data SHAPE is fixed by the event type and Hermoso fills it in, and MONEY IS AN INTEGER IN THE CURRENCY’S MINOR UNIT (4250 means $42.50 — sending 42.50 is refused, not rounded). Timestamps must be inside the last 7 days and no more than 10 minutes ahead. ONE BAD EVENT FAILS THE WHOLE BATCH of up to 1,000, so Hermoso validates locally first and names the offending event and field instead of letting OpenAI discard all of them. Use validateOnly:true for a free dry run that VALIDATES AND SAVES NOTHING — never tell a user a validate-only run was measured. If the browser pixel and the server both send the same conversion, give them the SAME id so OpenAI deduplicates it. Attribution is not instant: read openai_ads_conversions later rather than promising a number now. Free — costs no credits.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Send server-side conversion events to ChatGPT Ads",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "pixelSnippetId": {
            "type": "string",
            "description": "OpenAI’s pixel_id — create_openai_ads_pixel returns it as pixelSnippetId. NOT the clidsrc_… pixelId, which is the conversion source id."
          },
          "apiKey": {
            "type": "string",
            "description": "the Conversions API key (create_openai_ads_conversion_api_key) — NOT the Advertiser API key the connector stores"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            },
            "description": "up to 1000 events. Each: {id, type (order_created / lead_created / page_viewed / custom / …), timestamp_ms?, source_url? (required for web), action_source?, user? {email or externalId — Hermoso hashes them locally, plus country/city/zip_code/ip_address/user_agent/obref}, data? {amount as an INTEGER in minor units, currency, contents[]}}"
          },
          "validateOnly": {
            "description": "true validates the batch against ChatGPT Ads and SAVES NOTHING — no conversion is recorded",
            "type": "boolean"
          },
          "integrationSource": {
            "description": "stable identifier for the integration sending the batch; defaults to \"hermoso\"",
            "type": "string"
          }
        },
        "required": [
          "pixelSnippetId",
          "apiKey",
          "events"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_openai_ads_conversion_api_key",
      "title": "Create a ChatGPT Ads Conversions API key",
      "description": "Create a ChatGPT Ads Conversions API key — the credential a SERVER uses to send conversion events, which is the only way a conversion that happens off the page (an offline sale, a webhook, a mobile backend) can be counted at all. PERMANENT AND UNREPEATABLE, and both halves of that are why it is confirm-gated even though a key cannot spend: ChatGPT Ads publishes NO way to list, rotate or delete a key, so it exists on the ad account forever and Hermoso cannot clean it up; and OpenAI returns the secret EXACTLY ONCE. Tell the user both facts, get an explicit yes, then call with confirm:true — and tell them to store it in a server-side secret manager and never place it in browser code, client-visible environment variables, logs or source control. If OpenAI answers that key creation is not enabled for this ad account, that is an account-enablement answer from OpenAI (they say to contact your partner representative) — the connection is fine and nothing is broken on Hermoso’s side.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Create a ChatGPT Ads Conversions API key",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "a descriptive name, 3–1000 characters — it can never be listed or renamed afterwards"
          },
          "confirm": {
            "description": "REQUIRED true — the key is permanent and its secret is shown once",
            "type": "boolean"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_openai_ads_account",
      "title": "Update the ChatGPT Ads account brand (name / URL / favicon)",
      "description": "Update the ChatGPT Ads AD ACCOUNT’s brand metadata — its display name, its primary destination URL and/or its favicon — and start a new brand review. THIS IS THE GATE IN FRONT OF EVERY IMPRESSION: OpenAI states that an account whose review status is not \"approved\" CANNOT SERVE ADS, so this is how an account stuck in review gets fixed without leaving Hermoso. Pass any of name, url, faviconUrl (a public image URL — or just the brand’s home page, which OpenAI will resolve a favicon from; minimum 128×128) or faviconFileId. A brand change RESTARTS the review, so re-read the account (list_openai_ads_campaigns returns it) until it says approved before promising that anything will run. If OpenAI answers that programmatic brand updates are not enabled for this ad account, that is their account setting and not a Hermoso failure — they say to contact your partner representative.",
      "group": "ads",
      "section": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Update the ChatGPT Ads account brand (name / URL / favicon)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "description": "new account display name",
            "type": "string"
          },
          "url": {
            "description": "the account’s primary destination — a public http(s) URL. OpenAI accepts this field even though their published parameter table omits it.",
            "type": "string"
          },
          "faviconUrl": {
            "description": "public image URL (or the brand home page) to upload and assign as the account favicon — minimum 128×128",
            "type": "string"
          },
          "faviconFileId": {
            "description": "an already-uploaded favicon file id, if you have one",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_openai_ads_status",
      "title": "Activate, pause or archive a ChatGPT Ads account / campaign / ad group / ad",
      "description": "Turn a ChatGPT Ads AD ACCOUNT, campaign, AD GROUP or AD on (active), off (paused), or ARCHIVE it. Pass level:\"account\" (NO id — the connection IS the account), level:\"campaign\" + campaignId, level:\"adGroup\" + adGroupId, or level:\"ad\" + adId. LEVEL \"account\" IS THE EMERGENCY STOP AND BEHAVES DIFFERENTLY FROM EVERY OTHER LEVEL: BOTH directions take confirm:true, because pausing it stops ALL ChatGPT Ads delivery for the brand at once and activating it un-suppresses EVERY active campaign underneath — Hermoso cannot tell you in advance which of them will start spending. An account cannot be archived. 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": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Activate, pause or archive a ChatGPT Ads account / campaign / ad group / ad",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "level": {
            "description": "inferred from which id you pass — default campaign. \"account\" is the whole ad account and takes no id.",
            "type": "string",
            "enum": [
              "account",
              "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 (not available at level \"account\")"
          },
          "confirm": {
            "description": "REQUIRED true to activate (real spend), to archive (irreversible), or for EITHER direction at level \"account\"",
            "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": "X: the rest of the tree (2026-08-05)",
      "annotations": {
        "title": "Archive (ChatGPT Ads’ delete) a campaign / ad group / ad",
        "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). Add campaignId to get that ONE campaign’s whole tree — its AD GROUPS and ADS with their ids, statuses and review status. That is the only way to enumerate them, and it matters: Pinterest has no delete, so an ad group you cannot see is one you cannot even archive. 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": {
        "title": "List Pinterest ad accounts / campaigns",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "description": "Pinterest ad account id — omit to list the ad accounts shared with this brand",
            "type": "string"
          },
          "campaignId": {
            "description": "one campaign → its ad groups and ads too (the only way to enumerate them)",
            "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 — at ANY of Pinterest’s four levels: the whole ACCOUNT, by CAMPAIGN, by AD GROUP, or by individual AD. Ad level is how you answer \"WHICH AD IS WINNING\". EVERY SUB-ACCOUNT LEVEL NEEDS ITS IDS — this was measured, not read: Pinterest refuses campaign level without campaignIds, ad-group level without adGroupIds, AND ad level without adIds (\"Either ads id filter or both pin id and campaign id filters must be specified\"), so there is NO account-wide per-ad call. Get the ids from list_pinterest_ads_campaigns (name a campaignId and it returns that campaign’s ad groups and ads), or use level:\"account\" for a whole-account total with no ids at all. At AD level Pinterest publishes one alternative its own refusal names: pinIds AND campaignIds TOGETHER, which reports every ad promoting those Pins — half of that pair is refused here naming the missing half. The level is inferred from whichever ids you pass, so naming campaignIds still reports by campaign. Default columns lead with that level’s OWN id and name, because a report whose rows cannot be told apart answers nothing. 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": {
        "title": "Pinterest ads performance report",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "level": {
            "description": "which level to report at — omit and it is inferred from the ids you pass (none → the whole account)",
            "type": "string",
            "enum": [
              "account",
              "campaign",
              "adGroup",
              "ad"
            ]
          },
          "campaignIds": {
            "description": "REQUIRED for campaign level; also the second half of the ad-level pinIds pair",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "adGroupIds": {
            "description": "REQUIRED for ad-group level — Pinterest has no all-of-them form there",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "adIds": {
            "description": "REQUIRED for ad level — Pinterest refuses /ads/analytics without it, unless you pass pinIds AND campaignIds instead",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "pinIds": {
            "description": "ad level only, and only TOGETHER with campaignIds — every ad promoting these Pins",
            "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 for that level",
            "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": {
        "title": "Build a Pinterest ad campaign (paused)",
        "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": "LEAVE THIS OUT unless you know better — Pinterest ties it to the campaign objective and refuses a mismatch: CONSIDERATION takes CLICKTHROUGH; AWARENESS, SALES, LEADS, WEB_CONVERSION, VIDEO_COMPLETION and APP_INSTALL take IMPRESSION; CATALOG_SALES takes either. Omitted → the right one for the objective is used.",
                "type": "string",
                "enum": [
                  "CLICKTHROUGH",
                  "IMPRESSION",
                  "VIDEO_V_50_MRC"
                ]
              },
              "bid": {
                "description": "REQUIRED — what you pay per billable event, in the ad account’s currency. Pinterest rejects an ad group without one and Hermoso will not invent a bid. It must also be BELOW the campaign budget and above Pinterest’s own bid floor for the placement, both of which Pinterest states in its refusal.",
                "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\"],\"AGE_BUCKET\":[\"25-34\",\"35-44\"]} — at least one GEO or LOCATION is REQUIRED by Pinterest. Age: use AGE_BUCKET, or MINIMUM_AGE and MAXIMUM_AGE TOGETHER (18–65, with \"65+\" allowed as the maximum) — a minimum on its own is refused.",
                "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": {
        "title": "Add a Pinterest ad group",
        "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": "LEAVE THIS OUT unless you know better — Pinterest ties it to the campaign objective and refuses a mismatch: CONSIDERATION takes CLICKTHROUGH; AWARENESS, SALES, LEADS, WEB_CONVERSION, VIDEO_COMPLETION and APP_INSTALL take IMPRESSION; CATALOG_SALES takes either. Omitted → the right one for the objective is used.",
            "type": "string",
            "enum": [
              "CLICKTHROUGH",
              "IMPRESSION",
              "VIDEO_V_50_MRC"
            ]
          },
          "bid": {
            "description": "REQUIRED — what you pay per billable event, in the ad account’s currency. Pinterest rejects an ad group without one and Hermoso will not invent a bid. It must also be BELOW the campaign budget and above Pinterest’s own bid floor for the placement, both of which Pinterest states in its refusal.",
            "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\"],\"AGE_BUCKET\":[\"25-34\",\"35-44\"]} — at least one GEO or LOCATION is REQUIRED by Pinterest. Age: use AGE_BUCKET, or MINIMUM_AGE and MAXIMUM_AGE TOGETHER (18–65, with \"65+\" allowed as the maximum) — a minimum on its own is refused.",
            "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": {
        "title": "Create a Pinterest ad",
        "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": "update_pinterest_ads_ad_group",
      "title": "Edit a Pinterest ad group",
      "description": "EDIT an existing Pinterest ad group in place — its name, bid, budget, pacing, placement and, above all, its TARGETING. Before this, targeting was frozen the moment an ad group was created: a mistyped bid or a missing country meant building the whole tree again, and because PINTEREST HAS NO DELETE every correction left a permanent archived shell behind. This is a PARTIAL edit — a field you do not name is left exactly as Pinterest has it, so send only what changes, and never re-send everything you just read (that would overwrite a concurrent edit). targetingSpec is the exception: it REPLACES the whole targeting object, so include every criterion you still want. IT DOES NOT CHANGE STATUS — set_pinterest_ads_status owns ACTIVE / PAUSED / ARCHIVED, and passing status here is refused by name. Changing the bid, the budget, or which campaign it spends from WHILE IT IS LIVE moves real money on the next auction: show the user the exact new value, get an explicit yes, then pass confirm:true. Pinterest refuses targetingTemplateIds alongside targetingSpec / trackingUrls / autoTargeting / placementGroup, which is refused here naming the conflicting pair. The ad group is READ BACK from Pinterest afterwards and the note describes what Pinterest actually stored — print it verbatim. Free.",
      "group": "ads",
      "section": "PINTEREST ADS (2026-07-30) — rides the SAME Pinterest connection as the Pin tools; no reconnect needed.",
      "annotations": {
        "title": "Edit a Pinterest ad group",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "adGroupId": {
            "type": "string",
            "description": "the ad group to edit"
          },
          "status": {
            "description": "REFUSED HERE ON PURPOSE — use set_pinterest_ads_status, which confirm-gates real spend and Pinterest’s archive-as-delete",
            "type": "string",
            "enum": [
              "ACTIVE",
              "PAUSED",
              "ARCHIVED",
              "DRAFT"
            ]
          },
          "name": {
            "type": "string"
          },
          "bid": {
            "description": "what you pay per billable event, in the ad account’s currency",
            "type": "number"
          },
          "budget": {
            "description": "only on a campaign that is NOT budget-optimized",
            "type": "number"
          },
          "budgetType": {
            "type": "string",
            "enum": [
              "DAILY",
              "LIFETIME",
              "CBO_ADGROUP"
            ]
          },
          "billableEvent": {
            "description": "Pinterest ties this to the campaign objective and refuses a mismatch",
            "type": "string",
            "enum": [
              "CLICKTHROUGH",
              "IMPRESSION",
              "VIDEO_V_50_MRC"
            ]
          },
          "placementGroup": {
            "type": "string",
            "enum": [
              "ALL",
              "SEARCH",
              "BROWSE",
              "OTHER"
            ]
          },
          "pacing": {
            "type": "string",
            "enum": [
              "STANDARD",
              "ACCELERATED"
            ]
          },
          "autoTargeting": {
            "type": "boolean"
          },
          "targetingSpec": {
            "description": "REPLACES the whole targeting spec — get ids from search_pinterest_ads_targeting, never guess one",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "trackingUrls": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "targetingTemplateIds": {
            "description": "Pinterest refuses these alongside targetingSpec / trackingUrls / autoTargeting / placementGroup",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "optimizationGoalMetadata": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "startTime": {
            "description": "Unix timestamp in SECONDS",
            "type": "number"
          },
          "endTime": {
            "description": "Unix timestamp in SECONDS",
            "type": "number"
          },
          "lifetimeFrequencyCap": {
            "description": "CPM (IMPRESSION-billed) ad groups only, and Pinterest requires endTime with it",
            "type": "number"
          },
          "campaignId": {
            "description": "MOVE the ad group into a different campaign — it then spends from that budget",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true to change bid / budget / campaign on a LIVE ad group",
            "type": "boolean"
          }
        },
        "required": [
          "adGroupId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_pinterest_ads_ad",
      "title": "Edit a Pinterest ad",
      "description": "EDIT an existing Pinterest ad in place — its name, DESTINATION URL, creative type, tracking URLs, deep links, lead form, or which ad group it sits in. A PARTIAL edit: a field you do not name is left exactly as Pinterest has it. THE PIN CANNOT BE SWAPPED ON A REAL AD — Pinterest documents pinId as updatable \"only for draft ads\", so to promote a different Pin create a new ad in the same ad group with create_pinterest_ads_ad and archive this one; that is refused up front with the way through rather than after Pinterest rejects it. IT DOES NOT CHANGE STATUS — set_pinterest_ads_status owns ACTIVE / PAUSED / ARCHIVED, and passing status here is refused by name. EDITING AN AD SENDS IT BACK THROUGH PINTEREST’S REVIEW: the read-back reports the review status and says so when it moved, and an ad in review is NOT serving — never report an edited ad as live on the strength of the edit succeeding. Free.",
      "group": "ads",
      "section": "PINTEREST ADS (2026-07-30) — rides the SAME Pinterest connection as the Pin tools; no reconnect needed.",
      "annotations": {
        "title": "Edit a Pinterest ad",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "adId": {
            "type": "string",
            "description": "the ad to edit"
          },
          "status": {
            "description": "REFUSED HERE ON PURPOSE — use set_pinterest_ads_status, which confirm-gates real spend and Pinterest’s archive-as-delete",
            "type": "string",
            "enum": [
              "ACTIVE",
              "PAUSED",
              "ARCHIVED",
              "DRAFT"
            ]
          },
          "name": {
            "type": "string"
          },
          "destinationUrl": {
            "description": "where the click goes",
            "type": "string"
          },
          "creativeType": {
            "type": "string",
            "enum": [
              "REGULAR",
              "VIDEO",
              "SHOPPING",
              "CAROUSEL",
              "MAX_VIDEO",
              "COLLECTION",
              "IDEA",
              "SHOWCASE",
              "QUIZ",
              "COLLAGE",
              "APP"
            ]
          },
          "adGroupId": {
            "description": "move the ad into a different ad group",
            "type": "string"
          },
          "trackingUrls": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "clickTrackingUrl": {
            "type": "string"
          },
          "viewTrackingUrl": {
            "type": "string"
          },
          "leadFormId": {
            "type": "string"
          },
          "iosDeepLink": {
            "type": "string"
          },
          "androidDeepLink": {
            "type": "string"
          },
          "carouselDestinationUrls": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "pinId": {
            "description": "DRAFT ads only — Pinterest refuses it on any other status",
            "type": "string"
          }
        },
        "required": [
          "adId"
        ],
        "$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": {
        "title": "Set a Pinterest campaign budget",
        "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": {
        "title": "Activate, pause or archive a Pinterest campaign / ad group / ad",
        "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": {
        "title": "Archive (Pinterest’s delete) a campaign / ad group / ad",
        "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": {
        "title": "List Reddit ad accounts / campaigns",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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. ONE DATED LIMIT WORTH PLANNING AROUND: from 2026-10-30 Reddit caps the HOUR breakdown at windows of 7 DAYS OR FEWER and answers 400 beyond that. Hermoso warns before that date and refuses after it rather than relaying a bare vendor 400 — for a longer stretch, run one report per <=7-day window, or use the DATE breakdown for a daily series over the whole period. Read-only, free.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "title": "Reddit ads performance report",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List the Reddit profiles an ad account can publish as",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Resolve Reddit communities / geolocations / interests for targeting",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Forecast Reddit audience size and delivery",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Ask Reddit what to bid",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List a Reddit profile’s ad posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Create the Reddit post an ad will promote",
        "readOnlyHint": false,
        "destructiveHint": 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": "Turn comments on or off on a Reddit ad post",
      "description": "Turn comments ON or OFF on an existing Reddit ad post. THAT IS THE ONLY EDIT REDDIT ALLOWS: its post-update schema permits exactly one field, `allow_comments`, and REQUIRES it — headline and body both answer “Additional fields not permitted” once a post is published (measured live 2026-08-05). So a copy change is not an edit at all: create a new post with create_reddit_ads_post and point the ad at it with update_reddit_ads_ad, or fix the wording in Reddit’s Ads Manager. Never promise to reword a live post. 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. Turning comments off is publicly visible on a post people may already be replying to, so confirm it with the user first.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "title": "Turn comments on or off on a Reddit ad post",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "postId": {
            "type": "string",
            "description": "the post id (t3_…)"
          },
          "allowComments": {
            "type": "boolean",
            "description": "REQUIRED — Reddit demands allow_comments on every post update, and it is the only field it permits"
          }
        },
        "required": [
          "postId",
          "allowComments"
        ],
        "$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": {
        "title": "Create a Reddit campaign",
        "readOnlyHint": false,
        "destructiveHint": 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": {
        "title": "Create a Reddit ad group (targeting, budget, bidding, schedule)",
        "readOnlyHint": false,
        "destructiveHint": 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 MERGES KEY BY KEY — measured live 2026-08-05, and it is NOT a wholesale replace: a key you send replaces that whole list, a key you LEAVE OUT is kept exactly as it was, and an explicit empty array (geolocations: []) is the only way to clear one. So passing just {communities:[…]} does NOT drop an existing geo or interest filter on an ad group that holds the budget — name every key you want gone. 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": {
        "title": "Edit a Reddit ad group",
        "readOnlyHint": false,
        "destructiveHint": 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": {
        "title": "Create a Reddit ad",
        "readOnlyHint": false,
        "destructiveHint": 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": {
        "title": "Edit a Reddit ad",
        "readOnlyHint": false,
        "destructiveHint": 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": {
        "title": "Edit a Reddit campaign",
        "readOnlyHint": false,
        "destructiveHint": 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": {
        "title": "Activate, pause, archive or delete a Reddit campaign / ad group / ad",
        "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": {
        "title": "Delete or archive a Reddit campaign / ad group / ad",
        "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": {
        "title": "List Reddit conversion pixels (and whether they are firing)",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Send conversions to Reddit (Conversions API)",
        "readOnlyHint": false,
        "destructiveHint": 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": {
        "title": "List Reddit custom audiences",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Create a Reddit custom audience (customer list)",
        "readOnlyHint": false,
        "destructiveHint": 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": {
        "title": "Add or remove people in a Reddit custom audience",
        "readOnlyHint": false,
        "destructiveHint": 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": {
        "title": "Delete a Reddit custom audience",
        "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": {
        "title": "List Reddit saved audiences",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Create a reusable Reddit saved audience",
        "readOnlyHint": false,
        "destructiveHint": 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 MERGES KEY BY KEY — measured live 2026-08-05, not a wholesale replace: a key you send replaces that whole list, a key you LEAVE OUT is kept as it was, and an explicit empty array (geolocations: []) is the only way to clear one — so name every key you want gone. 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": {
        "title": "Edit a Reddit saved audience",
        "readOnlyHint": false,
        "destructiveHint": 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": {
        "title": "Delete a Reddit saved audience",
        "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": "Listing and reading forms KEEPS WORKING past the 2026-09-21 sunset — Reddit deliberately leaves GET/LIST up so advertisers can retain their records — so use this to EXPORT what exists before the deadline. Reddit exposes no API for the LEADS a form collected; those are downloaded from Ads Manager, and existing onsite-form ads are paused on 2026-09-30. 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": {
        "title": "List Reddit lead generation forms",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "REDDIT IS SUNSETTING THIS ON 2026-09-21 AND IT CANNOT BE REPLACED LIKE FOR LIKE. Reddit is removing onsite lead forms entirely — the API sunsets 2026-09-21 (their migration guide says 2026-09-30 for the same change; Hermoso stops creating on the earlier date so a form is never built that turns out not to work) and Reddit PAUSES every ad still using an onsite form on 2026-09-30. After that this tool refuses and says so. DO NOT BUILD A NEW FUNNEL ON IT: Reddit’s replacement is an OFFLINE form on the advertiser’s own landing page, measured with the Reddit Pixel or the Conversions API — list_reddit_ads_pixels and send_reddit_ads_conversions, both of which work here today. 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": {
        "title": "Create a Reddit lead generation form",
        "readOnlyHint": false,
        "destructiveHint": 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": {
        "title": "Reddit ad account changelog",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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_tiktok_ads_accounts",
      "title": "List TikTok advertiser accounts",
      "description": "List the TikTok ADVERTISER accounts this brand can act on — id, name, currency, timezone and status. Every other TikTok Ads tool needs an advertiserId, and this is where it comes from: call this first and let the USER pick when there is more than one. Read-only, free, spends nothing. Needs TikTok ADS connected (Settings ▸ Connectors ▸ TikTok Ads) — that is a DIFFERENT connection from the TikTok posting connector behind post_to_tiktok, so a brand that publishes to TikTok every day may still have nothing here.",
      "group": "ads",
      "section": "TIKTOK ADS (Marketing API v1.3) — the ninth ad platform (2026-08-07)",
      "annotations": {
        "title": "List TikTok advertiser accounts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_campaigns",
      "title": "List TikTok campaigns, ad groups and ads",
      "description": "Read a TikTok advertiser account’s whole tree in one call — campaigns, ad groups and ads, each with the operation status that says whether it is enabled at all. Omit advertiserId when the brand reaches exactly one account; with several, the call names the choices rather than picking for you. THE THREE TIERS ARE READ SEPARATELY AND A TIER THAT FAILED IS REPORTED AS FAILED, never as empty — if the result carries a `partial` note, say which tier could not be read instead of telling the user they have no ads. Read-only, free. TikTok’s QPS is 1, so a big account reads back slowly: that is the throttle working, not a fault.",
      "group": "ads",
      "section": "TIKTOK ADS (Marketing API v1.3) — the ninth ad platform (2026-08-07)",
      "annotations": {
        "title": "List TikTok campaigns, ad groups and ads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts — omit only when exactly one is reachable",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_identities",
      "title": "List the TikTok identities an ad can post as",
      "description": "List the IDENTITIES on a TikTok advertiser account — the TikTok accounts an ad is allowed to appear as. THIS IS A HARD PREREQUISITE, not a convenience: a TikTok ad carries no identity of its own and there is NO DEFAULT, so create_tiktok_ads_ad refuses without an id from here. Call it, show the user the list, and let them CHOOSE — an ad runs publicly under whichever account is named, so picking one for them is a public mistake on somebody else’s profile. If it comes back empty, no TikTok account has been authorised on this advertiser yet and nothing can be advertised from it; say that rather than guessing an id. Read-only, free.",
      "group": "ads",
      "section": "TIKTOK ADS (Marketing API v1.3) — the ninth ad platform (2026-08-07)",
      "annotations": {
        "title": "List the TikTok identities an ad can post as",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "eventSourceType": {
            "description": "PIXEL (default) or APP — TikTok publishes no other value",
            "type": "string",
            "enum": [
              "PIXEL",
              "APP"
            ]
          },
          "eventSourceId": {
            "description": "the pixel id (list_tiktok_ads_pixels) or app id. Omit and a single pixel is resolved for you; with several you are asked which.",
            "type": "string"
          },
          "searchKeyword": {
            "description": "filter by Custom Conversion name (fuzzy) or id",
            "type": "string"
          },
          "pageSize": {
            "description": "1-1000, default 10",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_identity_posts",
      "title": "List the organic TikTok posts under an identity",
      "description": "List the real ORGANIC TikTok posts published by one identity — the account itself, not the ad library. THIS IS WHERE A SPARK AD STARTS: pass an itemId from here to create_tiktok_ads_ad as tiktokItemId and the ad IS that post, with its own comments, likes and shares, running under the account that made it. Use this for an account the brand controls or has been granted (identityType TT_USER or BC_AUTH_TT); for a CREATOR’s post they authorised with a code, use list_tiktok_ads_spark_posts instead. identityId and identityType both come from list_tiktok_ads_identities, and a BC_AUTH_TT identity ALSO needs the identityAuthorizedBcId reported next to it — TikTok refuses the call without it. TikTok defaults to VIDEO posts only, so pass itemType:\"CAROUSEL\" to see photo posts; an account that posts only photos otherwise looks empty. Paginated by CURSOR, not page: pass back the cursor while hasMore is true. posterUrl and previewUrl EXPIRE AFTER AN HOUR — show them, never store them. Read-only, free.",
      "group": "ads",
      "section": "SPARK ADS — the TikTok twin of Meta's object_story_id / Pinterest's pin_id",
      "annotations": {
        "title": "List the organic TikTok posts under an identity",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "identityId": {
            "type": "string",
            "description": "REQUIRED — from list_tiktok_ads_identities"
          },
          "identityType": {
            "type": "string",
            "description": "REQUIRED — the matching type from list_tiktok_ads_identities. Only AUTH_CODE, TT_USER and BC_AUTH_TT can front a Spark Ad; CUSTOMIZED_USER cannot and is refused here."
          },
          "identityAuthorizedBcId": {
            "description": "REQUIRED when identityType is BC_AUTH_TT — reported next to the id by list_tiktok_ads_identities",
            "type": "string"
          },
          "itemType": {
            "description": "TikTok defaults to VIDEO only — pass CAROUSEL for photo posts",
            "type": "string",
            "enum": [
              "VIDEO",
              "CAROUSEL"
            ]
          },
          "excludeAdsOnly": {
            "description": "true hides ads-only posts (ones published as ads and hidden from the profile), returning at most the 500 most recent organic posts",
            "type": "boolean"
          },
          "cursor": {
            "description": "from the previous call, while hasMore is true",
            "type": "string"
          },
          "count": {
            "description": "1-20; TikTok ignores anything larger and returns 20",
            "type": "number"
          }
        },
        "required": [
          "identityId",
          "identityType"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_spark_posts",
      "title": "List TikTok posts authorised to this ad account",
      "description": "List the TikTok posts a post OWNER has authorised this ad account to promote — the creator-collaboration half of Spark Ads. These are posts from accounts the brand does NOT control: the owner generates an authorization code in the TikTok app and authorize_tiktok_ads_spark_post applies it, after which the post appears here with the itemId create_tiktok_ads_ad needs as tiktokItemId. AN EMPTY LIST IS A NORMAL STARTING STATE, not a failure — it means nobody has authorised a post yet, and the fix is to ask a creator for a code, not to retry. Each row carries the authorization WINDOW (authStartTime / authEndTime): a Spark Ad cannot outlive its authorization, so check the end time before building a campaign around one. Read-only, free.",
      "group": "ads",
      "section": "SPARK ADS — the TikTok twin of Meta's object_story_id / Pinterest's pin_id",
      "annotations": {
        "title": "List TikTok posts authorised to this ad account",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "itemTypes": {
            "description": "defaults to BOTH here (unlike list_tiktok_ads_identity_posts, which defaults to VIDEO alone)",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "VIDEO",
                "CAROUSEL"
              ]
            }
          },
          "keyword": {
            "description": "free text (fuzzy, ≤500 chars) or an exact TikTok post id (≥19 digits)",
            "type": "string"
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "1-50, default 20",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "authorize_tiktok_ads_spark_post",
      "title": "Apply a TikTok Spark Ads authorization code",
      "description": "Apply an authorization code so this ad account may promote somebody else’s TikTok post. THE CODE CANNOT BE MINTED FROM THE API — only the post’s owner can generate one, in the TikTok app: open the post → ⋯ → Ad settings → toggle Ad authorization → choose a duration → copy the code. Ask them for it; there is no way around this and no endpoint that creates one. Applying a code SPENDS NOTHING and creates nothing that can serve — it only grants permission — so it is deliberately not confirm-gated. If the post is a DUET or STITCH of another post, or mentions one, TikTok requires the ORIGINAL post owner’s code too: pass it as originalPostAuthCode or the call is refused. THE ANSWER IS THE READ-BACK: the reply lists the posts now authorised, because TikTok’s own response body is empty and a bare 200 proves nothing.",
      "group": "ads",
      "section": "SPARK ADS — the TikTok twin of Meta's object_story_id / Pinterest's pin_id",
      "annotations": {
        "title": "Apply a TikTok Spark Ads authorization code",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "authCode": {
            "type": "string",
            "description": "the code the post owner generated in the TikTok app. Paste it verbatim — Hermoso handles the + / %2B escaping TikTok warns about."
          },
          "originalPostAuthCode": {
            "description": "REQUIRED when the post is a duet/stitch of, or mentions, another post — the ORIGINAL post owner’s code",
            "type": "string"
          }
        },
        "required": [
          "authCode"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "unbind_tiktok_ads_spark_post",
      "title": "Remove a post from the authorised Spark Ads list",
      "description": "Remove a TikTok post from this ad account’s authorised Spark Ads list — the cleanup for an authorization that has expired or been revoked. IT IS NOT REVERSIBLE FROM OUR SIDE: re-authorising needs a FRESH code from the post’s owner, who is usually not the person making this call, so getting it wrong costs a conversation with a creator rather than a second API call — which is why it is confirm-gated even though it spends nothing. Ads already built on the post keep running; what is lost is the ability to build a NEW one. Calling it without confirm CHANGES NOTHING and returns the sentence describing exactly what would happen. THE READ-BACK INVERTS: the proof is the post’s ABSENCE from the authorised list afterwards, and an id that is still there is reported as not confirmed rather than done.",
      "group": "ads",
      "section": "SPARK ADS — the TikTok twin of Meta's object_story_id / Pinterest's pin_id",
      "annotations": {
        "title": "Remove a post from the authorised Spark Ads list",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "itemId": {
            "type": "string",
            "description": "from list_tiktok_ads_spark_posts"
          },
          "confirm": {
            "description": "REQUIRED true — call without it first to see exactly what would change",
            "type": "boolean"
          }
        },
        "required": [
          "itemId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_account_status",
      "title": "Check the TikTok account authorization",
      "description": "Report whether this brand holds the TikTok ACCOUNT-HOLDER authorization — the SECOND, separate consent on the same TikTok app that the TikTok Ads connection uses. TikTok issues two different grants: the ADVERTISER one (campaigns, budgets, reporting — that is `tiktok_ads`) and this ACCOUNT one, which is what lets Hermoso read and manage the comments on the brand's own TikTok posts and mint Spark-Ads authorization codes for them. Holding one does NOT give you the other, so a workspace can be fully connected for ads and still answer 'not connected' here — that is a real third state, not a broken session. Reports the state, the TikTok business id every other tool in this family uses, the scopes the grant actually carries, and any scope MISSING from it (TikTok binds scopes at authorize time and never retroactively, so a grant made before a scope was added simply does not have it and only a reconnect fixes that). Connecting is the one step that needs a browser — the reply carries the exact URL to send the user to. Read-only, free.",
      "group": "publish",
      "section": "TIKTOK ACCOUNT — THE SECOND GRANT ON THE SAME APP (2026-08-19)",
      "annotations": {
        "title": "Check the TikTok account authorization",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_comments",
      "title": "List comments on one of the brand’s TikTok posts",
      "description": "Read the comments on a TikTok post the AUTHORIZED ACCOUNT OWNS — this is TikTok's answer to list_meta_comments and list_youtube_comments. It sees BOTH public and hidden comments, and by default returns both: TikTok's `status` defaults to ALL, so the list mixes comments the owner hid with comments TikTok's own moderation, privacy or spam filters hid, and those are not the same thing (the second kind may refuse to unhide). Pass status:'PUBLIC' for only what the public sees. A row carrying parentCommentId IS A REPLY, not a top-level comment — that is how TikTok distinguishes them. include_replies attaches at most THREE replies per comment; use list_tiktok_comment_replies for all of them. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION, which is a separate consent from the TikTok Ads advertiser connection — tiktok_account_status says whether this brand has it. Read-only, free.",
      "group": "publish",
      "section": "TIKTOK ACCOUNT — THE SECOND GRANT ON THE SAME APP (2026-08-19)",
      "annotations": {
        "title": "List comments on one of the brand’s TikTok posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoId": {
            "type": "string",
            "description": "the TikTok post id (`item_id`) — the last path segment of a tiktok.com/@handle/video/<id> URL"
          },
          "commentIds": {
            "description": "filter to specific comment ids; TikTok caps this at 30",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "includeReplies": {
            "description": "attach up to THREE replies per comment — not all of them",
            "type": "boolean"
          },
          "status": {
            "description": "default ALL, which INCLUDES hidden comments",
            "type": "string",
            "enum": [
              "PUBLIC",
              "ALL"
            ]
          },
          "sortField": {
            "type": "string",
            "enum": [
              "likes",
              "replies",
              "create_time"
            ]
          },
          "sortOrder": {
            "type": "string",
            "enum": [
              "asc",
              "desc"
            ]
          },
          "cursor": {
            "type": "number"
          },
          "maxCount": {
            "type": "number"
          }
        },
        "required": [
          "videoId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_comment_replies",
      "title": "List every reply to one TikTok comment",
      "description": "All replies to a single comment on a post the authorized TikTok account owns — the complete list, where list_tiktok_comments only ever attaches three. TIKTOK DOES NOT RETURN REPLIES TO A HIDDEN COMMENT AT ALL, so an empty list against a hidden parent means 'cannot read', never 'no replies' — check the parent's status first if the answer looks wrong. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status). Read-only, free.",
      "group": "publish",
      "section": "TIKTOK ACCOUNT — THE SECOND GRANT ON THE SAME APP (2026-08-19)",
      "annotations": {
        "title": "List every reply to one TikTok comment",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoId": {
            "type": "string",
            "description": "TikTok requires the post id alongside the comment id"
          },
          "commentId": {
            "type": "string",
            "description": "from list_tiktok_comments"
          },
          "status": {
            "type": "string",
            "enum": [
              "PUBLIC",
              "ALL"
            ]
          },
          "sortField": {
            "type": "string",
            "enum": [
              "likes",
              "replies",
              "create_time"
            ]
          },
          "sortOrder": {
            "type": "string",
            "enum": [
              "asc",
              "desc"
            ]
          },
          "cursor": {
            "type": "number"
          },
          "maxCount": {
            "type": "number"
          }
        },
        "required": [
          "videoId",
          "commentId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "comment_on_tiktok_video",
      "title": "Post a new comment on the brand’s own TikTok post",
      "description": "Write a NEW top-level comment on a TikTok post the authorized account owns. Text (≤1,200 characters, UTF-8) or an image, and TikTok requires at least one of the two. AN IMAGE HERE MUST BE UPLOADED FIRST — a raw URL is refused on a new comment; call upload_tiktok_comment_image and pass back imageUri + imageWidth + imageHeight together (a reply is the one place TikTok accepts a plain URL). TIKTOK SILENTLY HIDES COMMENTS IT FLAGS AS SPAM and sends no signal when it does, so avoid posting many near-identical comments in a short window, and read the comment back with list_tiktok_comments(status:'PUBLIC') if it matters that it is visible. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).",
      "group": "publish",
      "section": "TIKTOK ACCOUNT — THE SECOND GRANT ON THE SAME APP (2026-08-19)",
      "annotations": {
        "title": "Post a new comment on the brand’s own TikTok post",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoId": {
            "type": "string",
            "description": "the TikTok post id"
          },
          "text": {
            "description": "≤1,200 characters (UTF-8). Either this or an image is required.",
            "type": "string"
          },
          "imageUri": {
            "description": "from upload_tiktok_comment_image — a raw URL is NOT accepted on a new comment",
            "type": "string"
          },
          "imageWidth": {
            "description": "required with imageUri",
            "type": "number"
          },
          "imageHeight": {
            "description": "required with imageUri",
            "type": "number"
          }
        },
        "required": [
          "videoId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reply_to_tiktok_comment",
      "title": "Reply to a comment on the brand’s TikTok post",
      "description": "Reply to an existing comment on a TikTok post the authorized account owns — TikTok's twin of reply_to_meta_comment and reply_to_youtube_comment. Text (≤1,200 characters) or an image; at least one is required. A REPLY IS THE ONE PLACE TIKTOK ACCEPTS A PLAIN IMAGE URL (pass imageUrl); an uploaded imageUri + width + height also works. TikTok silently hides replies it flags as spam, so read it back with list_tiktok_comment_replies if visibility matters. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).",
      "group": "publish",
      "section": "TIKTOK ACCOUNT — THE SECOND GRANT ON THE SAME APP (2026-08-19)",
      "annotations": {
        "title": "Reply to a comment on the brand’s TikTok post",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoId": {
            "type": "string",
            "description": "the post the comment sits on — TikTok requires it"
          },
          "commentId": {
            "type": "string",
            "description": "from list_tiktok_comments"
          },
          "text": {
            "description": "≤1,200 characters (UTF-8)",
            "type": "string"
          },
          "imageUrl": {
            "description": "a public image URL — TikTok accepts this on a REPLY only, never on a new comment",
            "type": "string"
          },
          "imageUri": {
            "description": "from upload_tiktok_comment_image",
            "type": "string"
          },
          "imageWidth": {
            "type": "number"
          },
          "imageHeight": {
            "type": "number"
          }
        },
        "required": [
          "videoId",
          "commentId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "moderate_tiktok_comment",
      "title": "Like, hide or delete a TikTok comment",
      "description": "Moderate one comment on a TikTok post the authorized account owns: LIKE, UNLIKE, HIDE, UNHIDE or DELETE. TWO RULES TIKTOK ENFORCES AND THIS TOOL STATES UP FRONT. (1) YOU CAN ONLY DELETE A COMMENT THIS ACCOUNT WROTE — anyone else's can be hidden but never deleted, so HIDE is the tool for an unwanted comment from a stranger, and it is reversible. DELETE is permanent and confirm-gated. (2) UNHIDE IS NOT GUARANTEED: TikTok says a comment may stay hidden because ITS OWN moderation, privacy or spam filters are what hid it, in which case accepting the request changes nothing — so pass videoId and the reply reads the comment back and reports its real status rather than claiming success from a 200. HIDE and UNHIDE require videoId; LIKE, UNLIKE and DELETE do not. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).",
      "group": "publish",
      "section": "TIKTOK ACCOUNT — THE SECOND GRANT ON THE SAME APP (2026-08-19)",
      "annotations": {
        "title": "Like, hide or delete a TikTok comment",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "commentId": {
            "type": "string",
            "description": "from list_tiktok_comments"
          },
          "action": {
            "type": "string",
            "enum": [
              "LIKE",
              "UNLIKE",
              "HIDE",
              "UNHIDE",
              "DELETE"
            ]
          },
          "videoId": {
            "description": "REQUIRED for HIDE and UNHIDE; pass it on the others too and the reply reads the comment back to prove the change landed",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true for DELETE, which is permanent — call without it to see exactly what would happen",
            "type": "boolean"
          }
        },
        "required": [
          "commentId",
          "action"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "upload_tiktok_comment_image",
      "title": "Upload an image for a TikTok comment",
      "description": "Turn a public image URL into the imageUri that comment_on_tiktok_video needs — TikTok will not take a raw URL on a new comment, only on a reply. Returns imageUri, imageWidth and imageHeight, and ALL THREE must be passed back together: TikTok rejects dimensions that do not match what it stored. Limits are TikTok's own — at most 5 MB, JPG/JPEG/PNG/WebP, between 360x360 and 1080x1920 (or 1920x1080). upload_file turns a local file into a URL this accepts. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).",
      "group": "publish",
      "section": "TIKTOK ACCOUNT — THE SECOND GRANT ON THE SAME APP (2026-08-19)",
      "annotations": {
        "title": "Upload an image for a TikTok comment",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "imageUrl": {
            "type": "string",
            "description": "a public URL to the image — upload_file turns a local file into one"
          }
        },
        "required": [
          "imageUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_tiktok_post_ad_authorization",
      "title": "Turn Spark-Ads authorization on or off for the brand’s own post",
      "description": "Turn TikTok's 'Ad authorization' setting ON or OFF for a post the authorized account owns — THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM. Until now the only way to get one was a human opening the TikTok app and copying a string; that is still true for somebody ELSE's post, and no longer true for the brand's own. Turning it on mints the code, which you then hand to authorize_tiktok_ads_spark_post so an ad account may promote the post. IT IS CONFIRM-GATED ON THE WAY ON, and not because it spends: TikTok changes the post's privacy to 'Available for Ads', sends it to their ad review team, says the post 'may also appear as an ad on third party platforms', and treats the call as accepting their Advertising Content Terms on the owner's behalf. Turning it OFF needs no confirm — but TikTok REFUSES to turn it off while an active Spark Ad is using the post, so pause those campaigns first. authorizationDays must be one of 7, 30, 60, 180, 365. THE ANSWER IS THE READ-BACK: the reply carries the post's real authorization status and its code, because TikTok's own response body is empty. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).",
      "group": "publish",
      "section": "TIKTOK ACCOUNT — THE SECOND GRANT ON THE SAME APP (2026-08-19)",
      "annotations": {
        "title": "Turn Spark-Ads authorization on or off for the brand’s own post",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "itemId": {
            "type": "string",
            "description": "the TikTok post id"
          },
          "enabled": {
            "type": "boolean",
            "description": "true turns Ad authorization ON (and mints the Spark Ads code); false turns it off"
          },
          "authorizationDays": {
            "description": "7 | 30 | 60 | 180 | 365 — TikTok publishes exactly those five; default 30",
            "type": "number"
          },
          "confirm": {
            "description": "REQUIRED true when enabling — it makes the post publicly promotable and accepts TikTok’s advertising terms on the owner’s behalf",
            "type": "boolean"
          }
        },
        "required": [
          "itemId",
          "enabled"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_tiktok_post_ad_authorization",
      "title": "Read a post’s Spark-Ads authorization status",
      "description": "Read the Spark-Ads authorization status of a post the authorized account owns — whether it is promotable, its authorization CODE, and the window the authorization runs for. A SPARK AD CANNOT OUTLIVE ITS AUTHORIZATION, so check the end time before building a campaign around a post. TIKTOK ERRORS RATHER THAN ANSWERING when a post has no authorization code at all, so a not-found style refusal from here almost always means Ad authorization was never turned on (or the code was deleted) — turn it on with set_tiktok_post_ad_authorization — and not that anything is broken. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status). Read-only, free.",
      "group": "publish",
      "section": "TIKTOK ACCOUNT — THE SECOND GRANT ON THE SAME APP (2026-08-19)",
      "annotations": {
        "title": "Read a post’s Spark-Ads authorization status",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "itemId": {
            "type": "string",
            "description": "the TikTok post id"
          }
        },
        "required": [
          "itemId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "extend_tiktok_post_ad_authorization",
      "title": "Extend a post’s Spark-Ads authorization",
      "description": "Extend how long a post the authorized account owns stays promotable as a Spark Ad — and REGENERATE its code if it was deleted. THE DAYS ARE ADDED, NOT SET: TikTok's own example is that a post with 180 days remaining, extended by 180, ends up at 360 — so passing '365' to a post that already has time left does not mean 'expires in a year'. Must be one of 7, 30, 60, 180, 365. Ad authorization has to be ON already (set_tiktok_post_ad_authorization) or TikTok refuses. The reply reads the new window back, because TikTok's own response body is empty. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).",
      "group": "publish",
      "section": "TIKTOK ACCOUNT — THE SECOND GRANT ON THE SAME APP (2026-08-19)",
      "annotations": {
        "title": "Extend a post’s Spark-Ads authorization",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "itemId": {
            "type": "string",
            "description": "the TikTok post id"
          },
          "authorizationDays": {
            "description": "7 | 30 | 60 | 180 | 365 — ADDED to whatever is left, not set as an absolute. Default 30.",
            "type": "number"
          }
        },
        "required": [
          "itemId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_tiktok_post_ad_authorization",
      "title": "Delete a post’s Spark-Ads authorization code",
      "description": "Delete the Spark-Ads authorization code for a post the authorized account owns. CONFIRM-GATED, and not because it spends: ads already built on the post keep running, but no NEW ad can be built against it until a fresh code exists, and TikTok REFUSES this outright while any campaign or ad using the post is still ACTIVE — pause those first. Calling it without confirm changes nothing and returns the sentence describing exactly what would happen. THE READ-BACK INVERTS: proof of success is that TikTok no longer reports a code for the post, and a code that is still there is reported as not confirmed rather than done. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).",
      "group": "publish",
      "section": "TIKTOK ACCOUNT — THE SECOND GRANT ON THE SAME APP (2026-08-19)",
      "annotations": {
        "title": "Delete a post’s Spark-Ads authorization code",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "itemId": {
            "type": "string",
            "description": "the TikTok post id"
          },
          "confirm": {
            "description": "REQUIRED true — call without it first to see exactly what would change",
            "type": "boolean"
          }
        },
        "required": [
          "itemId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_mentions",
      "title": "Posts that mention the brand on TikTok",
      "description": "Who is talking about the brand on TikTok: every public post whose CAPTION @-mentions the connected account's handle. This is TikTok's answer to x_mentions and list_threads_mentions, and it is the brand-monitoring read the product had for every other channel and not for TikTok. FOUR THINGS TIKTOK ENFORCES THAT MAKE AN EMPTY RESULT AMBIGUOUS, so read them before reporting silence: the mentioning post must be PUBLIC, it must be under 90 days old, its author must not be underage or of unknown age, and THE WHOLE FAMILY ONLY WORKS FOR A TIKTOK BUSINESS ACCOUNT, so a personal account returns nothing at all (tiktok_account_insights reports which one this is). Capped at the top 1,000 mentioning posts however many exist. VIEWS AND REACH COME BACK NULL, NOT ZERO, on any post under 1,000 of either. That is TikTok withholding a number, never a measurement of nothing. Thumbnail URLs expire after 48 hours. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission (tiktok_account_status says whether this brand has it). Read-only, free.",
      "group": "publish",
      "section": "TIKTOK BRAND MENTIONS: social listening on the brand's own handle (2026-08-20)",
      "annotations": {
        "title": "Posts that mention the brand on TikTok",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "days": {
            "description": "look-back window, 1 to 90 (TikTok indexes nothing older). Default 90",
            "type": "number"
          },
          "regions": {
            "description": "two-letter codes to filter the MENTIONING author's registration country, e.g. ['US','GB']. TikTok publishes 162 of them and answers an unknown one with an empty result, so an unpublished code is refused here instead",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "sortField": {
            "type": "string",
            "enum": [
              "CREATE_TIME",
              "LIKES",
              "COMMENTS",
              "SHARES"
            ]
          },
          "sortOrder": {
            "type": "string",
            "enum": [
              "ASC",
              "DESC"
            ]
          },
          "fields": {
            "description": "defaults to every field TikTok publishes; item_id, create_time, video_link, caption, likes, comments, shares, thumbnail_url, views, reach, creator_handle_name",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "cursor": {
            "type": "number"
          },
          "maxCount": {
            "description": "1 to 100",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_tiktok_mention",
      "title": "Read one mention from the TikTok mentions webhook",
      "description": "Read the full detail of a SINGLE mention: a mentioning post, or a mentioning comment if you pass commentId as well. This exists for the TikTok mentions WEBHOOK: the event carries only ids, and this turns one into the caption, the creator, the engagement and the thumbnail. TIKTOK ONLY GUARANTEES THE DATA FOR 48 HOURS after the event fires, so a queue that retries tomorrow gets nothing. For anything older read it out of list_tiktok_mentions or list_tiktok_mention_comments instead. itemId is required either way, comment or not. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.",
      "group": "publish",
      "section": "TIKTOK BRAND MENTIONS: social listening on the brand's own handle (2026-08-20)",
      "annotations": {
        "title": "Read one mention from the TikTok mentions webhook",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "itemId": {
            "type": "string",
            "description": "the post id. It is `video_id` in the webhook event content, or itemId from list_tiktok_mentions. REQUIRED even when reading a comment"
          },
          "commentId": {
            "description": "pass this to read a mentioning COMMENT rather than a mentioning post",
            "type": "string"
          },
          "fields": {
            "description": "defaults to every field TikTok publishes for that kind",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "itemId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_mention_comments",
      "title": "Comments that mention the brand on TikTok",
      "description": "Comments and replies anywhere on TikTok whose TEXT @-mentions the connected account's handle. This is the conversational half of brand monitoring, where list_tiktok_mentions covers post captions. Same four preconditions as that tool (public post, 90 days, adult author, BUSINESS account), so an empty list is not the same as silence. Capped at the top 1,000 by comment likes, and TIKTOK DELIBERATELY DEPRIORITISES a comment that is nothing but the @-mention with no other text, so bare tags may not appear at all. Sorts on different keys from the post list: VIDEO_LIKES, COMMENT_CREATE_TIME or COMMENT_LIKES. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights and comment-list permissions. Read-only, free.",
      "group": "publish",
      "section": "TIKTOK BRAND MENTIONS: social listening on the brand's own handle (2026-08-20)",
      "annotations": {
        "title": "Comments that mention the brand on TikTok",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "days": {
            "description": "1 to 90. Default 90",
            "type": "number"
          },
          "regions": {
            "description": "two-letter codes; filters on the commenting author's registration country",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "sortField": {
            "type": "string",
            "enum": [
              "VIDEO_LIKES",
              "COMMENT_CREATE_TIME",
              "COMMENT_LIKES"
            ]
          },
          "sortOrder": {
            "type": "string",
            "enum": [
              "ASC",
              "DESC"
            ]
          },
          "fields": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "cursor": {
            "type": "number"
          },
          "maxCount": {
            "description": "1 to 100; TikTok defaults this one to 10",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_mention_top_terms",
      "title": "The words and hashtags inside the brand’s TikTok mentions",
      "description": "The top 20 KEYWORDS and the top 20 HASHTAGS appearing in the captions of the posts that mention this brand. It is what people say when they talk about it, rather than which posts they said it in. Two TikTok endpoints behind one tool because they take identical parameters and answer the same question at two granularities; kind:'KEYWORDS' or kind:'HASHTAGS' calls only one. Counted across the top 1,000 mentioning posts of the last 90 days, so this is the language of the mentions and not of TikTok at large. Same BUSINESS-account precondition as every mentions tool. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.",
      "group": "publish",
      "section": "TIKTOK BRAND MENTIONS: social listening on the brand's own handle (2026-08-20)",
      "annotations": {
        "title": "The words and hashtags inside the brand’s TikTok mentions",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "kind": {
            "description": "default BOTH, which makes two calls",
            "type": "string",
            "enum": [
              "KEYWORDS",
              "HASHTAGS",
              "BOTH"
            ]
          },
          "regions": {
            "description": "two-letter codes to narrow which mentioning posts are counted",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_brand_hashtags",
      "title": "The brand hashtags TikTok tracks for this account",
      "description": "The brand hashtags this account curates, in two flavours. kind:'ENABLED' is what TikTok is currently counting (with the date each was turned on, and whether it is old enough to remove). kind:'AVAILABLE' is what TikTok will ACCEPT. A tag qualifies once it has appeared in at least three post captions, either from this account or from a post mentioning its handle. A tag containing the account handle as a substring can also be enabled even when it is not on the available list. AT MOST 50 CAN BE ENABLED PER BRAND. TikTok's own caveat on the available list: test and newly created accounts often return nothing and should not be used to judge whether this works. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.",
      "group": "publish",
      "section": "TIKTOK BRAND MENTIONS: social listening on the brand's own handle (2026-08-20)",
      "annotations": {
        "title": "The brand hashtags TikTok tracks for this account",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "kind": {
            "description": "default ENABLED",
            "type": "string",
            "enum": [
              "ENABLED",
              "AVAILABLE"
            ]
          },
          "username": {
            "description": "normally resolved from the authorization itself. Pass the @handle (without the @) only if that read is refused",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_tiktok_brand_hashtags",
      "title": "Turn a brand hashtag on or off for TikTok tracking",
      "description": "Enable or disable the hashtags TikTok counts as this brand's. ADD takes a LIST of up to 10 per call (50 enabled per brand in total); REMOVE takes exactly ONE, because TikTok publishes no bulk removal and a list would silently drop all but one. TWO TIMING RULES THAT ARE EASY TO TRIP: a newly enabled hashtag is not counted for 24 HOURS, so list_tiktok_brand_hashtag_posts shows nothing for it until then; and it CANNOT BE REMOVED FOR 7 DAYS after being enabled, so this tool reads the enable date first and reports the exact wait rather than relaying TikTok's undated refusal. A tag must either be on the AVAILABLE list or contain the account handle as a substring, or TikTok rejects it. THE ANSWER IS THE READ-BACK, not the 200: TikTok's remove response is an empty body, so success means the tag has left the enabled list. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission.",
      "group": "publish",
      "section": "TIKTOK BRAND MENTIONS: social listening on the brand's own handle (2026-08-20)",
      "annotations": {
        "title": "Turn a brand hashtag on or off for TikTok tracking",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "action": {
            "type": "string",
            "enum": [
              "ADD",
              "REMOVE"
            ]
          },
          "hashtags": {
            "description": "ADD only. Up to 10, with or without the leading #",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "hashtag": {
            "description": "REMOVE only. Exactly one",
            "type": "string"
          },
          "username": {
            "description": "normally resolved from the authorization itself",
            "type": "string"
          }
        },
        "required": [
          "action"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_brand_hashtag_posts",
      "title": "Posts carrying the brand’s hashtags on TikTok",
      "description": "Public posts whose captions carry one of the brand hashtags this account has enabled. It is the hashtag half of brand monitoring, where list_tiktok_mentions covers @-mentions. Omit `hashtag` for the top posts across every enabled tag; pass one to narrow to it. TWO TIKTOK BEHAVIOURS THAT READ AS BUGS IF NOBODY SAYS THEM: the hashtag filter is CASE-SENSITIVE and must exactly match an enabled tag, and filtering to one tag makes matched_hashtags come back EMPTY on every row. NOTHING IS RETURNED UNTIL HASHTAGS ARE ENABLED. That is a setup step rather than a result: use list_tiktok_brand_hashtags and manage_tiktok_brand_hashtags first, and allow 24 hours after enabling. Capped at the top 1,000 posts of the last 90 days. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.",
      "group": "publish",
      "section": "TIKTOK BRAND MENTIONS: social listening on the brand's own handle (2026-08-20)",
      "annotations": {
        "title": "Posts carrying the brand’s hashtags on TikTok",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "hashtag": {
            "description": "one ENABLED tag, spelled exactly as enabled. The match is case-sensitive",
            "type": "string"
          },
          "days": {
            "description": "1 to 90. Default 90",
            "type": "number"
          },
          "regions": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "sortField": {
            "type": "string",
            "enum": [
              "CREATE_TIME",
              "LIKES",
              "COMMENTS",
              "SHARES"
            ]
          },
          "sortOrder": {
            "type": "string",
            "enum": [
              "ASC",
              "DESC"
            ]
          },
          "fields": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "cursor": {
            "type": "number"
          },
          "maxCount": {
            "description": "1 to 100; TikTok defaults this one to 10",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_account_insights",
      "title": "TikTok follower demographics and daily performance",
      "description": "The connected TikTok account's OWN analytics: follower demographics broken down by AGE, GENDER, COUNTRY and CITY, the daily series (video views, profile views, likes, comments, shares, reached audience, engaged audience, follower gained/lost/net, and the profile-button clicks a verified Business account collects), and the lifetime counters. This is TikTok's twin of instagram_insights and youtube_channel_report. TWO PRECONDITIONS TIKTOK ENFORCES ON THE DEMOGRAPHICS, and this tool reports which one is in the way instead of returning an empty breakdown: the account must be a BUSINESS account, and it must have at least 100 FOLLOWERS. Below that TikTok withholds the distributions for privacy, which is not the same as an audience it could not measure. The look-back is capped at 60 DAYS, which is SHORTER than the 90 days the brand-mentions tools cover, and daily numbers lag by up to 48 hours. The bio, verified badge and profile link are deliberately not readable here. That needs a TikTok permission this authorization does not request, and tiktok_account on the TikTok posting connector already returns all three. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the audience-insights permission (tiktok_account_status says whether this brand has it). Read-only, free.",
      "group": "publish",
      "section": "TIKTOK AUDIENCE INSIGHTS AND CATEGORY BENCHMARKS (2026-08-20)",
      "annotations": {
        "title": "TikTok follower demographics and daily performance",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "startDate": {
            "description": "YYYY-MM-DD (UTC). Default is 7 days ago; TikTok keeps at most 60 days",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD (UTC). Default is yesterday",
            "type": "string"
          },
          "fields": {
            "description": "defaults to everything this authorization can read. Demographics are audience_ages, audience_genders, audience_countries, audience_cities",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_category_benchmark",
      "title": "TikTok industry averages for a business category",
      "description": "What an average TikTok Business account in a given industry looks like: mean likes, comments, shares, video count, follower count, 30-day follower growth, engagement rate and video views. Pair it with tiktok_account_insights to answer 'are we ahead of our category or behind it', which neither number answers alone. These are TikTok's own cross-account averages, not this brand's numbers. businessCategory must be one of TikTok's twenty-five published values. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION. Read-only, free.",
      "group": "publish",
      "section": "TIKTOK AUDIENCE INSIGHTS AND CATEGORY BENCHMARKS (2026-08-20)",
      "annotations": {
        "title": "TikTok industry averages for a business category",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "businessCategory": {
            "type": "string",
            "enum": [
              "ART_AND_CRAFTS",
              "AUTOMOTIVE_AND_TRANSPORTATION",
              "BABY",
              "BEAUTY",
              "CLOTHING_AND_ACCESSORIES",
              "EDUCATION_AND_TRAINING",
              "ELECTRONICS",
              "FINANCE_AND_INVESTING",
              "FOOD_AND_BEVERAGE",
              "GAMING",
              "HEALTH_AND_WELLNESS",
              "HOME_FURNITURE_AND_APPLIANCES",
              "MACHINERY_AND_EQUIPMENT",
              "MEDIA_AND_ENTERTAINMENT",
              "PERSONAL_BLOG",
              "PETS",
              "PROFESSIONAL_SERVICES",
              "PUBLIC_ADMINISTRATION",
              "REAL_ESTATE",
              "RESTAURANTS_AND_BARS",
              "SHOPPING_AND_RETAIL",
              "SOFTWARE_AND_APPS",
              "SPORTS_FITNESS_AND_OUTDOORS",
              "TRAVEL_AND_TOURISM",
              "OTHERS"
            ]
          }
        },
        "required": [
          "businessCategory"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_tiktok_ads_targeting",
      "title": "Resolve TikTok locations / interests / hashtags / languages",
      "description": "Look up the exact ids TikTok ad-group targeting expects, so none of them has to be invented. kind:\"location\" resolves TikTok’s targetable regions — an ad group CANNOT be created without location ids, TikTok refuses it in its own words (\"‘location_ids’ or ‘zipcode_ids’ must be specified\"). kind:\"interest\" resolves the interest categories and kind:\"interest_keyword\" the additional interest KEYWORDS (both attach to an ad group). kind:\"hashtag\" resolves real targeting HASHTAGS — until 2026-08-11 this kind pointed at TikTok’s interest-keyword endpoint and quietly returned interest categories instead; note that hashtag ids feed TikTok’s actions[] field, which Hermoso does not send yet, so treat hashtag results as RESEARCH rather than targeting you can apply. kind:\"language\" the language codes. A made-up id either fails the create or, worse, targets somebody else and spends money silently, so always resolve here first and never guess. Read-only, free.",
      "group": "publish",
      "section": "TIKTOK AUDIENCE INSIGHTS AND CATEGORY BENCHMARKS (2026-08-20)",
      "annotations": {
        "title": "Resolve TikTok locations / interests / hashtags / languages",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts — TikTok scopes these lookups to an advertiser",
            "type": "string"
          },
          "kind": {
            "description": "default location. interest_keyword and hashtag both REQUIRE keyword.",
            "type": "string",
            "enum": [
              "location",
              "interest",
              "interest_keyword",
              "hashtag",
              "language"
            ]
          },
          "keyword": {
            "description": "narrows the lookup, e.g. \"Canada\", \"Beauty\", \"skincare\"",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_ads_report",
      "title": "TikTok ads performance report",
      "description": "Performance for a TikTok advertiser account at campaign, ad group, ad or whole-advertiser level — spend, impressions, clicks, CTR, CPC, CPM and conversions by default, or whichever of TikTok’s metrics and grouping dimensions you name. `reportType` picks WHICH REPORT: BASIC is spend and performance (the default); AUDIENCE is the same money broken down by WHO SAW IT — pass an audience dimension such as age, gender, country_code or interest_category, which BASIC refuses outright. 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, spends nothing. NOTE: everything Hermoso creates on TikTok is created PAUSED, so a brand-new build reports nothing at all until somebody activates it with set_tiktok_ads_status(confirm:true) — check the statuses with list_tiktok_ads_campaigns before reading an empty report as bad performance.",
      "group": "publish",
      "section": "TIKTOK AUDIENCE INSIGHTS AND CATEGORY BENCHMARKS (2026-08-20)",
      "annotations": {
        "title": "TikTok ads performance report",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "level": {
            "description": "the reporting level — default campaign. \"advertiser\" is the whole account, which is the level an audience breakdown is normally run at.",
            "type": "string",
            "enum": [
              "campaign",
              "adgroup",
              "ad",
              "advertiser"
            ]
          },
          "reportType": {
            "description": "BASIC (default) = spend + performance. AUDIENCE = the audience breakdown; its dimensions are audience ATTRIBUTES (age, gender, country_code, interest_category …) and BASIC rejects those, so name them. PLAYABLE_MATERIAL = playable ads, CATALOG = Dynamic Showcase Ads. TikTok also documents BC (Business Center) and TT_SHOP (GMV max) — both are refused here by name with the reason.",
            "type": "string",
            "enum": [
              "BASIC",
              "AUDIENCE",
              "PLAYABLE_MATERIAL",
              "CATALOG"
            ]
          },
          "startDate": {
            "description": "YYYY-MM-DD",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD",
            "type": "string"
          },
          "metrics": {
            "description": "TikTok metric names — default [\"spend\",\"impressions\",\"clicks\",\"ctr\",\"cpc\",\"cpm\",\"conversion\"]",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "dimensions": {
            "description": "TikTok dimensions to group by — default the level’s own id, e.g. [\"campaign_id\"]. Add \"stat_time_day\" for a daily breakdown. On an AUDIENCE report these are audience attributes instead, e.g. [\"age\"].",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "page": {
            "description": "1-based page (default 1). A report LONGER than one page comes back with hasMore:true and a truncationNote — totals computed from one page are WRONG.",
            "type": "number"
          },
          "pageSize": {
            "description": "rows per page, 1–1000 (default 100). Adding \"stat_time_day\" overflows a page fast: 30 days x 4 campaigns is already 120 rows.",
            "type": "number"
          },
          "filtering": {
            "description": "TikTok’s own filter array, e.g. [{\"field_name\":\"campaign_ids\",\"filter_type\":\"IN\",\"filter_value\":\"['123']\"}]. Narrows the report AT TIKTOK rather than after truncation.",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_ads_campaign",
      "title": "Create a TikTok campaign (forced paused)",
      "description": "Create the top tier of a TikTok ad — the campaign, which fixes the OBJECTIVE everything under it optimises toward. CREATED PAUSED AND THERE IS NO OVERRIDE, and TikTok is the one platform where that is NOT the vendor’s own behaviour: TikTok creates ENABLED by default, so Hermoso forces operation_status DISABLE and overrides a caller who asks for ENABLE. It spends nothing until set_tiktok_ads_status(confirm:true). OBJECTIVES that create cleanly, each proven with a real create: TRAFFIC (the default), REACH, VIDEO_VIEWS, ENGAGEMENT, WEB_CONVERSIONS, LEAD_GENERATION, APP_INSTALL, PRODUCT_SALES, CONVERSIONS. RF_REACH (Reach & Frequency) also works but ONLY with budgetMode BUDGET_MODE_INFINITE — TikTok’s own words are \"The budget type for Reach & Frequency must be unlimited\". Five further values sit in TikTok’s schema enum and are REFUSED BY NAME with the reason (CATALOG_SALES is retired, SHOP_PURCHASES needs TikTok Shop on the advertiser, and APP_PROMOTION / RF_ENGAGEMENT / RF_APP_INSTALL are withheld) — the server owns that list, so a refusal names its cause and is TikTok’s restriction rather than a Hermoso fault. budgetMode is BUDGET_MODE_DAY (the default), BUDGET_MODE_TOTAL, BUDGET_MODE_DYNAMIC_DAILY_BUDGET or BUDGET_MODE_INFINITE. A campaign alone can never serve: build an ad group under it, then an ad. The result is READ BACK from TikTok and says so when the read-back could not be run.",
      "group": "publish",
      "section": "TIKTOK AUDIENCE INSIGHTS AND CATEGORY BENCHMARKS (2026-08-20)",
      "annotations": {
        "title": "Create a TikTok campaign (forced paused)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts — omit only when exactly one is reachable",
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "the campaign name in TikTok Ads Manager"
          },
          "objective": {
            "description": "default TRAFFIC. e.g. TRAFFIC, REACH, VIDEO_VIEWS, ENGAGEMENT, WEB_CONVERSIONS, LEAD_GENERATION, APP_INSTALL, PRODUCT_SALES, CONVERSIONS — or RF_REACH with budgetMode BUDGET_MODE_INFINITE. The server validates it and refuses an unavailable one BY NAME with TikTok’s reason.",
            "type": "string"
          },
          "budgetMode": {
            "description": "default BUDGET_MODE_DAY — or BUDGET_MODE_TOTAL / BUDGET_MODE_DYNAMIC_DAILY_BUDGET / BUDGET_MODE_INFINITE. RF_REACH requires BUDGET_MODE_INFINITE.",
            "type": "string"
          },
          "budget": {
            "description": "campaign budget in the advertiser’s own currency — not needed with BUDGET_MODE_INFINITE",
            "type": "number"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_ads_ad_group",
      "title": "Create a TikTok ad group (targeting, budget, bidding, schedule)",
      "description": "Create an ad group under an existing TikTok campaign — the tier that holds the budget, the bid, the placements, the schedule and ALL the targeting. CREATED PAUSED with no override (TikTok would otherwise create it ENABLED, and a live ad group under a paused campaign is exactly how spend escapes a one-level pause); it spends nothing until set_tiktok_ads_status(confirm:true). TIKTOK REQUIRES THIRTEEN FIELDS TOGETHER and its error names only the FIRST missing one per round trip, so the server checks the whole set locally and refuses with EVERY absentee at once — which matters here because QPS is 1 and each round trip costs a second. Sensible defaults are supplied for placement, pacing, promotion type, schedule type, optimization goal, billing event, bid type and budget mode, so the ONE thing you must resolve yourself is LOCATION: locationIds is effectively mandatory (TikTok: \"‘location_ids’ or ‘zipcode_ids’ must be specified\") and comes from search_tiktok_ads_targeting(kind:\"location\"). Resolve interest and language ids the same way — a guessed id either fails the create or targets the wrong people, silently. CONVERSION CAMPAIGNS (WEB_CONVERSIONS / CONVERSIONS / PRODUCT_SALES / LEAD_GENERATION) NEED A PIXEL: set optimizationGoal CONVERT, pass pixelId AND optimizationEvent, and billingEvent OCPM — TikTok pairs a billing event with every goal MANUALLY and derives nothing, so the server supplies the right one if you leave it out. Without a pixel TikTok refuses with \"Please select a pixel.\" The result is READ BACK from TikTok, and the pixel and event it reports are the STORED ones.",
      "group": "publish",
      "section": "TIKTOK AUDIENCE INSIGHTS AND CATEGORY BENCHMARKS (2026-08-20)",
      "annotations": {
        "title": "Create a TikTok ad group (targeting, budget, bidding, schedule)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign this ad group belongs to"
          },
          "name": {
            "type": "string"
          },
          "locationIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "REQUIRED in practice — ids from search_tiktok_ads_targeting(kind:\"location\"); TikTok refuses an ad group without them"
          },
          "placements": {
            "description": "default [\"PLACEMENT_TIKTOK\"]",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "placementType": {
            "description": "default PLACEMENT_TYPE_NORMAL",
            "type": "string"
          },
          "optimizationGoal": {
            "description": "what TikTok optimises delivery toward — default CLICK; it has to fit the campaign objective and TikTok’s refusal names the valid values",
            "type": "string"
          },
          "billingEvent": {
            "description": "what TikTok charges for — default CPC; must fit the optimization goal",
            "type": "string"
          },
          "bidType": {
            "description": "default BID_TYPE_NO_BID (let TikTok bid). A manual bid type needs bid.",
            "type": "string"
          },
          "bid": {
            "description": "bid price in the advertiser’s own currency — required by the manual bid types",
            "type": "number"
          },
          "pacing": {
            "description": "default PACING_MODE_SMOOTH, which spends the budget evenly across the day. PACING_MODE_FAST spends as fast as delivery allows — a real money decision, so it is never chosen for you.",
            "type": "string"
          },
          "budgetMode": {
            "description": "default BUDGET_MODE_DAY",
            "type": "string"
          },
          "budget": {
            "description": "ad-group budget in the advertiser’s own currency",
            "type": "number"
          },
          "scheduleType": {
            "description": "default SCHEDULE_FROM_NOW",
            "type": "string"
          },
          "scheduleStartTime": {
            "description": "\"YYYY-MM-DD HH:MM:SS\" in the advertiser’s timezone",
            "type": "string"
          },
          "scheduleEndTime": {
            "description": "only needed when the schedule type is a fixed window",
            "type": "string"
          },
          "promotionType": {
            "description": "what is being promoted — default WEBSITE",
            "type": "string"
          },
          "ageGroups": {
            "description": "TikTok age band values — omit to reach every age",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "genders": {
            "description": "omit to reach everyone",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "languages": {
            "description": "language codes from search_tiktok_ads_targeting(kind:\"language\")",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "interestCategoryIds": {
            "description": "ids from search_tiktok_ads_targeting(kind:\"interest\")",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "interestKeywordIds": {
            "description": "ids from search_tiktok_ads_targeting(kind:\"interest_keyword\") — TikTok pairs this with interestCategoryIds under Interests",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "pixelId": {
            "description": "REQUIRED when optimizationGoal is CONVERT or VALUE, and NOT ACCEPTED with any other goal — that is TikTok’s own two-sided rule, and it is why a conversion campaign built without one dies at the ad group with \"Please select a pixel.\" Get it from list_tiktok_ads_pixels, or create_tiktok_ads_pixel for a new one.",
            "type": "string"
          },
          "optimizationEvent": {
            "description": "REQUIRED whenever pixelId is given (and when optimizationGoal is IN_APP_EVENT or VALUE) — the conversion TikTok optimises toward. Web events: ON_WEB_DETAIL, ON_WEB_SEARCH, ON_WEB_ADD_TO_WISHLIST, ON_WEB_CART, INITIATE_ORDER, ADD_BILLING, SHOPPING, CONSULT, DOWNLOAD_START, FORM, ON_WEB_REGISTER, ON_WEB_SUBSCRIBE. Pick the one that matches the goal: SHOPPING for a purchase, FORM for a lead, ON_WEB_REGISTER for a signup. The event must already EXIST on the pixel or TikTok refuses with \"This pixel event type does not exist.\"",
            "type": "string"
          },
          "customConversionId": {
            "description": "optional narrower rule from list_tiktok_ads_custom_conversions — valid only alongside a pixel and only when optimizationGoal is CONVERT or IN_APP_EVENT",
            "type": "string"
          },
          "promotionTargetType": {
            "description": "LEAD_GENERATION campaigns only: INSTANT_PAGE (a TikTok in-app form) or EXTERNAL_WEBSITE (your own landing page). With EXTERNAL_WEBSITE + optimizationGoal CLICK, TikTok forbids pixelId and optimizationEvent.",
            "type": "string"
          },
          "conversionBid": {
            "description": "target cost per conversion for oCPM — required by TikTok when bidType is BID_TYPE_CUSTOM and billingEvent is OCPM",
            "type": "number"
          }
        },
        "required": [
          "campaignId",
          "name",
          "locationIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_pixels",
      "title": "List a TikTok advertiser’s conversion pixels",
      "description": "List the conversion pixels on a TikTok ad account, each with the EVENTS it can optimise toward — which is the difference between \"a pixel exists\" and \"this campaign can actually optimise\". Call it before building any conversion campaign (WEB_CONVERSIONS, CONVERSIONS, PRODUCT_SALES, LEAD_GENERATION): those need optimizationGoal CONVERT, which TikTok refuses without a pixel. Only an event reported under optimizationEvent can be used — TikTok returns none for PAGE_VIEW because it \"cannot be used for optimization\". An EMPTY event list is not proof a pixel is idle: TikTok refreshes pixel event data every 2-4 hours, so a pixel that started firing recently still reports []. Read-only, free.",
      "group": "ads",
      "section": "PIXELS: what makes a TikTok CONVERSION campaign deliverable at all",
      "annotations": {
        "title": "List a TikTok advertiser’s conversion pixels",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts — omit only when exactly one is reachable",
            "type": "string"
          },
          "pixelId": {
            "description": "filter to one pixel",
            "type": "string"
          },
          "name": {
            "description": "fuzzy name filter",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_ads_pixel",
      "title": "Create a TikTok conversion pixel",
      "description": "Create a new TikTok pixel on an ad account so conversion campaigns have something to optimise toward. Returns the pixel id and the pixel CODE plus the script to install. A PIXEL ALONE IS NOT ENOUGH AND THIS IS THE PART PEOPLE MISS: it measures nothing until (1) the script is installed on the website and (2) the specific conversion EVENT is defined on it — until then TikTok refuses a conversion ad group with \"This pixel event type does not exist.\" TikTok caps the name at 40 characters, rejects emojis and rejects DUPLICATE names, and it recommends naming the pixel after the site it measures. Creating a pixel spends nothing and serves nothing — it is a measurement definition. NOTE: TikTok publishes NO endpoint that deletes a pixel, so a pixel created here is permanent on that ad account.",
      "group": "ads",
      "section": "PIXELS: what makes a TikTok CONVERSION campaign deliverable at all",
      "annotations": {
        "title": "Create a TikTok conversion pixel",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "≤40 characters, no emojis, and it must not duplicate an existing pixel name on the account. TikTok recommends the website or domain it measures."
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_ads_pixel_stats",
      "title": "Read how often a TikTok pixel’s events fired",
      "description": "Event-level statistics for one TikTok pixel over a date range — how many times each event actually fired, which is how you tell a pixel that is installed and working from one that is installed and silent. Takes the pixel CODE (not the id) as reported by list_tiktok_ads_pixels. Read-only, free.",
      "group": "ads",
      "section": "PIXELS: what makes a TikTok CONVERSION campaign deliverable at all",
      "annotations": {
        "title": "Read how often a TikTok pixel’s events fired",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "pixelCode": {
            "type": "string",
            "description": "the pixelCode from list_tiktok_ads_pixels — NOT the pixelId"
          },
          "startDate": {
            "description": "YYYY-MM-DD",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD",
            "type": "string"
          }
        },
        "required": [
          "pixelCode"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_custom_conversions",
      "title": "List TikTok Custom Conversions",
      "description": "List the Custom Conversions defined on a TikTok EVENT SOURCE — narrower, rule-based conversions built on top of a pixel event (for example \"Purchase, but only on /checkout/premium\"). A Custom Conversion belongs to an event source, NOT to an advertiser, so TikTok requires eventSourceType and eventSourceId; with none given this resolves the single pixel on the advertiser when there is exactly one and otherwise asks which. Pass a Custom Conversion to create_tiktok_ads_ad_group as customConversionId to optimise toward the narrow rule instead of the broad standard event. TikTok accepts it ONLY alongside a pixel and only when optimizationGoal is CONVERT or IN_APP_EVENT, and only when its own optimizationEvent matches the one on the ad group; usableForAds reports whether its activity status allows it. Read-only, free.",
      "group": "ads",
      "section": "PIXELS: what makes a TikTok CONVERSION campaign deliverable at all",
      "annotations": {
        "title": "List TikTok Custom Conversions",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_ads_rf_inventory_estimate",
      "title": "Estimate TikTok Reach & Frequency inventory",
      "description": "Reach & Frequency INVENTORY ESTIMATE — how many people TikTok can reach, for what budget, at what frequency, over a fixed window, before anything is booked. THIS IS THE ONLY SANCTIONED SOURCE of the three numbers create_tiktok_ads_rf_ad_group needs (budget, purchasedImpression, purchasedReach), and that matters because TikTok does NOT refuse a reservation outside the range it estimated — it silently books its own maximum instead, so an invented number buys something nobody asked for. IMPRESSIONS AND REACH COME BACK IN THOUSANDS (110 means 110,000) and the create takes the same unit, so pass them back UNSCALED. Fix ONE of the three with rfPurchasedType (FIXED_BUDGET / FIXED_SHOW / FIXED_REACH) and TikTok predicts the other two; omit it to get the whole range. Reach & Frequency is ALLOWLIST-ONLY per ad account. Read-only, free, books nothing.",
      "group": "ads",
      "section": "REACH & FREQUENCY · LEAD MANAGEMENT · MEASUREMENT (2026-08-19)",
      "annotations": {
        "title": "Estimate TikTok Reach & Frequency inventory",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts — omit only when exactly one is reachable",
            "type": "string"
          },
          "locationIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "REQUIRED, and from ONE country or region only — ids from search_tiktok_ads_targeting(kind:\"location\")"
          },
          "scheduleStartTime": {
            "type": "string",
            "description": "\"YYYY-MM-DD HH:00:00\" UTC. R&F is booked in whole days IN THE TARGET REGION’S time zone expressed as UTC, so an advertiser in UTC-5 starting Nov 3 passes \"2020-11-03 05:00:00\" — not midnight."
          },
          "scheduleEndTime": {
            "type": "string",
            "description": "\"YYYY-MM-DD HH:59:59\" UTC. On THIS endpoint the window may not exceed 30 days (the create allows 90)."
          },
          "frequency": {
            "type": "number",
            "description": "with frequencySchedule, the cap: frequency 2 + frequencySchedule 3 means \"at most twice every 3 days\". Must be <= frequencySchedule and never more than 4 per day."
          },
          "frequencySchedule": {
            "type": "number",
            "description": "the cycle in days, at most min(campaign length, 30)"
          },
          "rfPurchasedType": {
            "description": "FIXED_BUDGET | FIXED_SHOW | FIXED_REACH — which one you FIX. Pass its own number too (budget / purchasedImpression / purchasedReach). Omit entirely for the unconstrained range.",
            "type": "string"
          },
          "budget": {
            "description": "required with FIXED_BUDGET",
            "type": "number"
          },
          "purchasedImpression": {
            "description": "IN THOUSANDS — required with FIXED_SHOW",
            "type": "number"
          },
          "purchasedReach": {
            "description": "IN THOUSANDS — required with FIXED_REACH",
            "type": "number"
          },
          "feedType": {
            "description": "STANDARD_FEED (large inventory, normal CPM) or TOP_FEED (limited inventory, higher CPM)",
            "type": "string"
          },
          "brandSafetyType": {
            "description": "NO_BRAND_SAFETY (default) | EXPANDED_INVENTORY | STANDARD_INVENTORY | LIMITED_INVENTORY",
            "type": "string"
          },
          "ageGroups": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "gender": {
            "type": "string"
          },
          "languages": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "operatingSystems": {
            "description": "ANDROID, IOS, PC",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "interestCategoryIds": {
            "description": "ids from search_tiktok_ads_targeting(kind:\"interest\")",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "locationIds",
          "scheduleStartTime",
          "scheduleEndTime",
          "frequency",
          "frequencySchedule"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_ads_rf_ad_group",
      "title": "Book a TikTok Reach & Frequency ad group (confirm-gated)",
      "description": "Create the ad group of a REACH & FREQUENCY campaign. THIS IS THE ONE OBJECT HERMOSO CREATES THAT CANNOT BE BORN PAUSED, and that is TikTok’s doing, not ours: /adgroup/rf/create/ publishes no operation_status anywhere in its parameter table, so there is no field to force — and an R&F ad group is a RESERVATION whose budget locks 5 minutes before delivery. So it needs confirm:true, and WITHOUT confirm NOTHING IS CREATED: you get the sentence describing exactly what would be booked, which you must show the user before asking for a yes. The three reservation numbers must come from tiktok_ads_rf_inventory_estimate — TikTok silently books its own maximum rather than refusing a number outside the estimated range. REACH & FREQUENCY IS ALLOWLIST-ONLY PER AD ACCOUNT: TikTok requires the advertiser to be allowlisted by a TikTok representative and to have signed a Commercial Contract for Branding, no API reports that status, and if TikTok refuses this the remedy is a conversation with the rep rather than a different field.",
      "group": "ads",
      "section": "REACH & FREQUENCY · LEAD MANAGEMENT · MEASUREMENT (2026-08-19)",
      "annotations": {
        "title": "Book a TikTok Reach & Frequency ad group (confirm-gated)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "an RF_REACH campaign (create_tiktok_ads_campaign with objective RF_REACH and budgetMode BUDGET_MODE_INFINITE)"
          },
          "name": {
            "type": "string"
          },
          "locationIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "one country or region only"
          },
          "rfPurchasedType": {
            "type": "string",
            "description": "FIXED_BUDGET | FIXED_SHOW | FIXED_REACH"
          },
          "budget": {
            "type": "number",
            "description": "from the estimate"
          },
          "purchasedImpression": {
            "type": "number",
            "description": "IN THOUSANDS, from the estimate"
          },
          "purchasedReach": {
            "type": "number",
            "description": "IN THOUSANDS, from the estimate"
          },
          "scheduleStartTime": {
            "type": "string",
            "description": "\"YYYY-MM-DD HH:00:00\" UTC for the target region"
          },
          "scheduleEndTime": {
            "type": "string",
            "description": "\"YYYY-MM-DD HH:59:59\" UTC; at most 90 days after the start"
          },
          "frequency": {
            "type": "number"
          },
          "frequencySchedule": {
            "type": "number"
          },
          "optimizationGoal": {
            "description": "REACH (default) | VIDEO_VIEW | CLICK | POST_ENGAGEMENT | INSTALL — must fit the campaign objective",
            "type": "string"
          },
          "cpvVideoDuration": {
            "description": "SIX_SECONDS — required when optimizationGoal is VIDEO_VIEW",
            "type": "string"
          },
          "requestId": {
            "description": "TikTok’s own idempotency key on this endpoint. Reuse the same value on a retry after a timeout and TikTok will not double-book.",
            "type": "string"
          },
          "ageGroups": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "gender": {
            "type": "string"
          },
          "languages": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "operatingSystems": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "interestCategoryIds": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "brandSafetyType": {
            "type": "string"
          },
          "feedType": {
            "type": "string"
          },
          "deliveryMode": {
            "description": "STANDARD | SCHEDULE | SEQUENCE",
            "type": "string"
          },
          "commentDisabled": {
            "type": "boolean"
          },
          "shareDisabled": {
            "type": "boolean"
          },
          "videoDownloadDisabled": {
            "type": "boolean"
          },
          "confirm": {
            "description": "REQUIRED to book. Without it nothing is created and you get the sentence to show the user.",
            "type": "boolean"
          }
        },
        "required": [
          "campaignId",
          "name",
          "locationIds",
          "rfPurchasedType",
          "budget",
          "purchasedImpression",
          "purchasedReach",
          "scheduleStartTime",
          "scheduleEndTime",
          "frequency",
          "frequencySchedule"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_lead_forms",
      "title": "List a TikTok advertiser’s Instant Forms",
      "description": "The Instant Forms (lead forms) on a TikTok ad account — this is where the pageId every other lead tool needs comes from. A form whose status is EDITED is a DRAFT and has never been able to collect anything, which is a different thing from \"a form with no leads\". THERE IS NO API THAT CREATES AN INSTANT FORM: they are built in TikTok Ads Manager, so an advertiser with none needs one made there (or a LEAD_GENERATION campaign using promotionTargetType EXTERNAL_WEBSITE, which collects on your own site and has no form here). Read-only, free.",
      "group": "ads",
      "section": "LEADS. LEAD_GENERATION has been an accepted objective all along and nothing could read a lead back, so a",
      "annotations": {
        "title": "List a TikTok advertiser’s Instant Forms",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "status": {
            "description": "PUBLISHED or EDITED (draft)",
            "type": "string"
          },
          "title": {
            "description": "exact-match name filter",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_lead_fields",
      "title": "What one TikTok Instant Form asks",
      "description": "The questions a TikTok Instant Form asks — which is exactly the set of columns download_tiktok_ads_leads will return. It is NOT a fixed schema: it is whatever that form was built to ask, so read it before promising a user which fields their leads carry. leadSource DIRECT_MESSAGE reads the fields collected in the associated Business Account’s direct messages instead, and takes no pageId (TikTok says it is \"not supported\" there). Read-only, free.",
      "group": "ads",
      "section": "LEADS. LEAD_GENERATION has been an accepted objective all along and nothing could read a lead back, so a",
      "annotations": {
        "title": "What one TikTok Instant Form asks",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "pageId": {
            "description": "the Instant Form, from list_tiktok_ads_lead_forms. Required for INSTANT_FORM.",
            "type": "string"
          },
          "leadSource": {
            "description": "INSTANT_FORM (default) or DIRECT_MESSAGE",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "download_tiktok_ads_leads",
      "title": "Download the leads from a TikTok lead ad",
      "description": "DOWNLOAD THE ACTUAL LEADS — the names, emails and phone numbers people entered in a TikTok Instant Form. REGION IS REQUIRED AND THERE IS NO \"ALL\": TikTok keeps US, EEA/CH/UK and everywhere-else leads in three SEPARATE stores and returns only the one you name, so asking for the wrong one returns an empty file that reads exactly like \"this ad got no leads\". Run it once per region if the ad targets more than one. TikTok restricts lead download to ad-account ADMINS, so a refusal is a Business Center role rather than a broken connection and reconnecting will not fix it. Hermoso creates the download task, polls it and parses the CSV in ONE call because TikTok expires the generated file after 10 minutes. A task that has not finished, or a file TikTok zipped because it exceeded 10MB, is reported as \"could not read\" — never as \"no leads\". THE ROWS ARE PERSONAL DATA. Free.",
      "group": "ads",
      "section": "LEADS. LEAD_GENERATION has been an accepted objective all along and nothing could read a lead back, so a",
      "annotations": {
        "title": "Download the leads from a TikTok lead ad",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "region": {
            "type": "string",
            "description": "REQUIRED. \"us\" = the United States · \"eu\" = the EEA, Switzerland and the UK · \"other\" = every country EXCEPT those — \"other\" is a third bucket, NOT \"everywhere\". Check the ad group’s targeting if unsure."
          },
          "pageId": {
            "description": "every lead on one Instant Form, from list_tiktok_ads_lead_forms",
            "type": "string"
          },
          "adId": {
            "description": "just that ad’s leads. Pass this OR pageId, never both — they are different scopes and TikTok would pick for you.",
            "type": "string"
          },
          "max": {
            "description": "rows to return, default 200, cap 500. The `total` field always reports how many the file actually held.",
            "type": "number"
          }
        },
        "required": [
          "region"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_tiktok_ads_test_lead",
      "title": "Create, read or delete a TikTok TEST lead",
      "description": "TikTok’s own test-lead mechanism — the way to prove a lead pipeline works end to end WITHOUT waiting for a real person to fill in a form, and the exact twin of testEventCode on send_tiktok_ads_events. action \"create\" makes one, \"get\" reads the existing one, \"delete\" removes it. A test lead flows through the SAME download path as a real one (so download_tiktok_ads_leads is provably wired), carries obviously-dummy campaign and ad-group names so it can never be mistaken for a customer, spends nothing and reaches nobody. TikTok allows EXACTLY ONE test lead per Instant Form, so delete the existing one before creating another. Free.",
      "group": "ads",
      "section": "LEADS. LEAD_GENERATION has been an accepted objective all along and nothing could read a lead back, so a",
      "annotations": {
        "title": "Create, read or delete a TikTok TEST lead",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "action": {
            "type": "string",
            "enum": [
              "create",
              "get",
              "delete"
            ],
            "description": "create | get | delete"
          },
          "pageId": {
            "description": "the Instant Form, from list_tiktok_ads_lead_forms — required for create/get on an INSTANT_FORM",
            "type": "string"
          },
          "leadId": {
            "description": "required for delete — get it from action:\"get\"",
            "type": "string"
          },
          "leadSource": {
            "description": "INSTANT_FORM (default) or DIRECT_MESSAGE",
            "type": "string"
          }
        },
        "required": [
          "action"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "send_tiktok_ads_events",
      "title": "Send server-side conversion events to TikTok (Events API 2.0)",
      "description": "SERVER-SIDE CONVERSION TRACKING — report a purchase, signup, form submission or lead to TikTok straight from a server, so TikTok can attribute it and optimise delivery toward it. This is what makes a pixel useful: a pixel with no events is inert, and a conversion campaign optimising toward an event nobody sends has nothing to learn from. ALWAYS OFFER testEventCode FIRST: an event carrying it lands in the Test Events tab of Events Manager and is EXCLUDED from reporting, attribution and optimisation, whereas an event sent WITHOUT it is a real, permanent conversion that TikTok will optimise against and that NO endpoint deletes. The code is copied out of Events Manager in a browser (open the pixel or event set ▸ Test Events ▸ the code button under \"Test Server Events\") and no API mints one, exactly like a Spark Ads authorization code. eventTime is a Unix timestamp in SECONDS — milliseconds are refused here for free, because TikTok accepts them and reads them as a date tens of thousands of years away, so the event is silently attributed to nothing. Emails, phones and external ids are trimmed, lower-cased, E.164-normalized and SHA-256 hashed by Hermoso exactly as TikTok specifies before anything leaves the process; a value that is already a 64-character hash is passed through untouched, so nothing is ever double-hashed. A phone with no \"+\" country code is refused rather than guessed. Free.",
      "group": "ads",
      "section": "MEASUREMENT. We could create a TikTok pixel and had no way to send it an event, which is the same shape as",
      "annotations": {
        "title": "Send server-side conversion events to TikTok (Events API 2.0)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "eventSource": {
            "type": "string",
            "enum": [
              "web",
              "app",
              "offline",
              "crm"
            ],
            "description": "web = a website pixel · app = a mobile app (TikTok gates this behind an allowlist) · offline = a physical store · crm = lead events from a CRM"
          },
          "eventSourceId": {
            "type": "string",
            "description": "web: the pixel CODE from list_tiktok_ads_pixels (NOT the pixelId) · app: the TikTok App ID · offline: the Offline Event Set ID · crm: the CRM Event Set ID"
          },
          "testEventCode": {
            "description": "from Events Manager ▸ Test Events. WITH it nothing reaches reporting or optimisation; WITHOUT it these are real permanent conversions. Offer it before sending anything live.",
            "type": "string"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "event": {
                  "type": "string",
                  "description": "a TikTok standard event (CompletePayment, Purchase, AddToCart, CompleteRegistration, SubmitForm, ViewContent …) or your own custom name"
                },
                "eventTime": {
                  "type": "number",
                  "description": "Unix timestamp in SECONDS, UTC"
                },
                "eventId": {
                  "description": "REQUIRED if the same event is also sent by the browser pixel — TikTok deduplicates on event_source_id + event_id + event",
                  "type": "string"
                },
                "user": {
                  "description": "{ email, phone, external_id, ttclid, ttp, ip, user_agent } — email/phone/external_id are hashed for you",
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {}
                },
                "properties": {
                  "description": "{ value, currency, contents:[{content_id, content_name, price, quantity}], order_id … }",
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {}
                },
                "page": {
                  "description": "{ url, referrer } — REQUIRED for web events",
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {}
                },
                "app": {
                  "description": "REQUIRED for app events",
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {}
                },
                "ad": {
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {}
                },
                "lead": {
                  "description": "REQUIRED for crm events",
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {}
                },
                "limitedDataUse": {
                  "type": "boolean"
                }
              },
              "required": [
                "event",
                "eventTime"
              ]
            },
            "description": "up to 1000 per request — above that TikTok rejects the WHOLE request, not the overflow"
          }
        },
        "required": [
          "eventSource",
          "eventSourceId",
          "events"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_audiences",
      "title": "List a TikTok advertiser’s custom audiences",
      "description": "The custom audiences on a TikTok ad account — retargeting pools built from real behaviour (website pixel, app activity, ad engagement, organic posts, leads, shop activity) plus any lookalike grown from them. Until this existed, TikTok targeting through Hermoso was interests-and-geo only. THE FIELD THAT DECIDES EVERYTHING IS usableInAdGroups: TikTok takes up to 48 HOURS to process a new audience, so isValid:false immediately after creation is the normal processing window rather than a failure, and only a valid audience changes delivery. ownership tells you whether this advertiser owns the audience or another one shared it. detail:true with audienceIds reads /dmp/custom_audience/get/ instead — adding the rule, the auto-refresh setting and any error message TikTok recorded. Read-only, free.",
      "group": "ads",
      "section": "AUDIENCE MANAGEMENT · DPA CATALOG · APP MANAGEMENT (2026-08-19)",
      "annotations": {
        "title": "List a TikTok advertiser’s custom audiences",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts — omit only when exactly one is reachable",
            "type": "string"
          },
          "audienceIds": {
            "description": "up to 100 ids",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "detail": {
            "description": "needs audienceIds — reads TikTok’s audience-detail endpoint instead, adding the rule and any error message it recorded",
            "type": "boolean"
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "up to 100",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_ads_audience",
      "title": "Build a TikTok retargeting audience from real behaviour",
      "description": "Create a TikTok custom audience from what people actually did — the thing that turns TikTok targeting from interests-and-geo into retargeting. audienceType chooses the source and EACH ONE READS A DIFFERENT ID SPACE for eventSourceIds, which is the mistake to avoid: PIXEL takes pixel ids (list_tiktok_ads_pixels), APP takes app ids (list_tiktok_ads_apps), ENGAGEMENT takes AD GROUP ids, ENGAGEMENT_ORGANIC_VIDEO takes TikTok post ids (list_tiktok_ads_identity_posts, max 10), TIKTOK_SHOP takes shop ids, OFFLINE takes offline event-set ids — and a LEAD_GENERATION audience must carry NO eventSourceIds at all, because TikTok errors if the field is present. The `event` on each rule must belong to that audience type: an event from a different family is refused here by name rather than silently building an audience that measures the wrong thing. THE AUDIENCE IS NOT USABLE IMMEDIATELY — TikTok takes up to 48 hours to process it, and until then it cannot be attached to an ad group. Creating one spends nothing: an audience is a definition. The reply is READ BACK from TikTok, because TikTok’s create returns an id and nothing else.",
      "group": "ads",
      "section": "AUDIENCE MANAGEMENT · DPA CATALOG · APP MANAGEMENT (2026-08-19)",
      "annotations": {
        "title": "Build a TikTok retargeting audience from real behaviour",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "≤128 characters"
          },
          "audienceType": {
            "type": "string",
            "enum": [
              "ENGAGEMENT",
              "ENGAGEMENT_ORGANIC_VIDEO",
              "ENGAGEMENT_LIVE_VIDEO",
              "APP",
              "PIXEL",
              "LEAD_GENERATION",
              "BUSINESS_ACCOUNT",
              "TIKTOK_SHOP",
              "OFFLINE"
            ]
          },
          "rules": {
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            },
            "description": "inclusion rules, combined with OR. Each is {event, retentionDays, eventSourceIds?, parameterFilters?} — e.g. {event:\"COMPLETE PAYMENT\", retentionDays:30, eventSourceIds:[\"<pixelId>\"]}"
          },
          "excludeRules": {
            "description": "same shape; anyone matching these is removed from the audience",
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "retentionInDays": {
            "description": "how long the AUDIENCE itself lives, 1-365. A DIFFERENT field from each rule’s retentionDays lookback window — TikTok says itself it will rename one of them.",
            "type": "number"
          },
          "autoRefresh": {
            "description": "default true at TikTok — keeps the audience updating with new matching people",
            "type": "boolean"
          },
          "identityId": {
            "description": "REQUIRED for ENGAGEMENT_ORGANIC_VIDEO and ENGAGEMENT_LIVE_VIDEO — from list_tiktok_ads_identities",
            "type": "string"
          },
          "identityType": {
            "type": "string",
            "enum": [
              "TT_USER",
              "BC_AUTH_TT"
            ]
          },
          "identityAuthorizedBcId": {
            "description": "REQUIRED when identityType is BC_AUTH_TT",
            "type": "string"
          }
        },
        "required": [
          "name",
          "audienceType",
          "rules"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_ads_lookalike_audience",
      "title": "Grow a TikTok lookalike from an existing audience",
      "description": "Create a TikTok Lookalike Audience — TikTok finds people who resemble a seed audience you already have. The seed must exist, must hold AT LEAST 100 people, and cannot itself be a lookalike. audienceSize trades reach against similarity: NARROW is closest to the seed, BROAD reaches furthest. includeSource has NO DEFAULT and TikTok requires it — it decides whether the seed’s own people remain inside the new audience, which matters because including them double-targets people you are already reaching. LOOKALIKE LOCATIONS ARE A SHORTER LIST THAN ORDINARY AD TARGETING (39 countries), so a location id that works on an ad group is not automatically valid here; an unsupported one is refused by name with the full list rather than sent. Takes up to 48 hours to process, and cannot be attached to a Reach & Frequency ad group. Creating one spends nothing.",
      "group": "ads",
      "section": "AUDIENCE MANAGEMENT · DPA CATALOG · APP MANAGEMENT (2026-08-19)",
      "annotations": {
        "title": "Grow a TikTok lookalike from an existing audience",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "≤128 characters"
          },
          "sourceAudienceId": {
            "type": "string",
            "description": "the seed, from list_tiktok_ads_audiences — at least 100 people, and not itself a lookalike"
          },
          "audienceSize": {
            "type": "string",
            "enum": [
              "NARROW",
              "BALANCED",
              "BROAD"
            ]
          },
          "includeSource": {
            "type": "boolean",
            "description": "REQUIRED, no default — true keeps the seed’s own people inside the lookalike"
          },
          "mobileOs": {
            "description": "default ALL",
            "type": "string",
            "enum": [
              "ALL",
              "IOS",
              "ANDROID"
            ]
          },
          "placements": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "one or more of \"TikTok\", \"TopBuzz & BuzzVideo\", \"Pangle\" — spelled exactly as TikTok publishes them"
          },
          "locationIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "two-letter codes from TikTok’s LOOKALIKE list, e.g. US, GB, DE, JP"
          }
        },
        "required": [
          "name",
          "sourceAudienceId",
          "audienceSize",
          "includeSource",
          "placements",
          "locationIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_tiktok_ads_audience",
      "title": "Rename a TikTok custom audience",
      "description": "Rename a TikTok custom audience. THAT IS ALL IT CHANGES, and the limit is TikTok’s rather than ours: their only other update path replaces the uploaded customer FILE, which Hermoso deliberately does not build (hashed personal data under TikTok’s own Terms, over a transport this integration does not have, where a wrong hash yields a silently EMPTY audience). Changing WHO is in an audience means creating a new one. The new name is READ BACK from TikTok because their update returns an empty body, so a bare success proves nothing — if the read-back disagrees with what you sent, the reply says so and the read-back is the truth.",
      "group": "ads",
      "section": "AUDIENCE MANAGEMENT · DPA CATALOG · APP MANAGEMENT (2026-08-19)",
      "annotations": {
        "title": "Rename a TikTok custom audience",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "audienceId": {
            "type": "string",
            "description": "from list_tiktok_ads_audiences"
          },
          "name": {
            "type": "string",
            "description": "the new name, ≤128 characters"
          }
        },
        "required": [
          "audienceId",
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "apply_tiktok_ads_audience",
      "title": "Include or exclude a TikTok audience on ad groups",
      "description": "Attach a TikTok custom audience to ad groups as an INCLUDE or an EXCLUDE — the call that changes WHO SEES THE ADS, and the reason building audiences is worth doing at all. actionMode is exactly \"Apply\" or \"Disconnect\" (TikTok’s enum is case-sensitive), and Apply also needs usageMode \"Include\" or \"Exclude\": GETTING usageMode WRONG INVERTS THE ENTIRE AUDIENCE, showing the ads to precisely the people you meant to keep out, and nothing in TikTok’s reply would say so. One audience per call, and the audience and ad groups must be on the same advertiser. A Lookalike cannot be applied to a Reach & Frequency ad group. It spends nothing by itself — every ad group Hermoso builds is born paused — but on an ALREADY-LIVE ad group it changes delivery on the next auction, so name the ad groups to the user first. The ad groups are read back from TikTok.",
      "group": "ads",
      "section": "AUDIENCE MANAGEMENT · DPA CATALOG · APP MANAGEMENT (2026-08-19)",
      "annotations": {
        "title": "Include or exclude a TikTok audience on ad groups",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "audienceId": {
            "type": "string",
            "description": "exactly one — TikTok takes a single custom_audience_id per call"
          },
          "adgroupIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "from list_tiktok_ads_campaigns"
          },
          "actionMode": {
            "type": "string",
            "enum": [
              "Apply",
              "Disconnect"
            ]
          },
          "usageMode": {
            "description": "REQUIRED when actionMode is Apply, and refused with Disconnect",
            "type": "string",
            "enum": [
              "Include",
              "Exclude"
            ]
          }
        },
        "required": [
          "audienceId",
          "adgroupIds",
          "actionMode"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_tiktok_ads_audience",
      "title": "Delete TikTok custom audiences",
      "description": "Delete one or more TikTok custom audiences. IRREVERSIBLE: TikTok publishes no undelete, any Lookalike seeded from one loses its seed, every ad group targeting it stops using it, and re-creating one means rebuilding the rule and waiting up to 48 hours for it to process again. WITHOUT confirm NOTHING IS DELETED — you get each audience’s real name, type and matched-user count READ BACK FROM TIKTOK, which is what you show the user before asking for a yes, because a confirm flag proves you meant to delete something and cannot tell an empty scratch audience from the one every campaign targets. Deleting MORE THAN ONE additionally needs confirmCount set to the exact number. If the audiences cannot be READ, the delete is refused outright rather than performed blind — an unknown blast radius on an irreversible action is not the same as an empty one.",
      "group": "ads",
      "section": "AUDIENCE MANAGEMENT · DPA CATALOG · APP MANAGEMENT (2026-08-19)",
      "annotations": {
        "title": "Delete TikTok custom audiences",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "audienceIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "up to 100"
          },
          "confirm": {
            "description": "REQUIRED true — without it nothing is deleted and you get the sentence describing what would be",
            "type": "boolean"
          },
          "confirmCount": {
            "description": "REQUIRED, and equal to audienceIds.length, when deleting more than one",
            "type": "number"
          }
        },
        "required": [
          "audienceIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_ads_audience_overlap",
      "title": "Compare how much two TikTok audiences share",
      "description": "How much a TikTok audience overlaps with up to four others — the read that answers whether a new audience is genuinely new or is mostly the people you already target, which is what stops two ad groups bidding against each other for the same person. TIKTOK SUPPRESSES ANY AUDIENCE SIZE UNDER 1,000, so a missing or blank size here means \"fewer than 1,000\", never \"nobody\" — reporting it as a zero would be a confidently wrong answer. Read-only, free.",
      "group": "ads",
      "section": "AUDIENCE MANAGEMENT · DPA CATALOG · APP MANAGEMENT (2026-08-19)",
      "annotations": {
        "title": "Compare how much two TikTok audiences share",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "benchmarkAudienceId": {
            "type": "string",
            "description": "the audience everything else is compared against"
          },
          "comparisonAudienceIds": {
            "description": "up to 4",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "benchmarkAudienceId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_business_centers",
      "title": "List the TikTok Business Centers this login administers",
      "description": "The TikTok Business Centers reachable on this connection. THIS IS THE ONE PIECE OF STRUCTURE THAT MAKES THE CATALOG TOOLS DIFFERENT FROM EVERY OTHER TIKTOK TOOL: a product catalog belongs to a Business Center, not to an ad account, so the catalog tools take a bcId from here and an advertiserId will not work. With exactly one Business Center reachable the catalog tools resolve it themselves; with several they refuse and name them rather than guessing, because a catalog created under the wrong Business Center is invisible to the ad account that needed it and nothing in the reply would say so. Read-only, free.",
      "group": "ads",
      "section": "DPA CATALOG. A catalog is a BUSINESS CENTER asset, so this lane resolves a bcId rather than an advertiser.",
      "annotations": {
        "title": "List the TikTok Business Centers this login administers",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "bcId": {
            "description": "filter to one",
            "type": "string"
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "up to 50",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_catalogs",
      "title": "List the product catalogs in a TikTok Business Center",
      "description": "The product catalogs in a TikTok Business Center — the containers Dynamic Product Ads sell from — each with HOW MANY OF ITS PRODUCTS ARE APPROVED, REJECTED OR STILL PROCESSING, which is the difference between \"the feed ran\" and \"the feed worked\". A products:null on a row means those counts COULD NOT BE READ for that catalog, which is not the same as a catalog with no products. Pass regions:true to also get the regions a new catalog may target — worth doing BEFORE creating one, because a catalog’s region and currency are set once and can never be changed. Read-only, free.",
      "group": "ads",
      "section": "DPA CATALOG. A catalog is a BUSINESS CENTER asset, so this lane resolves a bcId rather than an advertiser.",
      "annotations": {
        "title": "List the product catalogs in a TikTok Business Center",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "bcId": {
            "description": "from list_tiktok_ads_business_centers — omit only when exactly one is reachable",
            "type": "string"
          },
          "catalogId": {
            "description": "filter to one",
            "type": "string"
          },
          "regions": {
            "description": "also return the regions a catalog in this Business Center may target",
            "type": "boolean"
          },
          "overview": {
            "description": "set false to skip the per-catalog product counts (one extra call each)",
            "type": "boolean"
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_ads_catalog",
      "title": "Create a TikTok product catalog",
      "description": "Create a product catalog inside a TikTok Business Center — the container Dynamic Product Ads sell from. REGION AND CURRENCY ARE SET ONCE AND CAN NEVER BE CHANGED: TikTok’s own catalog-update endpoint changes the NAME and nothing else, so a catalog created in the wrong region or currency has to be replaced. Confirm both with the user before calling, and use list_tiktok_ads_catalogs(regions:true) to see which regions this Business Center may use. The new catalog IS EMPTY — products arrive through a scheduled feed, so the next step is always manage_tiktok_ads_catalog_feed. ENTERTAINMENT and MINI_SERIES catalogs are allowlist-only at TikTok and are offered rather than refused, because an allowlisted advertiser can use them. Creating a catalog spends nothing.",
      "group": "ads",
      "section": "DPA CATALOG. A catalog is a BUSINESS CENTER asset, so this lane resolves a bcId rather than an advertiser.",
      "annotations": {
        "title": "Create a TikTok product catalog",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "bcId": {
            "description": "from list_tiktok_ads_business_centers",
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "≤128 characters"
          },
          "catalogType": {
            "description": "default ECOM — what a shop selling physical products wants",
            "type": "string",
            "enum": [
              "ECOM",
              "HOTEL",
              "FLIGHT",
              "DESTINATION",
              "ENTERTAINMENT",
              "AUTO_VEHICLE",
              "AUTO_MODEL",
              "MINI_SERIES",
              "GENERIC"
            ]
          },
          "regionCode": {
            "type": "string",
            "description": "IMMUTABLE. The primary targeting region, e.g. US"
          },
          "currency": {
            "type": "string",
            "description": "IMMUTABLE. The currency for that region, e.g. USD"
          },
          "channel": {
            "description": "also immutable once set",
            "type": "string",
            "enum": [
              "PARTNER",
              "CLIENT"
            ]
          }
        },
        "required": [
          "name",
          "regionCode",
          "currency"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_tiktok_ads_catalog_feed",
      "title": "List, create or delete a TikTok catalog product feed",
      "description": "The scheduled import that fills a TikTok catalog from the product feed a store already publishes — Shopify, WooCommerce and BigCommerce all expose one, so this is usually the whole job of getting a merchant’s products into TikTok. THE ONE THING TO GET RIGHT IS updateMode, and it has NO DEFAULT ON PURPOSE: INCREMENTAL creates new products and updates existing ones, while OVERWRITE IS A FULL REPLACEMENT THAT REMOVES EVERY PRODUCT THE FEED OMITS — pointing a partial feed at a populated catalog in OVERWRITE mode deletes the rest of it. uri, intervalType and timezone are ALL-OR-NOTHING; TikTok requires each of the three whenever either of the others is given, so a URL with no schedule is a rejected call rather than a one-off fetch. If any additional-image URL inside the feed contains a comma it must be URL-encoded as %2C, or TikTok splits it into two URLs (Shopify-style transform parameters are the common case). Products do not appear instantly: the feed runs on its schedule and then TikTok reviews the products, so check list_tiktok_ads_catalog_products and tiktok_ads_catalog_diagnostics rather than expecting them immediately. action:\"delete\" is confirm-gated and stops FUTURE imports — the products already imported stay in the catalog.",
      "group": "ads",
      "section": "DPA CATALOG. A catalog is a BUSINESS CENTER asset, so this lane resolves a bcId rather than an advertiser.",
      "annotations": {
        "title": "List, create or delete a TikTok catalog product feed",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "bcId": {
            "description": "from list_tiktok_ads_business_centers",
            "type": "string"
          },
          "catalogId": {
            "type": "string",
            "description": "from list_tiktok_ads_catalogs"
          },
          "action": {
            "description": "default list",
            "type": "string",
            "enum": [
              "list",
              "create",
              "delete"
            ]
          },
          "feedId": {
            "description": "required for delete; filters a list",
            "type": "string"
          },
          "feedName": {
            "description": "required for create",
            "type": "string"
          },
          "updateMode": {
            "description": "REQUIRED on create and deliberately without a default — OVERWRITE REMOVES products missing from the feed",
            "type": "string",
            "enum": [
              "OVERWRITE",
              "INCREMENTAL"
            ]
          },
          "uri": {
            "description": "http/https/ftp/sftp URL of the feed file — CSV, TSV or XML (RSS/ATOM), up to 8GB",
            "type": "string"
          },
          "intervalType": {
            "description": "required alongside uri and timezone",
            "type": "string",
            "enum": [
              "HOURLY",
              "DAILY",
              "MONTHLY"
            ]
          },
          "intervalCount": {
            "description": "HOURLY 1-23, DAILY 1-30, MONTHLY 1-12",
            "type": "number"
          },
          "timezone": {
            "description": "IANA zone, e.g. America/New_York — required alongside uri and intervalType",
            "type": "string"
          },
          "hour": {
            "description": "0-23",
            "type": "number"
          },
          "minute": {
            "description": "0-59",
            "type": "number"
          },
          "dayOfMonth": {
            "type": "number"
          },
          "username": {
            "description": "only if the feed URL is password protected",
            "type": "string"
          },
          "password": {
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true for action:\"delete\"",
            "type": "boolean"
          }
        },
        "required": [
          "catalogId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_catalog_products",
      "title": "List the products in a TikTok catalog, with why any are unadvertisable",
      "description": "The products inside a TikTok catalog, each with whether it can ACTUALLY be advertised and — when it cannot — TikTok’s own rejection reason together with its suggested fix and which placements and countries were affected. usableInAds is true only when all three of audit approved, active status ACTIVATED and availability IN_STOCK / AVAILABLE_FOR_ORDER / PREORDER hold, so a catalog full of \"approved\" products can still be entirely unadvertisable and the raw audit status alone would not say so. Narrow with productIds, skuIds or productSetIds; productIds and skuIds cannot be used together (TikTok’s own rule), and TikTok returns at most 10,000 rows in total so page x pageSize is capped rather than paged into a 400. Read-only, free.",
      "group": "ads",
      "section": "DPA CATALOG. A catalog is a BUSINESS CENTER asset, so this lane resolves a bcId rather than an advertiser.",
      "annotations": {
        "title": "List the products in a TikTok catalog, with why any are unadvertisable",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "bcId": {
            "description": "from list_tiktok_ads_business_centers",
            "type": "string"
          },
          "catalogId": {
            "type": "string",
            "description": "from list_tiktok_ads_catalogs"
          },
          "productIds": {
            "description": "up to 1,000 — not together with skuIds",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "skuIds": {
            "description": "up to 1,000 — not together with productIds",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "productSetIds": {
            "description": "up to 100, from list_tiktok_ads_catalog_sets",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "up to 500",
            "type": "number"
          }
        },
        "required": [
          "catalogId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_catalog_sets",
      "title": "List the product sets in a TikTok catalog",
      "description": "The product SETS in a TikTok catalog — the slices an ad group can target instead of the whole catalog — each with its filter conditions and how many products it holds. Product sets are built in TikTok Ads Manager rather than here, and this reads what exists. On a very large set pass productCount:false, because counting products can time the call out at TikTok’s end — that is TikTok’s own caveat, not a guess. A null productCount means it was not requested or not returned, not that the set is empty. Read-only, free.",
      "group": "ads",
      "section": "DPA CATALOG. A catalog is a BUSINESS CENTER asset, so this lane resolves a bcId rather than an advertiser.",
      "annotations": {
        "title": "List the product sets in a TikTok catalog",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "bcId": {
            "description": "from list_tiktok_ads_business_centers",
            "type": "string"
          },
          "catalogId": {
            "type": "string",
            "description": "from list_tiktok_ads_catalogs"
          },
          "productSetId": {
            "description": "filter to one",
            "type": "string"
          },
          "productCount": {
            "description": "default true; pass false on a very large set to avoid a TikTok timeout",
            "type": "boolean"
          }
        },
        "required": [
          "catalogId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_ads_catalog_diagnostics",
      "title": "Diagnose why a TikTok catalog is not working",
      "description": "TikTok’s own diagnosis of a catalog — every detected issue at CRITICAL or WARNING level across product attributes, product review, catalog configuration, pixel/event problems and feed or file-upload failures, each carrying the reason AND TikTok’s suggested fix. This is the tool for \"the feed ran and no products showed up\" and for \"my products were rejected and I do not know why\". IT COVERS EVERY FEED IN THE CATALOG BY DEFAULT: naming a single feedId narrows it, and simply omitting the field at TikTok would silently report on the DEFAULT feed alone, so a multi-feed catalog with one broken feed would get a clean bill of health. Diagnostics regenerate once a day, so a fix made today may not clear until tomorrow’s run, and the reply says which day’s data it is. Read-only, free.",
      "group": "ads",
      "section": "DPA CATALOG. A catalog is a BUSINESS CENTER asset, so this lane resolves a bcId rather than an advertiser.",
      "annotations": {
        "title": "Diagnose why a TikTok catalog is not working",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "bcId": {
            "description": "from list_tiktok_ads_business_centers",
            "type": "string"
          },
          "catalogId": {
            "type": "string",
            "description": "from list_tiktok_ads_catalogs"
          },
          "feedId": {
            "description": "defaults to ALL feeds in the catalog",
            "type": "string"
          },
          "issueLevel": {
            "type": "string",
            "enum": [
              "CRITICAL",
              "WARNING"
            ]
          },
          "issueCategory": {
            "type": "string",
            "enum": [
              "PRODUCT_ATTRIBUTES",
              "PRODUCT_REVIEW",
              "CATALOG",
              "PIXEL_OR_EVENT",
              "FILE_UPLOAD_OR_FEED"
            ]
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "up to 20",
            "type": "number"
          }
        },
        "required": [
          "catalogId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_apps",
      "title": "List the mobile apps on a TikTok ad account",
      "description": "The mobile apps registered on a TikTok ad account. An APP_INSTALL campaign cannot be built without one, and an APP-activity audience uses these ids as its event sources — so this is the missing lookup that made APP_INSTALL offerable and undeliverable in the same product, exactly as conversion campaigns were before pixels. Registering a NEW app is deliberately not offered here: it needs the store listing and the attribution provider, and it belongs in TikTok Ads Manager rather than being set on a user’s behalf. Read-only, free.",
      "group": "ads",
      "section": "APP MANAGEMENT: the pixel gap, one lane over. APP_INSTALL was an objective with no way to name the app.",
      "annotations": {
        "title": "List the mobile apps on a TikTok ad account",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_app_events",
      "title": "List which app conversion events TikTok can optimise toward",
      "description": "The conversion events an APP campaign may actually optimise toward on one app — the app twin of list_tiktok_ads_pixels, and the same distinction between \"an event exists\" and \"an ad group can optimise for it\". An event reporting INSUFFICIENT_POSTBACK is NOT broken and NOT misconfigured: TikTok needs more measured conversions before it will optimise toward it, and unlockThreshold says how many against conversions in the last 30 days. Only an event with usableForOptimization:true can be used today. A brand-new app with no measured conversions legitimately returns none. Read-only, free.",
      "group": "ads",
      "section": "APP MANAGEMENT: the pixel gap, one lane over. APP_INSTALL was an objective with no way to name the app.",
      "annotations": {
        "title": "List which app conversion events TikTok can optimise toward",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "appId": {
            "type": "string",
            "description": "from list_tiktok_ads_apps"
          },
          "optimizationGoal": {
            "description": "default IN_APP_EVENT",
            "type": "string"
          },
          "objective": {
            "description": "default APP_INSTALL",
            "type": "string"
          },
          "appPromotionType": {
            "type": "string",
            "enum": [
              "APP_INSTALL",
              "APP_RETARGETING"
            ]
          },
          "placementType": {
            "description": "default PLACEMENT_TYPE_AUTOMATIC",
            "type": "string",
            "enum": [
              "PLACEMENT_TYPE_AUTOMATIC",
              "PLACEMENT_TYPE_NORMAL"
            ]
          },
          "availableOnly": {
            "description": "default true at TikTok — false also returns events that are not yet usable",
            "type": "boolean"
          }
        },
        "required": [
          "appId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_comments",
      "title": "Read the comments on a TikTok ad group’s ads",
      "description": "The public comments people have left on your TikTok ads — the read that finally makes TikTok comment moderation possible through Hermoso, where Meta and YouTube moderation already shipped. Covers both paid-impression ads and Spark Ads. TWO THINGS ARE NOT NEGOTIABLE AND BOTH COME FROM TIKTOK: comments are scoped to an AD GROUP and to nothing else (their search_field accepts the single value ADGROUP_ID, so there is no way to ask for one ad or a whole campaign — get ad group ids from list_tiktok_ads_campaigns), and startTime→endTime may span AT MOST 30 DAYS. That 30-day cap is not in TikTok’s parameter table; it was measured, and asking for more answers an opaque \"The maximum allowed time span is 30 days\" — so an empty result means \"none in these 30 days\", never \"none ever\", and covering a longer period means one call per slice. Every row carries what the other tools need: commentId, adId, tiktokItemId, identityId and identityType, plus hitBlockedWord (caught by your blocked-word list), isPinned, likes, replies, and canDelete — TikTok’s own per-comment answer to whether YOU may delete it, which is false for anything a member of the public wrote. Read-only, free.",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "Read the comments on a TikTok ad group’s ads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts — omit only when exactly one is reachable",
            "type": "string"
          },
          "adgroupId": {
            "type": "string",
            "description": "REQUIRED — from list_tiktok_ads_campaigns. TikTok offers no other way to scope a comment read."
          },
          "startTime": {
            "type": "string",
            "description": "YYYY-MM-DD. At most 30 days before endTime — TikTok refuses a wider window."
          },
          "endTime": {
            "type": "string",
            "description": "YYYY-MM-DD"
          },
          "commentTypes": {
            "description": "default ALL. COMMENT is a top-level comment, REPLY is an answer to one.",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "ALL",
                "COMMENT",
                "REPLY"
              ]
            }
          },
          "sortField": {
            "description": "default CREATE_TIME",
            "type": "string",
            "enum": [
              "CREATE_TIME",
              "LIKES",
              "REPLIES"
            ]
          },
          "sortType": {
            "description": "default DESC",
            "type": "string",
            "enum": [
              "ASC",
              "DESC"
            ]
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "up to 100",
            "type": "number"
          }
        },
        "required": [
          "adgroupId",
          "startTime",
          "endTime"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_ads_comment_thread",
      "title": "Read one TikTok ad comment with its replies",
      "description": "The conversation around a single comment on a TikTok ad: give it an ORIGINAL comment and you get that comment plus every reply to it; give it a REPLY and you get the reply plus the comment it answers. Use it before replying to a heated thread, so you answer what was actually said rather than the one line list_tiktok_ads_comments showed. Pages up to 1000 at a time, which is ten times what the list read allows. Read-only, free.",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "Read one TikTok ad comment with its replies",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "commentId": {
            "type": "string",
            "description": "from list_tiktok_ads_comments"
          },
          "commentType": {
            "description": "what THIS comment is — default COMMENT. The row from list_tiktok_ads_comments reports it.",
            "type": "string",
            "enum": [
              "COMMENT",
              "REPLY"
            ]
          },
          "originalCommentId": {
            "description": "only for a REPLY — the id of the comment it answers, as reported on the row",
            "type": "string"
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "up to 1000",
            "type": "number"
          }
        },
        "required": [
          "commentId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "moderate_tiktok_ads_comment",
      "title": "Hide or unhide comments on a TikTok ad",
      "description": "THE MODERATION TOOL for TikTok ads, and the TikTok twin of moderate_meta_comment. Hiding takes a comment out of public view — everyone stops seeing it except the person who wrote it, who is never told — and it works on ANYONE’s comment, which is what distinguishes it from delete_tiktok_ads_comment (that one only ever removes your own reply). IT IS FULLY REVERSIBLE: the same tool with operation PUBLIC puts a comment back, which is why it needs no confirmation. Takes a list, so a whole batch of spam goes in one call. TikTok answers this endpoint with no data at all, so the reply reports what was ACCEPTED and tells you to re-run list_tiktok_ads_comments to see commentStatus change — it is not a read-back and does not pretend to be. To hide a whole class of comment automatically and permanently, add the word to the account’s blocked-word list instead (manage_tiktok_ads_blocked_words).",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "Hide or unhide comments on a TikTok ad",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "commentIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "from list_tiktok_ads_comments"
          },
          "operation": {
            "type": "string",
            "enum": [
              "HIDDEN",
              "PUBLIC"
            ],
            "description": "HIDDEN takes them out of public view; PUBLIC puts them back"
          }
        },
        "required": [
          "commentIds",
          "operation"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reply_to_tiktok_ads_comment",
      "title": "Reply publicly to a comment on a TikTok ad",
      "description": "Post a public reply, as the brand, under a comment on one of your TikTok ads — the TikTok twin of reply_to_meta_comment and reply_to_youtube_comment. THE REPLY IS LIVE IMMEDIATELY AND IS PUBLIC, posted as the TikTok identity you name, so let the user approve the wording. TikTok requires FIVE ids and every one of them is on the comment row itself — pass back commentId, adId, tiktokItemId, identityId and identityType exactly as list_tiktok_ads_comments reported them rather than assembling them by hand; a mismatched identity is a public mistake. identityType on the comment endpoints is narrower than on the ad builder: only TT_USER or CUSTOMIZED_USER. The reply comes back as TikTok’s own stored row with its new comment id, so the answer is TikTok’s record and not an echo — and because it is your own comment, delete_tiktok_ads_comment can remove it.",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "Reply publicly to a comment on a TikTok ad",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "commentId": {
            "type": "string",
            "description": "the comment being replied to — from list_tiktok_ads_comments"
          },
          "adId": {
            "type": "string",
            "description": "from the same comment row"
          },
          "tiktokItemId": {
            "type": "string",
            "description": "the TikTok video id, from the same comment row"
          },
          "identityId": {
            "type": "string",
            "description": "from the same comment row (or list_tiktok_ads_identities) — the account the reply is posted AS"
          },
          "identityType": {
            "type": "string",
            "enum": [
              "TT_USER",
              "CUSTOMIZED_USER"
            ],
            "description": "as reported on the comment row"
          },
          "text": {
            "type": "string",
            "description": "the reply, exactly as it will appear in public"
          }
        },
        "required": [
          "commentId",
          "adId",
          "tiktokItemId",
          "identityId",
          "identityType",
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_tiktok_ads_comment",
      "title": "Delete your OWN comment on a TikTok ad",
      "description": "⚠ READ THIS BEFORE REACHING FOR IT: TikTok’s comment delete removes a comment YOUR OWN advertiser identity posted — your own reply. It CANNOT remove a comment left by a member of the public, and TikTok reports that per comment as canDelete on every row from list_tiktok_ads_comments. To take somebody else’s comment out of public view, use moderate_tiktok_ads_comment with operation HIDDEN, which is the moderation tool and is reversible. IRREVERSIBLE: TikTok publishes no undelete, the replies under it go with it, and re-posting means a new comment with a new id at the bottom of the thread rather than the one people answered. WITHOUT confirm NOTHING IS DELETED — you get the comment’s real text, author and date READ BACK FROM TIKTOK, which is what you show the user before asking for a yes. Deleting then additionally needs confirmText set to that exact text, because a confirm flag proves you meant to delete something and cannot prove you aimed at the right comment. If the comment cannot be READ, the delete is refused outright rather than performed blind.",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "Delete your OWN comment on a TikTok ad",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "commentId": {
            "type": "string",
            "description": "from list_tiktok_ads_comments — one whose canDelete is true"
          },
          "adId": {
            "type": "string",
            "description": "from the same comment row"
          },
          "tiktokItemId": {
            "type": "string",
            "description": "from the same comment row"
          },
          "identityId": {
            "type": "string",
            "description": "from the same comment row"
          },
          "identityType": {
            "type": "string",
            "enum": [
              "TT_USER",
              "CUSTOMIZED_USER"
            ],
            "description": "from the same comment row"
          },
          "commentType": {
            "description": "what the comment is — default REPLY, since your own comments are replies",
            "type": "string",
            "enum": [
              "COMMENT",
              "REPLY"
            ]
          },
          "confirm": {
            "description": "REQUIRED true — without it nothing is deleted and you get the sentence describing what would be",
            "type": "boolean"
          },
          "confirmText": {
            "description": "REQUIRED with confirm — the comment’s exact text, read back from TikTok",
            "type": "string"
          }
        },
        "required": [
          "commentId",
          "adId",
          "tiktokItemId",
          "identityId",
          "identityType"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_blocked_words",
      "title": "The words TikTok auto-hides in comments on this ad account",
      "description": "The ad account’s blocked-word list — a standing filter that hides ANY comment containing one of these words, automatically, across every ad on the account, before a human ever sees it. NOTHING ELSE IN HERMOSO DOES THIS: Meta and YouTube moderation here are both per-comment and after the fact, so this is the only place a brand can decide once that a slur or a competitor’s name never appears under its ads again. TikTok caps the list at 500 words per ad account and the reply says how many slots are left. Pass `check` with words to ask TikTok directly whether each one is already blocked — that answer is authoritative across the whole list rather than across the page you happened to fetch. Read-only, free.",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "The words TikTok auto-hides in comments on this ad account",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "check": {
            "description": "ask whether these specific words are blocked — answered by TikTok, not by searching the page",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "up to 500, which is the whole account limit — one page holds every word an account can have",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_tiktok_ads_blocked_words",
      "title": "Add, remove or replace TikTok comment blocked words",
      "description": "Change the ad account’s blocked-word list — the standing filter that auto-hides any comment containing one of these words across every ad on the account. operation \"add\" (up to TikTok’s 500-word account limit, refused here for free if the list would overflow rather than failing at TikTok), \"remove\", or \"replace\" one word with another. ⚠ REMOVING A WORD REPUBLISHES HISTORY: TikTok’s own words are that comments already hidden by it \"will become public instead of being visible only to the commentor\" — a bulk unhide across every ad on the account, for which TikTok reports no count. So a remove without confirm changes nothing and tells you which of the words are actually on the list first. EVERY WRITE HERE IS READ BACK: TikTok answers all three operations with an empty response and its delete explicitly does not error on a word that was never on the list, so reporting off the response would report a no-op as a change — the reply is the diff of the list before and after.",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "Add, remove or replace TikTok comment blocked words",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "operation": {
            "type": "string",
            "enum": [
              "add",
              "remove",
              "replace"
            ]
          },
          "words": {
            "description": "for add and remove",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "oldWord": {
            "description": "for replace",
            "type": "string"
          },
          "newWord": {
            "description": "for replace",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true for remove — without it nothing is removed and you get the list of which words are actually blocked plus what unhiding them will do",
            "type": "boolean"
          }
        },
        "required": [
          "operation"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_ads_diagnosis",
      "title": "TikTok’s own verdict on what is wrong with your ad groups",
      "description": "TikTok’s issues-and-suggestions diagnosis for the ad groups on an ad account — its own read on why delivery is underperforming, in three categories: CREATIVE (no background music, video too short, resolution too low), BID_AND_BUDGET (a suggested bid or budget, a recommendation to switch to Maximum Delivery, and the full Estimated Delivery Results tables pairing nine bid levels or fifteen budget levels with their estimated cost, conversions, CPA and impressions), and EVENT_TRACK (a pixel that has recorded nothing for seven days, which means the ad group is optimising toward an event nothing is firing). Every issue code comes back with a plain sentence saying what to DO about it. ⚠ AN EMPTY ANSWER IS NOT A CLEAN BILL OF HEALTH: TikTok diagnoses ACTIVE ad groups only and omits any ad group it has no suggestions for, so a missing ad group is EITHER healthy OR not currently active and TikTok does not distinguish the two. At most 20 ad groups per call. Read-only, free.",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "TikTok’s own verdict on what is wrong with your ad groups",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "adgroupIds": {
            "description": "up to 20 — omit for every active ad group on the account",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "issueCategories": {
            "description": "omit for all three. An unknown category is refused rather than dropped, so you never get an answer about something other than what you asked for.",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "CREATIVE",
                "BID_AND_BUDGET",
                "EVENT_TRACK"
              ]
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_tiktok_ads_brand_safety",
      "title": "Read a TikTok ad account’s Brand Safety Hub settings",
      "description": "What content this TikTok ad account’s ads are allowed to appear next to — the inventory filter tier (EXPANDED / STANDARD / LIMITED / NO_BRAND_SAFETY) plus the suitability controls: which content categories are excluded and which vertical’s sensitive content is avoided. It also returns, free and in the same call, THE CATALOGUE of every category id you could set, because those ids are unguessable and there is otherwise no way to act on the setting. ⚠ THE SCOPE IS THE THING TO KNOW: TikTok applies this to future Smart+ campaigns and, in their own words, \"will not apply to future regular campaigns created using /campaign/create/\" — which is the endpoint create_tiktok_ads_campaign uses. So this is the ad account default and the Ads Manager setting, NOT a guarantee inherited by a campaign Hermoso builds. A NULL vertical-sensitivity catalogue means this advertiser is not on TikTok’s allowlist for that feature, never that no vertical categories exist. Read-only, free.",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "Read a TikTok ad account’s Brand Safety Hub settings",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "categories": {
            "description": "default true — set false to skip the category catalogue and read only the current setting",
            "type": "boolean"
          },
          "objectiveType": {
            "description": "which objective to look the catalogue up under — default REACH. TikTok supports only these three on the catalogue endpoint.",
            "type": "string",
            "enum": [
              "REACH",
              "VIDEO_VIEWS",
              "ENGAGEMENT"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_tiktok_ads_brand_safety",
      "title": "Set a TikTok ad account’s Brand Safety Hub settings",
      "description": "Set what content this TikTok ad account’s ads may appear next to. THREE RULES TIKTOK IMPOSES AND ONE THING THAT CANNOT BE UNDONE. (1) The suitability controls move as a TRIO: TikTok requires brandSafetyType, categoryExclusionIds and verticalSensitivityId \"simultaneously\", so read the current values with get_tiktok_ads_brand_safety and send all three — a partial update is refused here rather than silently doing something else. (2) NO_BRAND_SAFETY is a tier TikTok REPORTS but does not ACCEPT: the write enum is EXPANDED_INVENTORY / STANDARD_INVENTORY / LIMITED_INVENTORY only, so an account can be moved off it through the API and not back onto it. (3) Category exclusions and vertical sensitivity are valid only under STANDARD or LIMITED. ⚠ coverAllObjectives IS A ONE-WAY DOOR — TikTok: \"Once set to true, this setting cannot be updated back to false.\" Turning it on widens the settings from Reach / Video Views / Community Interaction to every objective, permanently, so that direction is confirm-gated and nothing else here is. AND MIND THE SCOPE: TikTok applies this to future Smart+ campaigns and explicitly NOT to regular campaigns created through /campaign/create/, which is what create_tiktok_ads_campaign uses. The reply is TikTok’s own stored row read back, so it reports what actually changed rather than what was sent. EXPANDED_INVENTORY is allowlist-only per advertiser; if TikTok refuses it the remedy is your TikTok rep, not a different field.",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "Set a TikTok ad account’s Brand Safety Hub settings",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts",
            "type": "string"
          },
          "coverAllObjectives": {
            "type": "boolean",
            "description": "REQUIRED by TikTok on every update. true is PERMANENT — it cannot be set back to false."
          },
          "brandSafetyType": {
            "description": "required whenever you change any suitability control",
            "type": "string",
            "enum": [
              "EXPANDED_INVENTORY",
              "STANDARD_INVENTORY",
              "LIMITED_INVENTORY"
            ]
          },
          "categoryExclusionIds": {
            "description": "from get_tiktok_ads_brand_safety’s availableCategoryExclusions. Send [] explicitly to have none, so clearing them is something you said rather than something that happened.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "verticalSensitivityId": {
            "description": "from get_tiktok_ads_brand_safety’s availableVerticalSensitivity",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true only to turn coverAllObjectives ON for the first time, because that is irreversible",
            "type": "boolean"
          }
        },
        "required": [
          "coverAllObjectives"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "upload_tiktok_ads_creative",
      "title": "Upload a video or image into a TikTok advertiser’s asset library",
      "description": "Put a finished creative onto the TikTok AD ACCOUNT so an ad can point at it — this is the bridge between making an ad and running one, and create_tiktok_ads_ad has no other source for the ids it needs. Pass the public https url of a Hermoso render (every render already is one) or any other public https file; TikTok fetches it from its own servers, so a local path, a data: url or anything on http:// is refused here for free. A VIDEO UPLOAD ALSO UPLOADS ITS OWN COVER and hands back coverImageId — pass that straight to create_tiktok_ads_ad as imageIds, because TikTok requires a cover on a video ad and rejects any whose dimensions differ from the video. CHECK usableInAds BEFORE BUILDING THE AD: TikTok happily accepts a creative it will then refuse to build an ad from, and says so only in this flag — a 720x1280 render is fine and a 496x864 one is not, so a false here means re-render larger, and the note says so. Uploading the same file twice is fine; TikTok refuses a repeated file NAME, so leave fileName out unless you want a specific label in TikTok Ads Manager. Free — TikTok charges nothing for storage and Hermoso bills no credits for this.",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "Upload a video or image into a TikTok advertiser’s asset library",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "from list_tiktok_ads_accounts — omit only when exactly one is reachable",
            "type": "string"
          },
          "kind": {
            "description": "default video",
            "type": "string",
            "enum": [
              "video",
              "image"
            ]
          },
          "url": {
            "type": "string",
            "description": "public https url of the file. A Hermoso render url works as-is; for a local or external file run upload_file first to get one."
          },
          "fileName": {
            "description": "the label in TikTok Ads Manager. Omit it and a unique one is derived — TikTok rejects a DUPLICATE name on the same advertiser, so an explicit name that has been used before is refused.",
            "type": "string"
          }
        },
        "required": [
          "url"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_ads_ad",
      "title": "Create a TikTok ad",
      "description": "Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here for free rather than failing at TikTok with \"Unsupported image size\", which blames the image for a problem in the video. AN IDENTITY IS MANDATORY AND HAS NO DEFAULT: identityId AND identityType both come from list_tiktok_ads_identities and the ad appears publicly as that TikTok account, so let the USER pick and never guess — the call is refused outright without either. SPARK ADS — RUN A REAL ORGANIC POST INSTEAD: pass tiktokItemId (from list_tiktok_ads_identity_posts or list_tiktok_ads_spark_posts) INSTEAD OF videoId, and the ad IS that TikTok post, keeping its own comments, likes and shares under the account that made it. Spark needs an identityType of TT_USER, BC_AUTH_TT or AUTH_CODE — never CUSTOMIZED_USER, which is a Custom Identity and cannot carry one. THIS MATTERS BEYOND STYLE: TikTok is phasing Custom Identity out — ad accounts created on or after January 15, 2026 cannot create non-Spark ads at all, and existing accounts can no longer create them either, for any ad group delivering to Automatic or Select Placement with TikTok included (only Pangle / Global App Bundle campaigns are unaffected). Defaults: adFormat SINGLE_VIDEO, callToAction LEARN_MORE. THE STATUS IS READ BACK FROM TIKTOK’S OWN STORED ROW, never assumed: if it comes back anything other than DISABLE the note is a ⚠ warning that the ad would serve the moment its campaign is enabled — print that verbatim and act on it before touching anything above it.",
      "group": "ads",
      "section": "AD COMMENTS · BLOCKED WORDS · AD DIAGNOSIS · BRAND SAFETY (2026-08-19, wave 2)",
      "annotations": {
        "title": "Create a TikTok ad",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "adgroupId": {
            "type": "string",
            "description": "the ad group whose targeting, budget and schedule this ad runs under"
          },
          "name": {
            "type": "string",
            "description": "the ad name in TikTok Ads Manager — not the copy people see"
          },
          "identityId": {
            "type": "string",
            "description": "REQUIRED — from list_tiktok_ads_identities; the TikTok account the ad posts as"
          },
          "identityType": {
            "type": "string",
            "description": "REQUIRED — the matching type reported next to that id by list_tiktok_ads_identities. There is NO default and guessing is refused: the plausible-looking CUSTOMIZED_USER is wrong for a plain TikTok account, which is TT_USER."
          },
          "adFormat": {
            "description": "default SINGLE_VIDEO",
            "type": "string"
          },
          "videoId": {
            "description": "the videoId returned by upload_tiktok_ads_creative (or a video already in the advertiser’s TikTok asset library)",
            "type": "string"
          },
          "tiktokItemId": {
            "description": "SPARK ADS — promote an EXISTING organic TikTok post instead of an uploaded video. From list_tiktok_ads_identity_posts (an account you control) or list_tiktok_ads_spark_posts (a creator post authorised by code). Mutually exclusive with videoId, and NOT supported with identityType CUSTOMIZED_USER; both are refused here for free.",
            "type": "string"
          },
          "identityAuthorizedBcId": {
            "description": "REQUIRED when identityType is BC_AUTH_TT — the Business Center id reported next to that identity by list_tiktok_ads_identities",
            "type": "string"
          },
          "imageIds": {
            "description": "for a video ad, the ONE cover image: pass [coverImageId] from the same upload_tiktok_ads_creative call. For the image formats, the images themselves.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "adText": {
            "description": "the ad copy people read",
            "type": "string"
          },
          "callToAction": {
            "description": "TikTok’s CTA button value — default LEARN_MORE",
            "type": "string"
          },
          "landingPageUrl": {
            "description": "where the ad sends people",
            "type": "string"
          }
        },
        "required": [
          "adgroupId",
          "name",
          "identityId",
          "identityType"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_ads_rule",
      "title": "Create a TikTok automated rule (created turned OFF)",
      "description": "AUTOMATED RULES — a standing instruction TikTok runs on the user’s ad account on TIKTOK’S schedule, with nobody watching. WHAT A RULE CAN DO UNATTENDED, depending on its actions: pause a campaign / ad group / ad (TURN_OFF), email a report (MESSAGE), LOWER a daily budget, lifetime budget or bid (DECREASE) — or TURN AN OBJECT BACK ON and RAISE a budget or bid (TURN_ON, INCREASE, ADJUST_TO). The second group is standing permission to spend the user’s money without anyone present, on objects the rule re-selects at every execution, so treat it as such and say so before creating one. EVERY RULE IS CREATED TURNED OFF AND READ BACK TO PROVE IT: TikTok publishes no status field on its create and its own example shows a fresh rule running, so Hermoso creates it and disarms it in a second call — and if that second call fails the reply says LOUDLY that a live rule is on the account and how to switch it off. ARMING IS A SEPARATE ACT (set_tiktok_ads_rule_status), which is what makes the rule inspectable in between. An INCREASE or DECREASE must carry value.limit; TikTok requires it and it IS the safety rail — an increase without a ceiling ratchets upward every time the rule fires. TWO THINGS TIKTOK ITSELF PUBLISHES ABOUT THIS ENDPOINT: it is \"supported exclusively for direct advertisers\" and says SaaS platforms managing multiple advertisers cannot use it, so it may simply be refused for this account — that is THEIR restriction, not a broken connection and no reconnect changes it; and every notification it generates goes to the developer address registered on the app rather than to the user, so tiktok_ads_rule_results is the only place they will see what a rule did. Free.",
      "group": "ads",
      "section": "AUTOMATED RULES (2026-08-19)",
      "annotations": {
        "title": "Create a TikTok automated rule (created turned OFF)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "what this rule is called in TikTok Ads Manager — up to 512 characters"
          },
          "applyObjects": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "dimension": {
                  "type": "string",
                  "enum": [
                    "CAMPAIGN",
                    "ADGROUP",
                    "AD"
                  ]
                },
                "preConditionType": {
                  "type": "string",
                  "description": "SELECTED names a FIXED set and requires dimensionIds. Every other value (ALL_ACTIVE_CAMPAIGN, ALL_ACTIVE_AD_GROUP, ALL_ACTIVE_AD, ALL_ACTIVE_AD_GROUP_UNDER_SELECTED, ALL_ACTIVE_AD_UNDER_SELECTED and their ALL_INACTIVE_ twins) is RE-RESOLVED BY TIKTOK AT EVERY RUN, so objects created after today fall inside it."
                },
                "dimensionIds": {
                  "description": "required when preConditionType is SELECTED",
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              },
              "required": [
                "dimension",
                "preConditionType"
              ]
            },
            "description": "what the rule watches"
          },
          "conditions": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "subjectType": {
                  "type": "string",
                  "description": "COST, CPA, CPC, CPM, CTR, CVR, CONVERSION, IMPRESSION, CLICK, RESULT, COST_PER_RESULT, DAILY_BUDGET_SPENDING_RATE, ROAS_PURCHASE, DAYS_SINCE_CREATION, NAME … or NO_CONDITION, which TikTok itself warns about because it matches everything, every run"
                },
                "rangeType": {
                  "description": "TODAY | YESTERDAY | PAST_THREE_DAYS | PAST_FIVE_DAYS | PAST_SEVEN_DAYS | LIFETIME",
                  "type": "string"
                },
                "matchType": {
                  "description": "GT | LT | BETWEEN | MATCH — plus CONTAINS / NOT_CONTAINS / START_WITH / END_WITH / STRING_EQUAL, which TikTok allows ONLY when subjectType is NAME",
                  "type": "string"
                },
                "values": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  },
                  "description": "BETWEEN takes exactly two"
                },
                "calculationType": {
                  "description": "ALL_OBJECTS | OF_EACH_OBJECT — COST only",
                  "type": "string"
                }
              },
              "required": [
                "subjectType",
                "values"
              ]
            },
            "description": "when it fires"
          },
          "actions": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "subjectType": {
                  "type": "string",
                  "enum": [
                    "TURN_ON",
                    "TURN_OFF",
                    "MESSAGE",
                    "DAILY_BUDGET",
                    "LIFETIME_BUDGET",
                    "BID"
                  ],
                  "description": "TURN_OFF, MESSAGE and a DECREASE cannot start or raise spend. TURN_ON, INCREASE and ADJUST_TO can."
                },
                "actionType": {
                  "description": "required for DAILY_BUDGET / LIFETIME_BUDGET / BID. ADJUST_TO is treated as spend-increasing because it is only a decrease for objects currently above the target, which the rule does not read until it fires.",
                  "type": "string",
                  "enum": [
                    "INCREASE",
                    "DECREASE",
                    "ADJUST_TO"
                  ]
                },
                "valueType": {
                  "type": "string",
                  "enum": [
                    "EXACT",
                    "PERCENT"
                  ]
                },
                "value": {
                  "description": "{ value, limit } — limit is REQUIRED for INCREASE and DECREASE and is the ceiling (INCREASE) or floor (DECREASE) the rule may never cross",
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {}
                },
                "frequencyInfo": {
                  "description": "{ type: ONLY_ONCE | ONCE_IN_24_H | ONCE_IN_48_H | ONCE_IN_1_W | CUSTOM, customFrequencyType, time, count } — how often a budget/bid action may repeat",
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {}
                }
              },
              "required": [
                "subjectType"
              ]
            },
            "description": "what it does when the conditions match"
          },
          "notification": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {},
            "description": "{ notificationType: TASK_FINISH | ANY_CHANGES | NOT_NOTIFICATION, emailSetting } — note these emails go to the developer address on the app, not to the user"
          },
          "ruleExecInfo": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {},
            "description": "{ execTimeType: PER_HALF_HOUR | CUSTOM | HALF_HOUR_IN_SPECIFIC_TIME_PERIOD | SPECIFIC_TIME_ACCURATE_ONCE, execTime, timePeriodInfo } — PER_HALF_HOUR runs every thirty minutes forever"
          },
          "tzone": {
            "type": "string"
          }
        },
        "required": [
          "name",
          "applyObjects",
          "conditions",
          "actions",
          "notification",
          "ruleExecInfo"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_tiktok_ads_rule",
      "title": "Replace a TikTok automated rule’s definition",
      "description": "Replace an automated rule’s WHOLE definition. TikTok’s update is a full replace rather than a patch, so send every field the rule should keep — anything omitted is gone. IF THE RULE IS CURRENTLY RUNNING AND THE NEW VERSION CAN START OR RAISE SPEND, this arms it the moment it saves, so it takes the same confirm:true plus confirmScope echo as arming one: an edit must never be a route by which a harmless rule becomes a spending rule behind a gate it passed when it meant something else. A rule that is currently OFF needs no gate here, because arming it later runs the gate against the new definition. Free.",
      "group": "ads",
      "section": "AUTOMATED RULES (2026-08-19)",
      "annotations": {
        "title": "Replace a TikTok automated rule’s definition",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "ruleId": {
            "type": "string",
            "description": "from list_tiktok_ads_rules"
          },
          "name": {
            "type": "string"
          },
          "applyObjects": {
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            },
            "description": "same shape as create_tiktok_ads_rule"
          },
          "conditions": {
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "actions": {
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            }
          },
          "notification": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "ruleExecInfo": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "tzone": {
            "type": "string"
          },
          "confirm": {
            "description": "required when the rule is live and the new version can spend",
            "type": "boolean"
          },
          "confirmScope": {
            "description": "required in the same case — the scope token the refusal prints, computed from the version being sent",
            "type": "string"
          }
        },
        "required": [
          "ruleId",
          "name",
          "applyObjects",
          "conditions",
          "actions",
          "notification",
          "ruleExecInfo"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_rules",
      "title": "List TikTok automated rules, classified by whether they can spend",
      "description": "THE AUTOMATED RULES RUNNING ON A TIKTOK AD ACCOUNT, and — the part that matters — which of them can move money. Every row carries canSpend, the specific actions that put it there (armingActions), whether its reach is UNBOUNDED (a rule targeting \"all active campaigns\" re-resolves that set at every run, so it covers campaigns created after it was written), and a scopeToken. That token is what set_tiktok_ads_rule_status wants echoed back before it will arm a spending rule, and it is computed from the rule AS TIKTOK STORES IT — so it also detects a rule that has been edited in TikTok Ads Manager since it was read. The reply leads with the rules that are armed AND able to spend, because \"what is running on my account that I am not watching\" is the actual question. Pass ruleIds for specific rules, or filter by status / dimension / name. Read-only, free.",
      "group": "ads",
      "section": "AUTOMATED RULES (2026-08-19)",
      "annotations": {
        "title": "List TikTok automated rules, classified by whether they can spend",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "ruleIds": {
            "description": "specific rules — omit to list them all",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "status": {
            "type": "string",
            "enum": [
              "ON",
              "OFF",
              "DELETED"
            ]
          },
          "dimension": {
            "type": "string",
            "enum": [
              "CAMPAIGN",
              "ADGROUP",
              "AD"
            ]
          },
          "ruleInfo": {
            "description": "rule ids or rule names to filter by",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "tzone": {
            "type": "string"
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_ads_rule_results",
      "title": "What TikTok’s automated rules actually did",
      "description": "EVERY EXECUTION OF THE USER’S AUTOMATED RULES — when each ran, what it acted on and what it changed. THIS IS THE ONLY PLACE THEY WILL SEE IT: TikTok routes rule notification emails to the developer address registered on the app rather than to the advertiser, so an armed rule can pause campaigns or raise budgets on their account and nothing reaches their inbox. Call it with no ids to list executions (optionally filtered by rule, action, dimension or a UTC time window); pass ruleId AND execId TOGETHER, both taken from one row of that list, for the detail of a single run — TikTok keys a result on the pair, so half of one is refused rather than answered about the wrong thing. Read-only, free.",
      "group": "ads",
      "section": "AUTOMATED RULES (2026-08-19)",
      "annotations": {
        "title": "What TikTok’s automated rules actually did",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "ruleId": {
            "description": "for the DETAIL of one run — must be sent with execId from the same row",
            "type": "string"
          },
          "execId": {
            "description": "for the DETAIL of one run — must be sent with ruleId from the same row",
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "ON",
              "OFF",
              "DELETED"
            ]
          },
          "action": {
            "description": "filter to one kind of action — use this to answer \"has anything raised my budgets?\"",
            "type": "string",
            "enum": [
              "TURN_ON",
              "TURN_OFF",
              "MESSAGE",
              "DAILY_BUDGET",
              "LIFETIME_BUDGET",
              "BID"
            ]
          },
          "ruleInfo": {
            "description": "rule ids or names",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "time": {
            "description": "exactly two UTC datetimes: start and end",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "dimension": {
            "type": "string",
            "enum": [
              "CAMPAIGN",
              "ADGROUP",
              "AD"
            ]
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_tiktok_ads_rule_status",
      "title": "Arm, disarm or delete a TikTok automated rule",
      "description": "TURN_ON IS THE SWITCH THAT HANDS A RULE TO TIKTOK TO RUN UNATTENDED, and it is gated in two classes because the two are not the same act. A rule whose only actions are TURN_OFF, MESSAGE or a DECREASE cannot start or raise spend — the worst it does is under-deliver, which is recoverable by turning it off — so it needs confirm:true and nothing else. A rule that can TURN_ON an object or INCREASE / ADJUST_TO a budget or bid is standing permission to spend real money with nobody present, so it needs confirm:true AND confirmScope set to the scopeToken that list_tiktok_ads_rules prints. That token is computed from the rule AS TIKTOK STORES IT: confirm:true proves the caller meant to arm SOMETHING, and only the echo proves they aimed at the rule they actually inspected rather than one a teammate has edited since. CALLING WITHOUT THE GATE CHANGES NOTHING and returns the sentence describing exactly what the rule will be able to do — show the user that, get an unambiguous yes, then confirm. TURN_OFF and DELETE only ever reduce what runs unattended, so both take confirm:true and no echo; prefer TURN_OFF, because TikTok publishes no way to restore a deleted rule and turning one off is reversible. THE ANSWER IS THE READ-BACK: the result carries the status TikTok stored per rule, and says so when it could not confirm. Free.",
      "group": "ads",
      "section": "AUTOMATED RULES (2026-08-19)",
      "annotations": {
        "title": "Arm, disarm or delete a TikTok automated rule",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "ruleIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "the rules to change"
          },
          "operateType": {
            "type": "string",
            "enum": [
              "TURN_ON",
              "TURN_OFF",
              "DELETE"
            ],
            "description": "TURN_ON = start running it unattended; TURN_OFF = stop, reversible; DELETE = remove, NOT reversible"
          },
          "confirm": {
            "description": "REQUIRED true for every operation — call without it first to see what would happen",
            "type": "boolean"
          },
          "confirmScope": {
            "description": "REQUIRED as well, to arm a rule that can start or raise spend: the scopeToken from list_tiktok_ads_rules. The refusal prints the value it wants.",
            "type": "string"
          }
        },
        "required": [
          "ruleIds",
          "operateType"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "bind_tiktok_ads_rule",
      "title": "Put objects under a TikTok automated rule, or take them out",
      "description": "Bind campaigns, ad groups or ads to an EXISTING automated rule, or unbind them from one. BINDING TO A RULE THAT IS RUNNING AND ABLE TO SPEND puts those objects under standing permission to have their budget or bid raised, which is the same act as arming the rule for them — so it takes the same confirm:true plus confirmScope echo, computed from the rule as TikTok stores it. UNBINDING only ever narrows a rule’s reach and needs neither. TikTok answers a bind with an empty body, so the result is read back off the rule rather than taken from the 200. Free.",
      "group": "ads",
      "section": "AUTOMATED RULES (2026-08-19)",
      "annotations": {
        "title": "Put objects under a TikTok automated rule, or take them out",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "ruleId": {
            "type": "string",
            "description": "from list_tiktok_ads_rules"
          },
          "dimension": {
            "type": "string",
            "enum": [
              "CAMPAIGN",
              "ADGROUP",
              "AD"
            ]
          },
          "dimensionIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "the objects to bind or unbind"
          },
          "bindType": {
            "type": "string",
            "enum": [
              "BIND",
              "UNBIND"
            ]
          },
          "confirm": {
            "description": "required to BIND onto a live rule that can spend",
            "type": "boolean"
          },
          "confirmScope": {
            "description": "required in the same case — the scopeToken from list_tiktok_ads_rules",
            "type": "string"
          }
        },
        "required": [
          "ruleId",
          "dimension",
          "dimensionIds",
          "bindType"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_offline_event_sets",
      "title": "List TikTok Offline Event sets",
      "description": "The Offline Event sets on a TikTok ad account — the containers real-world conversions are reported into: an in-store purchase, a phone booking, a signed contract, a call-centre sale. An id here is what send_tiktok_ads_offline_events reports into, and what send_tiktok_ads_events wants as eventSourceId when eventSource is \"offline\". The reply also counts how many are AUTO-TRACKING, which matters because an auto-tracking set is attached to every campaign created afterwards and TikTok caps an advertiser at ten of them. Read-only, free.",
      "group": "ads",
      "section": "OFFLINE EVENTS + CRM EVENT SETS (2026-08-19)",
      "annotations": {
        "title": "List TikTok Offline Event sets",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "eventSetIds": {
            "description": "filter to specific sets",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "name": {
            "description": "filter by exact name",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_tiktok_ads_offline_event_set",
      "title": "Create, rename or delete a TikTok Offline Event set",
      "description": "Create, rename or delete an Offline Event set — the container in-store and other real-world conversions are reported into. AUTOTRACKING IS A STANDING SETTING RATHER THAN A PROPERTY OF THIS SET: with it on, EVERY campaign created under this advertiser afterwards attributes to it automatically, including campaigns nobody has thought of yet. TikTok caps an advertiser at ten auto-tracking sets, and once that is reached a non-auto-tracking set cannot be switched over until one is deleted — so leave it off unless the user means it. DELETING is confirm-gated, and not because of the container: every conversion already reported into it stops being available to reporting and optimisation, campaigns tracking it lose their attribution, and TikTok publishes no undelete. A create is READ BACK from TikTok, because the create response is only an id and never the stored row. Free.",
      "group": "ads",
      "section": "OFFLINE EVENTS + CRM EVENT SETS (2026-08-19)",
      "annotations": {
        "title": "Create, rename or delete a TikTok Offline Event set",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "action": {
            "type": "string",
            "enum": [
              "create",
              "update",
              "delete"
            ]
          },
          "eventSetId": {
            "description": "required for update and delete — from list_tiktok_ads_offline_event_sets",
            "type": "string"
          },
          "name": {
            "description": "required for create; max 40 characters, and TikTok refuses a name already used on the account",
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "autoTracking": {
            "description": "attach EVERY future campaign on this advertiser to this event set. Max ten per advertiser. Off unless you mean it.",
            "type": "boolean"
          },
          "confirm": {
            "description": "REQUIRED true to delete",
            "type": "boolean"
          }
        },
        "required": [
          "action"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "send_tiktok_ads_offline_events",
      "title": "Report in-store and other real-world conversions to TikTok",
      "description": "REPORT REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract — so TikTok can attribute them to the ads that caused them and optimise delivery toward them. This is what makes a TikTok campaign measurable for a business whose sale does not happen on a website. Emails and phone numbers are normalized and SHA-256 hashed by Hermoso exactly as TikTok specifies before anything leaves the process, using the SAME implementation as send_tiktok_ads_events; a value that is already a 64-character hash is passed through untouched, and a phone with no \"+\" country code is refused rather than guessed. EITHER emails OR phone_numbers is REQUIRED on every event — an offline conversion identifying nobody is attributed to nothing. ⚠ THE TIMESTAMP IS AN ISO-8601 STRING HERE (\"2026-08-19T19:11:01Z\"), NOT the Unix-seconds NUMBER that send_tiktok_ads_events takes: TikTok accepts a wrong-shaped one, reads it as some other date and attributes the conversion to nothing, so it is refused here for free. ⚠ THERE IS NO TEST MODE: TikTok documents test_event_code on Events API 2.0 and on NEITHER offline endpoint, so everything sent here is a real, permanent conversion that no endpoint deletes. To rehearse the pipeline first, send the same events through send_tiktok_ads_events with eventSource \"offline\", the same event set id and a testEventCode from Events Manager — those land in the Test Events tab and are excluded from reporting, attribution and optimisation. ⚠ REPORTING NEEDS A ROLE, NOT JUST A CONNECTION: TikTok requires the connected user to be an ADMIN or OPERATOR of that advertiser account, measured live — the same token can create and delete Offline Event SETS and still be refused on the events themselves. If that happens Hermoso says so plainly rather than telling anyone to reconnect, because reconnecting cannot grant a role; it has to change in TikTok Business Center. Free.",
      "group": "ads",
      "section": "OFFLINE EVENTS + CRM EVENT SETS (2026-08-19)",
      "annotations": {
        "title": "Report in-store and other real-world conversions to TikTok",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "eventSetId": {
            "type": "string",
            "description": "the Offline Event Set these belong to — from list_tiktok_ads_offline_event_sets"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "event": {
                  "type": "string",
                  "description": "one of TikTok’s supported offline events, CASE SENSITIVE: Purchase, Contact, Subscribe, Lead, AddPaymentInfo, AddToCart, ApplicationApproval, AddToWishlist, CompleteRegistration, Download, InitiateCheckout, Search, ViewContent, StartTrial, SubmitApplication, CustomizeProduct, FindLocation, Schedule. TikTok publishes a CLOSED list here and no custom-event mechanism, unlike web events."
                },
                "timestamp": {
                  "type": "string",
                  "description": "ISO-8601 STRING, e.g. \"2026-08-19T19:11:01Z\" — NOT a Unix number"
                },
                "eventId": {
                  "description": "your own unique id for this event, so it can be matched between your system and TikTok",
                  "type": "string"
                },
                "user": {
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {},
                  "description": "{ emails: [...], phone_numbers: [...] } — at least one is REQUIRED. Pass them in the clear and Hermoso hashes them, or pass SHA-256 hashes you already hold."
                },
                "properties": {
                  "description": "{ value, currency, order_id, shop_id, contents: [{content_id, content_name, price, quantity}], event_channel: in_store | phone_call | email | website | crm | other }. A value with no currency is refused — TikTok marks both required for revenue reporting, and a figure with no unit is a wrong number rather than a missing one.",
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {}
                }
              },
              "required": [
                "event",
                "timestamp",
                "user"
              ]
            },
            "description": "one event goes to TikTok’s single endpoint, several to its bulk one — Hermoso picks"
          }
        },
        "required": [
          "eventSetId",
          "events"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_crm_event_sets",
      "title": "List TikTok CRM Event sets",
      "description": "The CRM Event sets on a TikTok ad account. A CRM Event Set is where LEAD-LIFECYCLE events go — a lead that became qualified, booked a demo, or closed — and sending those back with send_tiktok_ads_events (eventSource \"crm\", eventSourceId = the set’s id) is what lets TikTok optimise a LEAD_GENERATION campaign toward leads that actually convert rather than toward form fills. An advertiser is capped at fifty sets. Read-only, free.",
      "group": "ads",
      "section": "OFFLINE EVENTS + CRM EVENT SETS (2026-08-19)",
      "annotations": {
        "title": "List TikTok CRM Event sets",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "eventSetIds": {
            "description": "filter to specific sets — TikTok accepts at most 50 ids",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "name": {
            "description": "filter by exact name",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_ads_crm_event_set",
      "title": "Create a TikTok CRM Event set",
      "description": "Create a CRM Event set — the container for lead-lifecycle events, and the id send_tiktok_ads_events needs as eventSourceId when eventSource is \"crm\". Making one is how a LEAD_GENERATION campaign stops optimising toward form fills and starts optimising toward leads that qualify and close. ⚠ TIKTOK PUBLISHES CREATE AND LIST FOR THESE AND NOTHING ELSE — no update and no delete anywhere in its API reference — so a set made here is PERMANENT, in the same way a TikTok pixel is, and an advertiser is capped at fifty with no API way to free a slot. Name it something the user will still recognise in a year. Free.",
      "group": "ads",
      "section": "OFFLINE EVENTS + CRM EVENT SETS (2026-08-19)",
      "annotations": {
        "title": "Create a TikTok CRM Event set",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "max 40 characters; TikTok trims it and refuses a duplicate. There is no way to rename or delete it afterwards."
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_tiktok_ads_status",
      "title": "Activate, pause or delete TikTok campaigns / ad groups / ads",
      "description": "THE ONE SWITCH THAT ARMS REAL MONEY ON TIKTOK. Pass level (\"campaign\", \"adgroup\" or \"ad\"), the ids, and a status: ENABLE starts real spend on the next auction, DISABLE stops it, DELETE removes the objects (TikTok models removal as a STATUS — it publishes no delete verb — which is why delete_tiktok_ads_object is a thin wrapper over this same route). EVERY status change here needs confirm:true, and CALLING IT WITHOUT confirm CHANGES NOTHING and hands back the sentence describing exactly what would happen — show the user that, get an unambiguous yes, then confirm. EVERY TIER HAS TO BE ENABLED FOR AN IMPRESSION TO SERVE: Hermoso creates all three paused, so enabling the campaign alone does nothing while its ad group and ad are still disabled, and each level is a separate call. Ids can be passed in bulk, but TikTok’s QPS is 1 and calls are serialized, so a long list is simply slow. THE ANSWER IS THE READ-BACK: the result carries what TikTok STORED per id, plus a note when the read-back did not return every id — repeat that rather than the status you asked for.",
      "group": "ads",
      "section": "OFFLINE EVENTS + CRM EVENT SETS (2026-08-19)",
      "annotations": {
        "title": "Activate, pause or delete TikTok campaigns / ad groups / ads",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "level": {
            "type": "string",
            "enum": [
              "campaign",
              "adgroup",
              "ad"
            ],
            "description": "which tier the ids belong to"
          },
          "ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "the campaign / ad group / ad ids to change"
          },
          "status": {
            "type": "string",
            "enum": [
              "ENABLE",
              "DISABLE",
              "DELETE"
            ],
            "description": "ENABLE = start real spend; DISABLE = stop; DELETE = remove (TikTok has no delete verb, removal is a status)"
          },
          "confirm": {
            "description": "REQUIRED true — call without it first to see exactly what would change",
            "type": "boolean"
          }
        },
        "required": [
          "level",
          "ids",
          "status"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_tiktok_ads_budget",
      "title": "Change a TikTok campaign or ad group budget",
      "description": "Change the budget on a TikTok campaign (level:\"campaign\") or ad group (level:\"adgroup\"), in the advertiser’s own currency. It needs confirm:true, and CALLING IT WITHOUT confirm CHANGES NOTHING and returns the sentence naming the object and the new amount — show the user that first. Everything Hermoso creates on TikTok is forced paused, so a budget change on one of those spends nothing; on an object somebody has ENABLED it takes effect on the next auction, and LOWERING the budget is the fastest way to slow real money down short of set_tiktok_ads_status(status:\"DISABLE\"). The new budget is READ BACK from TikTok — report what it returns, not what you sent, and say so plainly when the read-back could not confirm it.",
      "group": "ads",
      "section": "OFFLINE EVENTS + CRM EVENT SETS (2026-08-19)",
      "annotations": {
        "title": "Change a TikTok campaign or ad group budget",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "level": {
            "description": "which tier holds the budget — default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "adgroup"
            ]
          },
          "id": {
            "type": "string",
            "description": "the campaign or ad group id"
          },
          "budget": {
            "type": "number",
            "description": "the new budget in the advertiser’s own currency — must be above zero"
          },
          "budgetMode": {
            "description": "default BUDGET_MODE_DAY",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true — call without it first to see exactly what would change",
            "type": "boolean"
          }
        },
        "required": [
          "id",
          "budget"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_tiktok_ads_object",
      "title": "Delete TikTok campaigns / ad groups / ads",
      "description": "Remove TikTok campaigns, ad groups or ads. TIKTOK HAS NO DELETE VERB — removal is modelled as a STATUS, exactly like Reddit — so this posts to the same route as set_tiktok_ads_status with status:\"DELETE\" rather than being a second implementation with its own rules. It is permanent, TikTok publishes no undelete, and it needs confirm:true; calling it WITHOUT confirm changes nothing and returns the sentence naming how many objects go, which is what you show the user first. TikTok does NOT document whether removing a campaign takes its ad groups and ads with it — each carries its own status — so remove the children yourself if they should go too, and VERIFY with list_tiktok_ads_campaigns afterwards rather than assuming a cascade either way. TO STOP DELIVERY WITHOUT DESTROYING ANYTHING, use set_tiktok_ads_status(status:\"DISABLE\"): that is reversible and this is not.",
      "group": "ads",
      "section": "OFFLINE EVENTS + CRM EVENT SETS (2026-08-19)",
      "annotations": {
        "title": "Delete TikTok campaigns / ad groups / ads",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "level": {
            "type": "string",
            "enum": [
              "campaign",
              "adgroup",
              "ad"
            ],
            "description": "which tier the ids belong to"
          },
          "ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "the campaign / ad group / ad ids to remove"
          },
          "confirm": {
            "description": "REQUIRED true — the removal is permanent",
            "type": "boolean"
          }
        },
        "required": [
          "level",
          "ids"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_tto_accounts",
      "title": "List the TikTok One (Creator Marketplace) accounts on this connection",
      "description": "The TikTok One accounts this TikTok connection can act on. TikTok One is TikTok’s influencer marketplace: find creators, invite them to a campaign, get their videos tagged to it, read organic-versus-paid performance on those videos, and ask them for Spark Ads authorization so the brand can put money behind their post. THIS IS THE ONE PIECE OF STRUCTURE THAT MAKES THE CREATOR TOOLS DIFFERENT: a TikTok One account id is a THIRD id space beside an advertiser id and a Business Center id, and every creator-marketplace tool needs one from here. With exactly one reachable the other tools resolve it themselves; with several they refuse and name them. It rides the SAME connection and the SAME OAuth flow as TikTok Ads and there is nothing extra to apply for; but a connection authorized before 2026-08-19 does not carry the TikTok One permission and its owner has to reconnect. Read-only, free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "List the TikTok One (Creator Marketplace) accounts on this connection",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_creator_labels",
      "title": "List TikTok creator category labels",
      "description": "TikTok’s creator category labels, in two sets that are NOT interchangeable. labelType \"SEARCH\" gives the tags discover_tiktok_creators filters on: contentLabelIds is what a creator POSTS about, industryLabelIds the commercial categories they have actually worked in. labelType \"RANKING\" gives the labels tiktok_creator_leaderboard needs, and there the pairing is strict: BRANDED_CONTENT ranks by an INDUSTRY label, ORGANIC_CONTENT by a CONTENT label. Both arrive as bare numeric ids that look identical, and TikTok reports a mismatch as an invalid field rather than as the wrong kind of label, so read this first rather than guessing. Read-only, free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "List TikTok creator category labels",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "description": "from list_tiktok_tto_accounts: omit only when exactly one is reachable",
            "type": "string"
          },
          "labelType": {
            "description": "default SEARCH",
            "type": "string",
            "enum": [
              "SEARCH",
              "RANKING"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "discover_tiktok_creators",
      "title": "Search TikTok One for creators to work with",
      "description": "Search TikTok One for creators, filtered by audience size, engagement rate, median and average views, starting price, language, content and industry category, and by their FOLLOWERS’ country, gender split and age band. FOUR THINGS THAT ARE EASY TO GET WRONG AND ARE ALL REFUSED FOR FREE BEFORE THE CALL: countryCodes is required; every country in ONE search must come from the same regional category, which TikTok defines as the US, Europe (DE/ES/FR/GB/IT) and everywhere else, so a US+UK search has to be two searches; engagement rates are 0-1 rather than percentages, so 5% is 0.05; and stateProvinces works only when the country is exactly US. THIS IS NOT A TIKTOK SEARCH: it sees only creators who have JOINED the Creator Marketplace, so a narrow filter set empties fast and the fix is to widen the follower or engagement range rather than to conclude the category is empty. Profile image URLs carry their own expiry, so show them and do not store them. Read-only, free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "Search TikTok One for creators to work with",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "countryCodes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "REQUIRED, and all from ONE regional category: US | Europe (DE ES FR GB IT) | other (AE AR AU BR CA CO EG ID IL JP KR MX MY PH SA SG TH TR TW VN)"
          },
          "stateProvinces": {
            "description": "US only, and only when countryCodes is exactly [\"US\"]",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "keywordSearch": {
            "description": "fuzzy match, ≤100 characters",
            "type": "string"
          },
          "contentLabelIds": {
            "description": "what the creator posts about: from list_tiktok_creator_labels(labelType:\"SEARCH\")",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "industryLabelIds": {
            "description": "commercial categories they have worked in: same source",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "languages": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "minFollowers": {
            "type": "number"
          },
          "maxFollowers": {
            "type": "number"
          },
          "minEngagementRate": {
            "description": "0-1, NOT a percentage",
            "type": "number"
          },
          "maxEngagementRate": {
            "type": "number"
          },
          "minMedianViews": {
            "type": "number"
          },
          "maxMedianViews": {
            "type": "number"
          },
          "minAvgViews": {
            "type": "number"
          },
          "maxAvgViews": {
            "type": "number"
          },
          "minCreatorPrice": {
            "description": "the creator’s starting price, USD",
            "type": "number"
          },
          "maxCreatorPrice": {
            "type": "number"
          },
          "followerCountryCodes": {
            "description": "where their AUDIENCE is, which is often not where they are",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "followerGenderRatio": {
            "type": "string",
            "enum": [
              "FEMALE_50",
              "FEMALE_60",
              "FEMALE_70",
              "MALE_50",
              "MALE_60",
              "MALE_70"
            ]
          },
          "followerAge": {
            "type": "string",
            "enum": [
              "18-24",
              "25-34",
              "35-44",
              "45-54",
              "55+"
            ]
          },
          "sortField": {
            "type": "string",
            "enum": [
              "RELEVANCE",
              "FOLLOWERS",
              "MEDIAN_VIEWS",
              "ENGAGEMENT_RATE"
            ]
          },
          "sortOrder": {
            "type": "string",
            "enum": [
              "ASC",
              "DESC"
            ]
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "1-200, default 24",
            "type": "number"
          }
        },
        "required": [
          "countryCodes"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_creator_leaderboard",
      "title": "Read TikTok’s Creator Leaderboard",
      "description": "The creators ranking highest on TikTok’s own Creator Leaderboard for one category over one week or month; up to 100, best first, with how far each moved since the previous period. US ONLY: TikTok publishes ranking data for no other country. labelId must MATCH rankingType: BRANDED_CONTENT takes an INDUSTRY label and ORGANIC_CONTENT takes a CONTENT label, both from list_tiktok_creator_labels(labelType:\"RANKING\"): and the mismatch is refused here rather than at TikTok, which reports it only as an invalid field. A leaderboard is a SNAPSHOT taken when the rank was generated, so TikTok warns a creator may still appear under a label they have since lost. Read-only, free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "Read TikTok’s Creator Leaderboard",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "rankingType": {
            "type": "string",
            "enum": [
              "BRANDED_CONTENT",
              "ORGANIC_CONTENT"
            ],
            "description": "BRANDED_CONTENT needs an industry label, ORGANIC_CONTENT a content label"
          },
          "timePeriod": {
            "type": "string",
            "enum": [
              "WEEK",
              "MONTH"
            ]
          },
          "lookback": {
            "type": "string",
            "enum": [
              "ONE",
              "TWO",
              "THREE"
            ],
            "description": "ONE is the most recent completed period"
          },
          "labelId": {
            "type": "string",
            "description": "from list_tiktok_creator_labels(labelType:\"RANKING\"), matching rankingType"
          },
          "countryCode": {
            "description": "US: the only country TikTok ranks",
            "type": "string"
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "1-100, default 20",
            "type": "number"
          }
        },
        "required": [
          "rankingType",
          "timePeriod",
          "lookback",
          "labelId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "check_tiktok_creator_status",
      "title": "Check whether TikTok handles have joined TikTok One",
      "description": "Whether particular TikTok handles have joined TikTok One, up to 20 at a time, with each answered IN (can be invited), NOT_IN (a real account that has not joined) or INVALID (not a handle TikTok knows). WORTH CALLING BEFORE INVITING ANYONE: a creator who has not joined cannot be invited, and TikTok reports that as a 200 with the handle in a failed list rather than as an error; so an unchecked invite looks like it worked and quietly reached nobody. Read-only, free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "Check whether TikTok handles have joined TikTok One",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "handles": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "TikTok usernames WITHOUT the @, max 20"
          }
        },
        "required": [
          "handles"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_tto_brand_profiles",
      "title": "List TikTok One brand profiles",
      "description": "The Brand Profiles on a TikTok One account. A Brand Profile is what a creator sees when they open an invite link; name, industry, logo, website and optionally the brand’s own TikTok account; and a brand-level campaign cannot exist without one. Read-only, free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "List TikTok One brand profiles",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "brandProfileIds": {
            "description": "filter, max 20",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_tto_brand_profile",
      "title": "Create a TikTok One brand profile",
      "description": "Create a Brand Profile on a TikTok One account; the identity creators see when they open an invite link. THE NAME, INDUSTRY AND LOGO ARE PERMANENT: TikTok marks all three \"cannot be updated\" and publishes no brand-profile update endpoint anywhere in its API, so a profile is authored exactly once and a typo has to be lived with. Confirm all three with the user before calling. The logo must be a 1:1 image under 5MB at a publicly reachable URL, and the website must start with https. Adding tiktokAccountUrl lets creators see the brand’s own TikTok account on the profile, which TikTok says improves how many of them take the invitation seriously. Free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "Create a TikTok One brand profile",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "brandName": {
            "type": "string",
            "description": "PERMANENT: ≤60 characters"
          },
          "brandIndustryId": {
            "type": "string",
            "description": "PERMANENT: TikTok’s Brand Profile industry id"
          },
          "brandWebsite": {
            "type": "string",
            "description": "must start with https://"
          },
          "logoUrl": {
            "type": "string",
            "description": "PERMANENT: JPG/PNG/GIF, 1:1, ≤5MB, public URL"
          },
          "tiktokAccountUrl": {
            "description": "https://www.tiktok.com/@handle",
            "type": "string"
          }
        },
        "required": [
          "brandName",
          "brandIndustryId",
          "brandWebsite",
          "logoUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_tto_campaigns",
      "title": "List TikTok One creator campaigns",
      "description": "The TikTok One creator campaigns on this account, each with its invite link, the creators invited, the videos linked so far, the ad accounts that automatically receive Spark Ads rights, and the COUNTRY CODES that tiktok_tto_campaign_report needs. TWO TIKTOK BEHAVIOURS WORTH KNOWING: it pages five at a time, which is its own maximum rather than ours, and it defaults to campaignType CAMPAIGN: so a brand-level campaign is invisible here unless you ask for BRAND_LINK explicitly. Read-only, free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "List TikTok One creator campaigns",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "campaignIds": {
            "description": "max 5",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "campaignType": {
            "description": "TikTok defaults to CAMPAIGN: ask for BRAND_LINK to see brand-level ones",
            "type": "string",
            "enum": [
              "CAMPAIGN",
              "BRAND_LINK"
            ]
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "1-5",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_tiktok_tto_campaign",
      "title": "Create a TikTok One creator campaign",
      "description": "Create a TikTok One creator campaign and get the invite link creators tag their videos with. TWO SHAPES, AND THEY NEED DIFFERENT FIELDS: campaignType \"CAMPAIGN\" invites named creators by handle and produces a campaign-level link; campaignType \"BRAND_LINK\" requires a brandProfileId, takes no handles at all, and produces one open link anyone can tag a video with. THE FIELD WITH LASTING CONSEQUENCES IS advertiserIds: naming ad accounts means every video a creator links to this campaign AUTOMATICALLY grants those accounts Spark Ads rights for sparkAdsAuthorizationDays and is synced into their creative library; that is standing permission rather than a one-off, and TikTok requires the days whenever advertiserIds is given. sendNotification puts an invitation in a real creator’s TikTok inbox and is OFF unless you ask for it; without it the creators see the invitation only if you share the link yourself. Handles TikTok rejects come back in a failed list rather than as an error, so the reply reports which ones were ACTUALLY invited rather than which ones were asked for. Free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "Create a TikTok One creator campaign",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "campaignType": {
            "description": "default CAMPAIGN",
            "type": "string",
            "enum": [
              "CAMPAIGN",
              "BRAND_LINK"
            ]
          },
          "campaignName": {
            "description": "required for CAMPAIGN; shown to creators, ≤120 characters: TikTok suggests [product name - product description]",
            "type": "string"
          },
          "campaignDescription": {
            "description": "≤1000 characters: positioning, audience, core features",
            "type": "string"
          },
          "brandProfileId": {
            "description": "required for BRAND_LINK; optional alternative to brandName on CAMPAIGN",
            "type": "string"
          },
          "brandName": {
            "description": "≤60 characters, when there is no Brand Profile",
            "type": "string"
          },
          "handleNames": {
            "description": "required for CAMPAIGN: TikTok usernames without the @, max 100",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "advertiserIds": {
            "description": "max 50: these ad accounts get AUTOMATIC Spark Ads rights on every linked video",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "sparkAdsAuthorizationDays": {
            "description": "0-365; REQUIRED when advertiserIds is given",
            "type": "number"
          },
          "anchorId": {
            "description": "from manage_tiktok_tto_anchor",
            "type": "string"
          },
          "sendNotification": {
            "description": "default false: true puts an invitation in each creator’s TikTok inbox",
            "type": "boolean"
          },
          "businessAccountHandle": {
            "description": "allowlist-only at TikTok, and non-EU only: lets creators message the brand from the invite link",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_tiktok_tto_campaign",
      "title": "Add creators or ad accounts to a TikTok One campaign",
      "description": "Add creators or ad accounts to an existing TikTok One campaign. IT ONLY EVER ADDS: TikTok appends what you pass to what the campaign already holds and publishes nothing that removes either, so there is no way to un-invite a creator or unlink an ad account through the API: the reply therefore reports what the campaign NOW HOLDS rather than what you sent. The name, description, brand and anchor cannot be changed after creation at all; TikTok’s own create endpoint accepts a campaign_id and then ignores every field except the account and the handles, which is why a rename is impossible rather than merely unsupported here. handleNames is not accepted on a BRAND_LINK campaign, because a brand-level link is open to whoever opens it. Free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "Add creators or ad accounts to a TikTok One campaign",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string"
          },
          "campaignType": {
            "description": "default CAMPAIGN",
            "type": "string",
            "enum": [
              "CAMPAIGN",
              "BRAND_LINK"
            ]
          },
          "handleNames": {
            "description": "creators to ADD: max 100, CAMPAIGN only",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "advertiserIds": {
            "description": "ad accounts to ADD: max 50",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "sendNotification": {
            "description": "default false",
            "type": "boolean"
          }
        },
        "required": [
          "campaignId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "link_tiktok_tto_video",
      "title": "Ask a creator to link a video to a TikTok One campaign",
      "description": "Ask a creator to link one of their public videos to a TikTok One campaign, or withdraw that request. THIS PUTS A NOTIFICATION IN A REAL PERSON’S TIKTOK INBOX, AND REPEATING IT IS A REMINDER RATHER THAN A RETRY: TikTok refuses a second LINK within 24 hours of the last, allows at most two reminders in total, and counts them; so never re-send after a transient failure without reading list_tiktok_tto_link_requests first. REVOKE has its own one-way rule: it works only while the creator has neither accepted nor rejected, TikTok allows it once per request, and there is no un-revoke. TikTok also refuses a video already linked to any other campaign, and refuses one whose creator was never invited. Free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "Ask a creator to link a video to a TikTok One campaign",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string"
          },
          "videoId": {
            "type": "string",
            "description": "the creator’s public video: from list_tiktok_tto_campaigns or tiktok_tto_campaign_report"
          },
          "action": {
            "description": "default LINK; a second LINK is a REMINDER",
            "type": "string",
            "enum": [
              "LINK",
              "REVOKE"
            ]
          }
        },
        "required": [
          "campaignId",
          "videoId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_tto_link_requests",
      "title": "List TikTok One video-linking requests",
      "description": "Every video-linking request on this TikTok One account and where each one stands; waiting on the creator, approved, rejected or withdrawn; plus how many reminders are left on it. READ THIS BEFORE RE-SENDING ANYTHING: a second LINK is a reminder into a stranger’s inbox and TikTok caps them at two. Rows also carry the link request id, which the invited creator needs if they want to answer through their own TikTok One tools. Read-only, free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "List TikTok One video-linking requests",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "campaignIds": {
            "description": "max 50",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "handles": {
            "description": "max 50",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "campaignType": {
            "description": "omit to see every campaign",
            "type": "string",
            "enum": [
              "CAMPAIGN",
              "BRAND_LINK"
            ]
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "1-50, default 10",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_tto_campaign_report",
      "title": "Report on a TikTok One creator campaign",
      "description": "How each creator video on a TikTok One campaign actually performed, with EVERY HEADLINE METRIC SPLIT ORGANIC VERSUS PAID: views, reach, engagement rate, likes, comments, shares, favourites, completion rate, average view time; plus audience breakdowns and any anchor clicks. That split is the question an influencer campaign exists to answer: did the creator’s own audience carry the video, or did the media spend. Give startDate and endDate TOGETHER to also get day-by-day figures; giving one without the other is refused, because TikTok requires each whenever the other is present. TikTok reports one country per call and will only accept one of the CAMPAIGN’s own countries, so it is read off the campaign rather than guessed. Two TikTok behaviours worth passing on: reporting is backfilled if a creator tags a video retroactively, and campaigns created through the API do NOT show their reporting on the TikTok One website, so this is the only place it appears. Read-only, free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "Report on a TikTok One creator campaign",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string"
          },
          "countryCode": {
            "description": "one of the campaign’s own country codes: resolved automatically when it has only one",
            "type": "string"
          },
          "startDate": {
            "description": "YYYY-MM-DD (UTC+0), paired with endDate: together they add per-day figures",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD (UTC+0), paired with startDate",
            "type": "string"
          },
          "campaignType": {
            "type": "string",
            "enum": [
              "CAMPAIGN",
              "BRAND_LINK"
            ]
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "1-100, default 25",
            "type": "number"
          }
        },
        "required": [
          "campaignId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "request_tiktok_tto_spark_authorization",
      "title": "Ask a TikTok One creator for Spark Ads authorization",
      "description": "Ask the creator of a campaign video for Spark Ads authorization, so the brand can run their organic post as an ad. THIS IS THE ONLY WAY TO OBTAIN A SPARK ADS CODE WITHOUT THE CREATOR PASTING ONE OUT OF THE TIKTOK APP BY HAND: once they accept, get_tiktok_tto_spark_authorization hands back the code that authorize_tiktok_ads_spark_post takes, which then makes the post usable by create_tiktok_ads_ad. THE NUMBER OF DAYS IS A REQUEST, NOT A SETTING: TikTok says the creator picks the actual window when approving, so read the granted dates back rather than assuming what was asked for. action \"EXTEND\" lengthens an authorization the creator has already approved. TikTok returns no confirmation body for this call, so the state in the reply was read back separately rather than echoed. Free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "Ask a TikTok One creator for Spark Ads authorization",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "videoId": {
            "type": "string",
            "description": "a video the creator has already linked to the campaign"
          },
          "authorizationDays": {
            "description": "1-365, TikTok defaults to 30: a REQUEST, not a guarantee",
            "type": "number"
          },
          "action": {
            "description": "EXTEND adds the days to an authorization already approved",
            "type": "string",
            "enum": [
              "EXTEND"
            ]
          }
        },
        "required": [
          "videoId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_tiktok_tto_spark_authorization",
      "title": "Check a TikTok One Spark Ads authorization",
      "description": "Whether a creator has approved Spark Ads authorization for their video, and if so the authorization CODE, the window it covers, and whether that code is already bound to an ad account. The code is what authorize_tiktok_ads_spark_post takes. It appears only once the creator accepts: nothing on this side can produce one; and TikTok also reports how many further requests are left. Read-only, free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "Check a TikTok One Spark Ads authorization",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "videoId": {
            "type": "string"
          }
        },
        "required": [
          "videoId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_tiktok_tto_anchor",
      "title": "List, create or delete TikTok webpage anchors",
      "description": "Webpage anchors: the link that appears above a creator’s video description and in its comment section, sending viewers to a product or service page. action \"list\", \"create\" or \"delete\". AN ANCHOR CREATED HERE CAN NEVER BE DELETED: TikTok removes only DRAFT anchors, an anchor created through the API is born IN_REVIEW, and no API path produces a draft; so treat creating one as permanent, exactly like a TikTok pixel. An anchor is visible ONLY to viewers in its own country, and TikTok measures its performance only when the creator’s country matches too; the video still reaches everyone, the anchor does not. The landing page must be a product or service DETAIL page or TikTok rejects it during review. The thumbnail is passed as a public URL: TikTok also accepts a file upload, and that transport is deliberately not built because the URL form is documented as equivalent. Free.",
      "group": "ads",
      "section": "TIKTOK ONE (CREATOR MARKETPLACE) · STORES · VERIFICATION · PAYMENT PORTFOLIOS (2026-08-20)",
      "annotations": {
        "title": "List, create or delete TikTok webpage anchors",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ttoAccountId": {
            "type": "string"
          },
          "action": {
            "description": "default list",
            "type": "string",
            "enum": [
              "list",
              "create",
              "delete"
            ]
          },
          "anchorId": {
            "description": "required for delete",
            "type": "string"
          },
          "anchorIds": {
            "description": "filters a list, max 100",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "categoryLabelId": {
            "description": "required for create: TikTok’s product/service category, and it decides which countries the anchor may use",
            "type": "string"
          },
          "countryCode": {
            "description": "required for create: the anchor is invisible outside it",
            "type": "string"
          },
          "landingPageUrl": {
            "description": "required for create: a product or service DETAIL page",
            "type": "string"
          },
          "anchorTitle": {
            "description": "required for create: the call-to-action wording",
            "type": "string",
            "enum": [
              "WATCH_NOW",
              "LISTEN_NOW",
              "READ_MORE",
              "SHOW_NOW",
              "GET_OFFER",
              "LEARN_MORE",
              "CONTACT_US",
              "JOIN_NOW",
              "APPLY_NOW"
            ]
          },
          "anchorName": {
            "description": "required for create: an internal label TikTok never shows viewers, ≤32 characters",
            "type": "string"
          },
          "thumbnailUrl": {
            "description": "required for create: public URL, exactly 210x375, under 2MB",
            "type": "string"
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "1-50",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_stores",
      "title": "List the TikTok Shops on a TikTok ad account",
      "description": "The TikTok Shops granted to a TikTok ad account; what Shopping Ads and GMV Max campaigns sell from. Each row also names the BUSINESS CENTER that can reach the shop, and that is what list_tiktok_ads_store_products needs, because the product endpoint is keyed on a Business Center rather than on this ad account. An empty result is a real answer rather than an error: a shop has to be created in TikTok Shop Seller Center and granted to the ad account, and this reads that grant rather than creating one. Read-only, free.",
      "group": "ads",
      "section": "ONSITE COMMERCE STORE. The two endpoints do NOT share an id space: the store list is keyed on an",
      "annotations": {
        "title": "List the TikTok Shops on a TikTok ad account",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "type": "string"
          },
          "storeId": {
            "description": "filter to one",
            "type": "string"
          },
          "storeType": {
            "type": "string",
            "enum": [
              "TIKTOK_SHOP"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_store_products",
      "title": "List the products in a TikTok Shop",
      "description": "The products inside a TikTok Shop, with titles, images, price ranges, currency and historical sales. Pass adCreationEligible to also learn which of them may ACTUALLY be advertised; CUSTOM_SHOP_ADS for Shopping Ads, GMV_MAX for a Product GMV Max campaign; and note that WITHOUT it TikTok does not report eligibility at all, so an absent flag means unknown rather than yes. That filter additionally requires an advertiserId, because eligibility is a fact about a product AND an ad account rather than about a product alone. This call is keyed on a BUSINESS CENTER, which list_tiktok_ads_stores reports on each store row. Read-only, free.",
      "group": "ads",
      "section": "ONSITE COMMERCE STORE. The two endpoints do NOT share an id space: the store list is keyed on an",
      "annotations": {
        "title": "List the products in a TikTok Shop",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "bcId": {
            "description": "from the store row, or from list_tiktok_ads_business_centers",
            "type": "string"
          },
          "storeId": {
            "type": "string",
            "description": "from list_tiktok_ads_stores"
          },
          "advertiserId": {
            "description": "REQUIRED when adCreationEligible is given",
            "type": "string"
          },
          "adCreationEligible": {
            "type": "string",
            "enum": [
              "CUSTOM_SHOP_ADS",
              "GMV_MAX"
            ]
          },
          "itemGroupIds": {
            "description": "product SPU ids, max 10",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "productName": {
            "type": "string"
          },
          "sortField": {
            "type": "string",
            "enum": [
              "min_price",
              "historical_sales"
            ]
          },
          "sortType": {
            "type": "string",
            "enum": [
              "ASC",
              "DESC"
            ]
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "1-100, default 10",
            "type": "number"
          }
        },
        "required": [
          "storeId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_ads_verification_status",
      "title": "Check a TikTok account’s business verification",
      "description": "Whether a TikTok ad account or Business Center has passed business verification, when it was reviewed, and if it failed, TikTok’s own reason. AN AD ACCOUNT AND A BUSINESS CENTER ARE VERIFIED SEPARATELY and carry different statuses, so pass one or the other and never both; a verified Business Center does not verify the ad accounts inside it. Worth reading during onboarding: an unverified account meets limits that otherwise get diagnosed as something else entirely. It also reports the qualification id of any document already on file, which submit_tiktok_ads_verification can reuse. Read-only, free.",
      "group": "ads",
      "section": "BUSINESS VERIFICATION. The reads plus a JSON submit. THE DOCUMENT UPLOAD IS DELIBERATELY NOT BUILT: it is",
      "annotations": {
        "title": "Check a TikTok account’s business verification",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "an ad account: pass this OR bcId, not both",
            "type": "string"
          },
          "bcId": {
            "description": "a Business Center: pass this OR advertiserId, not both",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_verification_documents",
      "title": "List the verification documents TikTok accepts in a country",
      "description": "Which identity or business documents TikTok accepts for verification in a given country; the list differs everywhere, so there is no useful default and the country is required. Returns each document’s name and the code submit_tiktok_ads_verification needs. business_type BUSINESS asks about company paperwork (an EIN letter, a certificate of incorporation); INDIVIDUAL asks about personal identification (passport, driver’s licence, residence card). Read-only, free.",
      "group": "ads",
      "section": "BUSINESS VERIFICATION. The reads plus a JSON submit. THE DOCUMENT UPLOAD IS DELIBERATELY NOT BUILT: it is",
      "annotations": {
        "title": "List the verification documents TikTok accepts in a country",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "verificationType": {
            "description": "default BUSINESS",
            "type": "string",
            "enum": [
              "BUSINESS",
              "INDIVIDUAL"
            ]
          },
          "regionIsoCode": {
            "type": "string",
            "description": "the ISO country of the ACCOUNT being verified, e.g. US: tiktok_ads_verification_status reports it"
          }
        },
        "required": [
          "regionIsoCode"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "submit_tiktok_ads_verification",
      "title": "Submit a TikTok business or identity verification request",
      "description": "Submit a business or individual verification request for a TikTok ad account or Business Center. IT DOES NOT UPLOAD ANY DOCUMENT AND HERMOSO NEVER HANDLES ONE: this sends the account details plus the ids of images the user already uploaded in TikTok Ads Manager, or the id of a document already on file that TikTok permits them to reuse; tiktok_ads_verification_status reports that id. A BUSINESS submission takes exactly one image id; an INDIVIDUAL one takes exactly two, the front and back of a single document. THE LEGAL NAME AND THE DOCUMENT NUMBER CANNOT BE CHANGED AFTERWARDS and must match the paperwork character for character, so this is confirm-gated: calling it without confirm sends nothing and returns a sentence naming exactly what would leave here. Submitting also needs real authority: TikTok requires admin or operator on an ad account, admin on a Business Center. Free.",
      "group": "ads",
      "section": "BUSINESS VERIFICATION. The reads plus a JSON submit. THE DOCUMENT UPLOAD IS DELIBERATELY NOT BUILT: it is",
      "annotations": {
        "title": "Submit a TikTok business or identity verification request",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "advertiserId": {
            "description": "pass this OR bcId, not both",
            "type": "string"
          },
          "bcId": {
            "type": "string"
          },
          "verificationType": {
            "type": "string",
            "enum": [
              "BUSINESS",
              "INDIVIDUAL"
            ]
          },
          "companyName": {
            "description": "BUSINESS: the legal name, PERMANENT, must match the document exactly",
            "type": "string"
          },
          "individualName": {
            "description": "INDIVIDUAL: the full legal name, PERMANENT",
            "type": "string"
          },
          "websiteUrl": {
            "description": "required for both",
            "type": "string"
          },
          "industryCode": {
            "description": "BUSINESS only",
            "type": "string",
            "enum": [
              "NOT_APPLICABLE",
              "ALCOHOL",
              "OTC",
              "DATING_APP",
              "FINANCIAL_SERVICES"
            ]
          },
          "regionIsoCode": {
            "description": "the account’s ISO country",
            "type": "string"
          },
          "fileTypeCode": {
            "description": "from list_tiktok_ads_verification_documents",
            "type": "string"
          },
          "licenseNo": {
            "description": "BUSINESS: the certificate number, PERMANENT",
            "type": "string"
          },
          "identityNo": {
            "description": "INDIVIDUAL: the government ID number, PERMANENT",
            "type": "string"
          },
          "qualificationImageIds": {
            "description": "exactly 1 for BUSINESS, exactly 2 for INDIVIDUAL (front and back)",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "confirm": {
            "description": "REQUIRED true: call without it first to see exactly what would be sent",
            "type": "boolean"
          }
        },
        "required": [
          "verificationType"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_payment_portfolios",
      "title": "List TikTok payment portfolios",
      "description": "The payment portfolios under a TikTok Business Center; how a customer’s ad accounts are actually funded, whether the money is pooled across them (an Advanced portfolio) or held per account (a Standard one), how many accounts draw on each, and what credit line it carries. WORTH READING WHEN CAMPAIGNS STOP DELIVERING, because that is very often a funding answer rather than an ads answer, and nothing else in Hermoso can see it. By TikTok’s own default this lists every portfolio belonging to the same CLIENT as the Business Center, including ones not linked to it; pass bcRelated:true to narrow it. READ-ONLY BY DESIGN: Hermoso never creates a portfolio, moves an ad account onto one, or allocates a credit line, because each of those decides where a customer’s money sits. Free.",
      "group": "ads",
      "section": "PAYMENT PORTFOLIOS — READS ONLY, and the omission is the design. TikTok publishes three write endpoints",
      "annotations": {
        "title": "List TikTok payment portfolios",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "bcId": {
            "description": "from list_tiktok_ads_business_centers",
            "type": "string"
          },
          "paymentPortfolioIds": {
            "description": "filter, max 10",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "type": {
            "description": "SHARED is Advanced (pooled funds), NON_SHARED is Standard (per-account)",
            "type": "string",
            "enum": [
              "SHARED",
              "NON_SHARED"
            ]
          },
          "bcRelated": {
            "description": "true narrows to portfolios actually linked to this Business Center",
            "type": "boolean"
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "1-50",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_ads_payment_portfolio_links",
      "title": "List what a TikTok payment portfolio funds",
      "description": "Which ad accounts draw money from one TikTok payment portfolio, and which users are allowed to spend from it. Two separate TikTok calls answering two separate questions, so one failing does not empty the other; a half that could not be read says so rather than reporting an empty list. Read-only, free.",
      "group": "ads",
      "section": "PAYMENT PORTFOLIOS — READS ONLY, and the omission is the design. TikTok publishes three write endpoints",
      "annotations": {
        "title": "List what a TikTok payment portfolio funds",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "paymentPortfolioId": {
            "type": "string",
            "description": "from list_tiktok_ads_payment_portfolios"
          },
          "show": {
            "description": "default both",
            "type": "string",
            "enum": [
              "advertisers",
              "users",
              "both"
            ]
          },
          "page": {
            "type": "number"
          },
          "pageSize": {
            "description": "1-50",
            "type": "number"
          }
        },
        "required": [
          "paymentPortfolioId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_snapchat_ads_accounts",
      "title": "List Snapchat organizations and ad accounts",
      "description": "List the Snapchat AD ACCOUNTS SHARED WITH THIS BRAND — the ones it may actually build on and spend from, which is NOT everything the Snapchat login can reach — id, name, currency, timezone and status. Every other Snapchat Ads tool needs an adAccountId and this is where it comes from. One call returns both tiers, because Snap nests ad accounts inside their organization. An account flagged as a TEST account is marked as such — those cannot serve real ads. Read-only, free.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "List Snapchat organizations and ad accounts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_snapchat_ads_campaigns",
      "title": "Read the Snapchat ad tree",
      "description": "Read the whole Snapchat ad tree for an ad account — campaigns, ad squads and ads with their statuses. The three tiers are fetched separately so one failure cannot take the tree down, and a tier that FAILED to read is reported in `partial` rather than as an empty list: an empty list here means an empty account, never a failed read. Read-only, free.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "Read the Snapchat ad tree",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "description": "from list_snapchat_ads_accounts — omit only when exactly one is reachable",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "snapchat_ads_report",
      "title": "Snapchat ad performance",
      "description": "Read Snapchat ad performance — impressions, spend, swipes and video quartiles — at ad account, campaign, ad squad or ad level. THREE THINGS TO KNOW BEFORE CALLING: granularity is required (TOTAL is the default); DAY and HOUR granularity REQUIRE startTime and endTime AND both must land exactly on the start of an hour, which Snapchat refuses otherwise and Hermoso refuses for free before spending the call; and SPEND COMES BACK IN MICRO-CURRENCY, so divide by 1,000,000 before quoting money to anyone. Read-only, free.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "Snapchat ad performance",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "level": {
            "description": "default campaign",
            "type": "string",
            "enum": [
              "adaccount",
              "campaign",
              "adsquad",
              "ad"
            ]
          },
          "id": {
            "description": "the object to report on — defaults to the ad account itself for level:adaccount",
            "type": "string"
          },
          "granularity": {
            "description": "default TOTAL. DAY and HOUR need startTime and endTime, both on the hour.",
            "type": "string",
            "enum": [
              "TOTAL",
              "DAY",
              "HOUR",
              "LIFETIME"
            ]
          },
          "startTime": {
            "description": "ISO 8601, on the start of an hour (22:00, never 22:45)",
            "type": "string"
          },
          "endTime": {
            "description": "ISO 8601, on the start of an hour",
            "type": "string"
          },
          "fields": {
            "description": "comma-separated metrics — default impressions,spend,swipes",
            "type": "string"
          },
          "breakdown": {
            "description": "object-level breakdown: ad, adsquad (campaign stats only) or campaign (ad-account stats only)",
            "type": "string"
          },
          "swipeUpAttributionWindow": {
            "description": "how long after a SWIPE a conversion still counts. Omit to use the ad account default. Changing it changes the numbers, not just the report.",
            "type": "string",
            "enum": [
              "1_DAY",
              "7_DAY",
              "28_DAY"
            ]
          },
          "viewAttributionWindow": {
            "description": "how long after a VIEW (no swipe) a conversion still counts; 'none' attributes no view-throughs at all. Omit to use the ad account default.",
            "type": "string",
            "enum": [
              "none",
              "1_HOUR",
              "3_HOUR",
              "6_HOUR",
              "1_DAY",
              "7_DAY"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_snapchat_ads_targeting",
      "title": "Resolve Snapchat targeting ids",
      "description": "Resolve Snapchat targeting options to the ids an ad squad needs — countries (REQUIRED: an ad squad cannot be created without at least one), regions, metros, age groups, genders, languages, device OS or interests. NEVER invent one of these ids: invented targeting is silent and spends money on the wrong people. Region and metro lookups need a countryCode because Snapchat scopes those lists per country. Interests live in SEVERAL taxonomies at different paths (scls, vac, shp) which are not interchangeable, so name one rather than assuming. Read-only, free.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "Resolve Snapchat targeting ids",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "kind": {
            "description": "default country",
            "type": "string",
            "enum": [
              "country",
              "region",
              "metro",
              "age_group",
              "gender",
              "language",
              "os_type",
              "interest"
            ]
          },
          "countryCode": {
            "description": "REQUIRED for region and metro — two-letter code, e.g. \"us\"",
            "type": "string"
          },
          "taxonomy": {
            "description": "for kind:interest — scls, vac or shp",
            "type": "string"
          },
          "query": {
            "description": "filter the returned list to matching entries",
            "type": "string"
          },
          "limit": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_snapchat_ads_profiles",
      "title": "List Snapchat Public Profiles for an ad account",
      "description": "FIND THE PUBLIC PROFILE ID EVERY SNAPCHAT AD CREATIVE REQUIRES. Snapchat has required profile_properties on every creative since 2024-02-26, so without one upload_snapchat_ads_creative cannot build anything — this is where the id comes from. Snapchat publishes no clean list-profiles endpoint, so this reads the ad account SHARING POLICIES (the documented mechanism by which a profile reaches an ad account) and reports which resource-type token Snapchat accepted. IT CAN LEGITIMATELY FAIL: the Public Profile API is on a different host and documents its own OAuth scope which this connection does not hold, and a failure is reported as \"could not tell\" with the manual way out — NEVER as \"you have no profiles\", because an empty list there would look like a real answer. Read-only, free.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "List Snapchat Public Profiles for an ad account",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "resourceType": {
            "description": "override the shared_resource_types token if Snapchat documents a different one — by default several are tried and the one that works is reported",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "upload_snapchat_ads_creative",
      "title": "Upload a render to Snapchat as media + creative",
      "description": "PUT A FINISHED HERMOSO RENDER ONTO THE SNAPCHAT AD ACCOUNT so an ad can point at it — the bridge between making an ad and running one, and create_snapchat_ads_ad has no other source for the creativeId it needs. Pass the public https url of a render; SNAPCHAT HAS NO UPLOAD-FROM-URL, so Hermoso fetches the bytes and posts them as multipart. This does TWO things in one call — it uploads the MEDIA and then builds the CREATIVE that wraps it — because a Snapchat ad points at a CREATIVE and never at a media id, and stopping after the upload leaves an asset nothing can use. A PUBLIC PROFILE IS MANDATORY: Snapchat has required profile_properties on every ad creative since 2024-02-26, so profileId is REQUIRED and a call without one is refused BEFORE any bytes move (which is what stops an orphan media row being left on the ad account for a creative that could never be created). Get one from list_snapchat_ads_profiles, or read it in Snapchat Ads Manager. headline is required too. COPY LIMITS ARE SHORT AND ENFORCED: headline max 34 characters, brandName max 32 — far shorter than Meta or Google, and over-long copy is REFUSED rather than truncated, because shipping words nobody wrote is worse than a refusal. To build a SECOND creative on media already uploaded, pass mediaId instead of url — nothing is downloaded or uploaded again. A file over 32MB is refused by name (Snapchat requires a chunked upload flow above that which Hermoso does not implement). Free.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "Upload a render to Snapchat as media + creative",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "url": {
            "description": "public https url of the render — a Hermoso render URL already is one; for any other file run it through upload_file first. Omit only when reusing mediaId.",
            "type": "string"
          },
          "mediaId": {
            "description": "build a creative on media ALREADY uploaded to this ad account instead of uploading again — the way to reuse one video across several creatives",
            "type": "string"
          },
          "kind": {
            "description": "default video",
            "type": "string",
            "enum": [
              "video",
              "image"
            ]
          },
          "name": {
            "description": "label in Snapchat Ads Manager",
            "type": "string"
          },
          "headline": {
            "type": "string",
            "description": "REQUIRED by Snapchat. MAX 34 CHARACTERS — the text shown beneath the brand name"
          },
          "brandName": {
            "description": "MAX 32 CHARACTERS",
            "type": "string"
          },
          "callToAction": {
            "description": "the swipe-up button label; must fit the creative type",
            "type": "string"
          },
          "landingPageUrl": {
            "description": "where a swipe-up goes",
            "type": "string"
          },
          "creativeType": {
            "description": "default SNAP_AD",
            "type": "string"
          },
          "profileId": {
            "type": "string",
            "description": "REQUIRED — the Snapchat Public Profile the ad posts as. Snapchat has rejected every creative without one since 2024-02-26. From list_snapchat_ads_profiles, or Snapchat Ads Manager."
          },
          "creative": {
            "description": "set false to upload the MEDIA ONLY and build the creative yourself — the default true is what an ad actually needs",
            "type": "boolean"
          }
        },
        "required": [
          "headline",
          "profileId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_snapchat_ads_campaign",
      "title": "Create a Snapchat campaign (forced paused)",
      "description": "Create the top tier of a Snapchat ad — the campaign. CREATED PAUSED AND THERE IS NO OVERRIDE; it spends nothing until set_snapchat_ads_status(confirm:true). A CAMPAIGN ALONE CAN NEVER SERVE: it needs an ad squad and then an ad under it, and every tier must be ACTIVE before one impression is shown. Objectives are Snapchat’s CURRENT objective_v2 set — AWARENESS_AND_ENGAGEMENT, SALES, TRAFFIC, APP_PROMOTION, LEADS. A LEGACY objective name (BRAND_AWARENESS, WEB_CONVERSION and the rest) is REFUSED BY NAME rather than silently mapped onto a v2 value, because mapping one onto the other would optimise the campaign for something the user did not ask for. The result is READ BACK from Snapchat and the note says so when the read-back could not be run.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "Create a Snapchat campaign (forced paused)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "description": "from list_snapchat_ads_accounts — omit only when exactly one is reachable",
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "the campaign name in Snapchat Ads Manager — max 375 characters"
          },
          "objective": {
            "description": "AWARENESS_AND_ENGAGEMENT, SALES, TRAFFIC, APP_PROMOTION or LEADS. A legacy name is refused by name.",
            "type": "string"
          },
          "buyModel": {
            "description": "default AUCTION. RESERVED is Reach & Frequency booking and has its own contract.",
            "type": "string",
            "enum": [
              "AUCTION",
              "RESERVED"
            ]
          },
          "startTime": {
            "description": "ISO 8601 — defaults to now, which is harmless because the campaign is paused",
            "type": "string"
          },
          "endTime": {
            "description": "ISO 8601",
            "type": "string"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_snapchat_ads_ad_squad",
      "title": "Create a Snapchat ad squad (targeting, budget, bidding, schedule)",
      "description": "Create an ad squad under an existing Snapchat campaign — the tier that holds the budget, the bid, the targeting, the placements and the schedule. CREATED PAUSED with no override. TWO THINGS ARE MANDATORY AND NEITHER IS GUESSABLE: countries (at least one two-letter code — Snapchat refuses an ad squad with no geo, and search_snapchat_ads_targeting(kind:\"country\") resolves them), and a budget. **MONEY ON SNAPCHAT IS MICRO-CURRENCY**: state a plain amount in dailyBudget (50 means fifty dollars) and Hermoso multiplies by 1,000,000 for you. Only use dailyBudgetMicro if you have ALREADY converted, and NEVER pass both — that is refused rather than resolved by precedence, because applying the conversion twice to an already-converted amount asks for a budget a million times too large, and under-converting merely fails loudly while over-converting does not. Snapchat’s documented minimum daily budget is 5 units. A bid is required unless bidStrategy is AUTO_BID, which lets Snapchat choose it.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "Create a Snapchat ad squad (targeting, budget, bidding, schedule)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign this ad squad belongs to"
          },
          "name": {
            "type": "string",
            "description": "max 375 characters"
          },
          "countries": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "REQUIRED — two-letter country codes, e.g. [\"us\"]. Snapchat refuses an ad squad with no geo targeting."
          },
          "dailyBudget": {
            "description": "in the ad account’s own currency — 50 means fifty. Hermoso converts to micro. Minimum 5.",
            "type": "number"
          },
          "dailyBudgetMicro": {
            "description": "ONLY if you have already multiplied by 1,000,000. Passing this AND dailyBudget is refused.",
            "type": "number"
          },
          "lifetimeBudget": {
            "description": "in the ad account’s own currency — an alternative to a daily budget",
            "type": "number"
          },
          "lifetimeBudgetMicro": {
            "type": "number"
          },
          "bid": {
            "description": "in the ad account’s own currency — required unless bidStrategy is AUTO_BID",
            "type": "number"
          },
          "bidMicro": {
            "description": "ONLY if already converted; never alongside bid",
            "type": "number"
          },
          "bidStrategy": {
            "description": "default AUTO_BID — Snapchat picks the bid, and no bid field is needed. ONLY AUTO_BID and LOWEST_COST_WITH_MAX_BID actually work: Snapchat deprecated MIN_ROAS (with roas_value_micro) on 10 February 2025 and its ad-squads reference lists BOTH MIN_ROAS and TARGET_COST as not available, so either is refused by name before anything is created. They stay in the enum because they are still in Snapchat published enum — the refusal names the vendor, not your input.",
            "type": "string",
            "enum": [
              "AUTO_BID",
              "LOWEST_COST_WITH_MAX_BID",
              "TARGET_COST",
              "MIN_ROAS"
            ]
          },
          "optimizationGoal": {
            "description": "what Snapchat optimises delivery toward — default IMPRESSIONS. Others include SWIPES, VIDEO_VIEWS, APP_INSTALLS, PIXEL_PURCHASE, LEAD_FORM_SUBMISSIONS.",
            "type": "string"
          },
          "billingEvent": {
            "description": "IMPRESSION — the only value Snapchat documents",
            "type": "string"
          },
          "type": {
            "description": "default SNAP_ADS",
            "type": "string",
            "enum": [
              "SNAP_ADS",
              "LENS",
              "FILTER"
            ]
          },
          "placementConfig": {
            "description": "default AUTOMATIC — Snapchat places the ad across its surfaces",
            "type": "string",
            "enum": [
              "AUTOMATIC",
              "CUSTOM"
            ]
          },
          "minAge": {
            "description": "e.g. \"18\"",
            "type": "string"
          },
          "maxAge": {
            "type": "string"
          },
          "gender": {
            "description": "leave unset to reach everyone — an absent gender is no gender restriction, not a default",
            "type": "string",
            "enum": [
              "MALE",
              "FEMALE",
              "OTHER"
            ]
          },
          "languages": {
            "description": "language codes such as [\"en\",\"es\"] — resolve them with search_snapchat_ads_targeting(kind:\"language\")",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "osType": {
            "description": "device OS — resolve with search_snapchat_ads_targeting(kind:\"os_type\")",
            "type": "string",
            "enum": [
              "iOS",
              "ANDROID",
              "WEB"
            ]
          },
          "interests": {
            "description": "Snapchat interest category ids such as [\"SLC_1\"] — resolve with search_snapchat_ads_targeting(kind:\"interest\", taxonomy:\"scls\"). Ids are NOT interchangeable between taxonomies.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "regions": {
            "description": "region/state ids INSIDE the one country named in countries — resolve with search_snapchat_ads_targeting(kind:\"region\", countryCode:\"ca\"). Refused if more than one country is given, because a region id belongs to a country.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "metros": {
            "description": "metro/DMA ids inside the one country named in countries — resolve with kind:\"metro\"",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "postalCodes": {
            "description": "postal codes inside the one country named in countries",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "regulatedContent": {
            "description": "declare regulated content (alcohol, gambling and the like)",
            "type": "boolean"
          },
          "startTime": {
            "description": "ISO 8601",
            "type": "string"
          },
          "endTime": {
            "description": "ISO 8601",
            "type": "string"
          }
        },
        "required": [
          "campaignId",
          "name",
          "countries"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_snapchat_ads_ad",
      "title": "Create a Snapchat ad (forced paused)",
      "description": "Create the Snapchat ad itself, inside an ad squad. CREATED PAUSED with no override. THE CREATIVE COMES FROM upload_snapchat_ads_creative: pass the creativeId it returns. AN AD POINTS AT A CREATIVE, NEVER AT A MEDIA ID — passing a mediaId is refused by name rather than failing at Snapchat with a field-path error. SNAPCHAT REVIEWS EVERY AD before it can show: the returned reviewStatus says whether that has happened, and an ad Snapchat has REJECTED cannot serve even once it is activated, so relay a rejection instead of reporting a successful build. The status is READ BACK from Snapchat’s own row — if the note carries a ⚠ saying it was stored as anything other than PAUSED, relay that and pause it before anything above it is activated.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "Create a Snapchat ad (forced paused)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "adSquadId": {
            "type": "string",
            "description": "the ad squad this ad belongs to"
          },
          "name": {
            "type": "string",
            "description": "max 375 characters"
          },
          "creativeId": {
            "type": "string",
            "description": "from upload_snapchat_ads_creative — NOT a mediaId"
          },
          "type": {
            "description": "default SNAP_AD. Others include REMOTE_WEBPAGE, APP_INSTALL, STORY, COLLECTION, LEAD_GENERATION.",
            "type": "string"
          }
        },
        "required": [
          "adSquadId",
          "name",
          "creativeId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_snapchat_ads_budget",
      "title": "Change a Snapchat ad squad budget",
      "description": "Change the budget on a Snapchat AD SQUAD. BUDGETS LIVE ON THE AD SQUAD, not on the campaign — a campaign-level ask is refused by name rather than silently patching nothing. NEEDS confirm:true, and without it NOTHING CHANGES: you get a sentence naming the ad squad AS READ FROM SNAPCHAT and the amount, which you must show the user first. State a plain amount in dailyBudget and Hermoso converts to micro-currency; never pass both units. The new budget is READ BACK — report what Snapchat stored, in both micro and real money, not what you sent.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "Change a Snapchat ad squad budget",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "level": {
            "description": "adsquad — the only tier that holds a budget on Snapchat",
            "type": "string",
            "enum": [
              "adsquad"
            ]
          },
          "id": {
            "type": "string",
            "description": "the ad squad id"
          },
          "dailyBudget": {
            "description": "in the ad account’s own currency — minimum 5",
            "type": "number"
          },
          "dailyBudgetMicro": {
            "description": "ONLY if already multiplied by 1,000,000; never alongside dailyBudget",
            "type": "number"
          },
          "lifetimeBudget": {
            "type": "number"
          },
          "lifetimeBudgetMicro": {
            "type": "number"
          },
          "confirm": {
            "description": "REQUIRED true — without it nothing changes and you get the sentence to show the user",
            "type": "boolean"
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_snapchat_ads_status",
      "title": "Activate or pause Snapchat campaigns, ad squads and ads",
      "description": "THE ONE SWITCH THAT ARMS REAL MONEY ON SNAPCHAT. ACTIVE starts real spend on the next auction; PAUSED stops it. EVERY change needs confirm:true, and WITHOUT confirm NOTHING CHANGES — you get a sentence naming each object AS READ FROM SNAPCHAT, with its real name and its current status, which you must show the user before asking for a yes. Confirming proves the caller meant to change SOMETHING; only reading the object back proves they aimed at the right one. EVERY TIER must be ACTIVE for a single impression to serve — a live ad under a paused ad squad shows nothing. THE ANSWER IS THE READ-BACK: report what Snapchat STORED per id, never the status you asked for. Snapchat has NO delete status — removal is a real DELETE verb, so use delete_snapchat_ads_object.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "Activate or pause Snapchat campaigns, ad squads and ads",
        "readOnlyHint": false,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "level": {
            "type": "string",
            "enum": [
              "campaign",
              "adsquad",
              "ad"
            ],
            "description": "which tier these ids belong to"
          },
          "ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "the objects to change"
          },
          "status": {
            "type": "string",
            "enum": [
              "ACTIVE",
              "PAUSED"
            ],
            "description": "ACTIVE arms real spend"
          },
          "confirm": {
            "description": "REQUIRED true — without it nothing changes and you get the sentence to show the user",
            "type": "boolean"
          }
        },
        "required": [
          "level",
          "ids",
          "status"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_snapchat_ads_object",
      "title": "Delete a Snapchat campaign, ad squad or ad",
      "description": "PERMANENTLY DELETE a Snapchat campaign, ad squad or ad. Snapchat publishes a REAL delete verb at every tier — unlike TikTok, where removal is a status — so this is irreversible and there is no undelete. Needs confirm:true, and the unconfirmed call changes nothing and names the objects READ FROM SNAPCHAT plus what deleting a parent takes with it (a campaign takes its ad squads and ads). TO STOP DELIVERY REVERSIBLY, use set_snapchat_ads_status with PAUSED instead — the refusal says so, because most people asking to \"remove\" an ad mean \"stop it\". The read-back is INVERTED for a delete: an id that still resolves afterwards is reported as NOT CONFIRMED, never as a success.",
      "group": "ads",
      "section": "SNAPCHAT ADS (2026-08-10) — the tenth ad platform",
      "annotations": {
        "title": "Delete a Snapchat campaign, ad squad or ad",
        "readOnlyHint": false,
        "destructiveHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "level": {
            "type": "string",
            "enum": [
              "campaign",
              "adsquad",
              "ad"
            ],
            "description": "which tier these ids belong to"
          },
          "ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "the objects to delete"
          },
          "confirm": {
            "description": "REQUIRED true — without it nothing is deleted and you get the blast radius to show the user",
            "type": "boolean"
          }
        },
        "required": [
          "level",
          "ids"
        ],
        "$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": {
        "title": "List the LinkedIn company Pages this account administers",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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, a 2–20 image CAROUSEL (LinkedIn calls it a MultiImage post; pass the slides in order as imageUrls[]), or a LINK POST with a real preview card (linkUrl). USE linkUrl WHENEVER THE POINT OF THE POST IS A LINK: LinkedIn disables URL scraping for API partners, so a url sitting in the text renders as plain text with no card, and the card’s title, description and image only exist if you pass linkTitle / linkDescription / linkThumbnailUrl — read them off the page and supply them. 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. A VIDEO POST CAN CARRY CAPTIONS AND ITS OWN COVER, and both are attached only during the upload: pass captionsSrt (SubRip content — LinkedIn is watched with the sound off) and videoThumbnailUrl (otherwise LinkedIn picks a frame for you). LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).",
      "group": "ads",
      "section": "LINKEDIN COMPANY PAGES + ADS (2026-07-30)",
      "annotations": {
        "title": "Publish to a LinkedIn company Page",
        "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"
          },
          "linkUrl": {
            "description": "publish a LINK POST — LinkedIn renders a real preview card for this URL instead of leaving a bare link in the text. Mutually exclusive with imageUrl / videoUrl / imageUrls: LinkedIn’s content field is a union, so combining them is refused by name rather than one being dropped.",
            "type": "string"
          },
          "linkTitle": {
            "description": "the headline ON the preview card. LINKEDIN NEVER SCRAPES THE PAGE — their Posts API disables URL scraping for API partners outright — so if you do not pass this the card renders UNLABELLED. Fetch the page’s own title and pass it.",
            "type": "string"
          },
          "linkDescription": {
            "description": "the sub-line on the preview card. Same rule as linkTitle: absent means blank, because LinkedIn will not fetch it.",
            "type": "string"
          },
          "linkThumbnailUrl": {
            "description": "a Hermoso-hosted image used as the card’s picture (uploaded to LinkedIn for you). Without it the card has no image.",
            "type": "string"
          },
          "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": {
            "description": "accessibility alt text (max 4086 characters, ~120 recommended). A STRING describes every image; an ARRAY describes each slide of a multi-image post separately, in slide order — LinkedIn stores altText per image, and their own sample request carries a different one on each. Not available on a PERSONAL-profile post: LinkedIn’s member posting API has no alt-text field at all.",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            ]
          },
          "title": {
            "description": "video title",
            "type": "string"
          },
          "captionsSrt": {
            "description": "CLOSED CAPTIONS for a videoUrl post — the SubRip (.srt) CONTENT itself, cue numbers and `00:00:00,000 --> 00:00:02,000` timing lines included, NOT a URL and NOT the plain script (a file with no timings is refused, because LinkedIn would accept it and then silently never show it). Most of LinkedIn is watched with the sound off, so an uncaptioned video is one most of the feed never hears. LinkedIn allows ONE caption file per video and ENGLISH ONLY; it can be attached only WHILE the video is uploaded, never added to a published post; and it is processed asynchronously, so the reply confirms it was UPLOADED and never that it is visible yet. Requires videoUrl — passing it on an image, carousel or link post is refused by name.",
            "type": "string"
          },
          "videoThumbnailUrl": {
            "description": "the COVER IMAGE for a videoUrl post — a Hermoso-hosted image (a render, or any picture of the user’s via upload_file). Without it LinkedIn adds a system-generated thumbnail, which on an ad is usually whatever the first frame happens to be. Like captions this can only be set WHILE the video is uploaded, never afterwards. Requires videoUrl. This is NOT linkThumbnailUrl, which is the picture on a link-preview card.",
            "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": {
        "title": "Edit or delete a LinkedIn post",
        "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": {
        "title": "Organic performance of a LinkedIn company Page",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List LinkedIn ad accounts / campaigns",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "LinkedIn ads performance report",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "How many LinkedIn members a targeting spec reaches",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "LinkedIn suggested bid and budget range",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Create a LinkedIn campaign group (draft)",
        "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": {
        "title": "Create a LinkedIn campaign (draft)",
        "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": {
        "title": "Set a LinkedIn campaign budget",
        "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": {
        "title": "Activate or pause a LinkedIn campaign group / campaign / ad",
        "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"
          },
          "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": {
        "title": "Find LinkedIn targeting URNs",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Create a LinkedIn ad (creative, draft)",
        "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": {
        "title": "Delete a LinkedIn campaign group / campaign / ad",
        "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": "create_linkedin_conversion_rule",
      "title": "Create a LinkedIn conversion rule",
      "description": "Create a LinkedIn CONVERSION RULE — the object LinkedIn attributes conversions to, and the prerequisite for send_linkedin_conversions. `type` is the behaviour being tracked (LEAD, PURCHASE, SIGN_UP, QUALIFIED_LEAD, KEY_PAGE_VIEW…). THE RULE IS BORN ASSOCIATED WITH NOTHING: until you attach campaigns with associate_linkedin_conversion_campaigns it attributes nothing, which also makes it the safe place to send test events — LinkedIn has no test mode on the wire, unlike Reddit. Pass associateAllCampaigns:true to attach it to up to 200 ACTIVE campaigns instead; that spends nothing but it changes what those live campaigns optimise toward and what their reports count, so ask the user first. Attribution windows are 1, 7, 30 or 90 days (365 only for SUBMIT_APPLICATION, PURCHASE, ADD_TO_CART, QUALIFIED_LEAD and LEAD). Creating a rule cannot spend money — it is a definition. Free.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "title": "Create a LinkedIn conversion rule",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "shown in Campaign Manager and in every report"
          },
          "type": {
            "type": "string",
            "description": "the conversion behaviour, e.g. LEAD, PURCHASE, SIGN_UP, QUALIFIED_LEAD, KEY_PAGE_VIEW"
          },
          "postClickAttributionWindowSize": {
            "description": "1, 7, 30 or 90 days (365 for the five long-window types). LinkedIn default 30.",
            "type": "number"
          },
          "viewThroughAttributionWindowSize": {
            "description": "same allowed values. LinkedIn default 7.",
            "type": "number"
          },
          "attributionType": {
            "type": "string",
            "enum": [
              "LAST_TOUCH_BY_CAMPAIGN",
              "LAST_TOUCH_BY_CONVERSION"
            ]
          },
          "valueType": {
            "description": "DYNAMIC (default) takes each event’s own value",
            "type": "string",
            "enum": [
              "DYNAMIC",
              "FIXED",
              "NO_VALUE"
            ]
          },
          "enabled": {
            "description": "default true. A disabled rule REFUSES streamed events.",
            "type": "boolean"
          },
          "associateAllCampaigns": {
            "description": "attach to up to 200 ACTIVE campaigns now — ask the user first",
            "type": "boolean"
          },
          "associateCampaignsByObjective": {
            "description": "attach only to campaigns whose objective matches this conversion type",
            "type": "boolean"
          }
        },
        "required": [
          "name",
          "type"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_linkedin_conversion_rules",
      "title": "List LinkedIn conversion rules",
      "description": "List the conversion rules on a LinkedIn ad account, including ones SHARED from other accounts in the same Business Manager. Use it to find the conversionId send_linkedin_conversions needs, and to check whether a rule can actually receive API events — only a rule whose conversionMethod is CONVERSIONS_API and which is enabled can, and a rule built for the Insight Tag cannot. Zero rules genuinely means none exist; say that rather than implying a failure. Read-only, free.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "title": "List LinkedIn conversion rules",
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_linkedin_conversion_rule",
      "title": "Update or disable a LinkedIn conversion rule",
      "description": "Rename a LinkedIn conversion rule, change its attribution windows or attribution model, or ENABLE/DISABLE it. Disabling is how a conversion rule is retired — LinkedIn publishes no delete for one, and while it is disabled every streamed event for it is refused. The rule’s `type` is immutable. The note quotes what LinkedIn returned on the read-back, not what was sent — repeat that. Free.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "title": "Update or disable a LinkedIn conversion rule",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "conversionId": {
            "type": "string",
            "description": "the rule id, e.g. \"104012\""
          },
          "name": {
            "type": "string"
          },
          "enabled": {
            "description": "false RETIRES it — streamed events are then refused",
            "type": "boolean"
          },
          "postClickAttributionWindowSize": {
            "type": "number"
          },
          "viewThroughAttributionWindowSize": {
            "type": "number"
          },
          "attributionType": {
            "type": "string",
            "enum": [
              "LAST_TOUCH_BY_CAMPAIGN",
              "LAST_TOUCH_BY_CONVERSION"
            ]
          }
        },
        "required": [
          "conversionId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "associate_linkedin_conversion_campaigns",
      "title": "Attach campaigns to a LinkedIn conversion rule",
      "description": "Associate LinkedIn campaigns with a conversion rule — or detach them with remove:true. THIS IS WHAT MAKES A CONVERSION COUNT: LinkedIn only attributes a conversion to campaigns associated with its rule, so an unassociated rule reports zero however many events you stream to it. Associate every campaign the conversion could plausibly have come from. It spends nothing, but on a LIVE campaign it changes what the campaign optimises toward and what its report counts. The result is the PER-CAMPAIGN status LinkedIn returned, not a blanket success — repeat any that failed. Free.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "title": "Attach campaigns to a LinkedIn conversion rule",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "conversionId": {
            "type": "string",
            "description": "the conversion rule id, or its urn:lla:llaPartnerConversion:… URN"
          },
          "campaignIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "LinkedIn campaign ids (list_linkedin_ads_campaigns has them)"
          },
          "remove": {
            "description": "detach instead of attach",
            "type": "boolean"
          }
        },
        "required": [
          "conversionId",
          "campaignIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "send_linkedin_conversions",
      "title": "Send conversions to LinkedIn (Conversions API)",
      "description": "Stream conversion events to LinkedIn (Conversions API) — server-side conversion tracking for things that happen where the Insight Tag cannot see them: a CRM deal closing, an offline sale, a phone order, a qualified lead. Needs a rule from create_linkedin_conversion_rule, and that rule needs campaigns associated or nothing is attributed. PASS THE PERSON’S PLAIN EMAIL ADDRESS as `email` — Hermoso applies LinkedIn’s own normalization and SHA-256 hashes it on the server, and the plaintext is never stored or logged. NEVER COMPUTE THE HASH YOURSELF: an invented digest is a well-formed 64-character string that matches nobody, and LinkedIn accepts it with a 201, so the failure is completely silent (`emailSha256` exists only for a source system that already holds real digests). SEND EVERY IDENTIFIER YOU HAVE — email, liFatId (the li_fat_id click id LinkedIn appends to ad click URLs), ipAddress, firstName WITH lastName, an externalId, a lead URN — the match rate is what decides whether the conversion counts at all. Events must have happened in the past 90 DAYS. ACCEPTED IS NOT MATCHED: success means LinkedIn took the events, not that any matched a member, and no API reports the match rate — check attribution in linkedin_ads_report over the following days and never present acceptance as conversions. Up to 5000 events per call. Free.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "title": "Send conversions to LinkedIn (Conversions API)",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "conversionId": {
            "description": "default conversion rule id for every event that does not name its own",
            "type": "string"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "object",
              "propertyNames": {
                "type": "string"
              },
              "additionalProperties": {}
            },
            "description": "the conversion events — each takes conversionId, conversionHappenedAt (epoch ms or ISO, within 90 days), and at least one identifier: email (PLAIN — hashed here, never hash it yourself), emailSha256, liFatId, ipAddress (IPv4, hashed here), googleAid, acxiomId, firstName WITH lastName, companyName, title, countryCode, lead (urn:li:leadGenFormResponse:…), externalIds (max 1), plus optional amount + currencyCode and an eventId for Insight-Tag deduplication"
          }
        },
        "required": [
          "events"
        ],
        "$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 and renaming are always safe and always reversible. ARCHIVING IS NOT: Meta treats an archived object as DELETED and refuses to bring it back — every later edit answers \"This campaign has been deleted, so you can only edit the name\" (measured live 2026-08-05), archiving a campaign takes its ad sets and ads down with it, and the only way back is to duplicate it as a new object. Use PAUSED unless the user has said they are finished with it for good.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "title": "Edit a Meta campaign / ad set / ad",
        "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": {
        "title": "Delete a Meta campaign / ad set / ad",
        "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 published post. target:\"facebook\" → edit the message (action:\"edit\", message:…) OR delete (action:\"delete\"); target:\"threads\" → delete only (Threads has no edit API); target:\"instagram\" → DELETE ONLY — Meta lets you change nothing on a published Instagram post except whether comments are enabled, so a caption cannot be fixed; deleting covers ordinary posts, Stories, Reels and ENTIRE carousel albums (Instagram cannot remove one card out of an album — pass the album’s own media id, from list_instagram_media). Deleting is permanent. FOR INSTAGRAM, CALL IT WITHOUT confirm FIRST: nothing is deleted and you get back the post’s real caption, its likes and comments and how many carousel cards go with it — show the user exactly that, then call again with confirm:true plus confirmName (and confirmChildren for an album) if the refusal asks for them. A post nobody has liked or commented on yet stays a one-call delete. INSTAGRAM DELETE NEEDS A RECONNECT ON AN OLD CONNECTION: the `instagram_manage_contents` permission joined Hermoso’s Meta grant on 2026-08-05, so any Meta connection made before then must be reconnected (Settings ▸ Connectors ▸ Meta) before Instagram will accept a delete. Call the tool rather than pre-refusing — every refusal it can raise names the one thing that fixes it.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "title": "Edit or delete a published post",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "the post id returned by post_to_meta — for Instagram, the media id from list_instagram_media"
          },
          "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"
          },
          "pageId": {
            "description": "which Page to use — needed when the post id has no page prefix, or when the brand has several Pages and you are deleting an Instagram post",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true to delete (permanent)",
            "type": "boolean"
          },
          "confirmName": {
            "description": "Instagram only: the post’s exact caption line, exactly as the unconfirmed call reported it — required once the post has any likes or comments",
            "type": "string"
          },
          "confirmChildren": {
            "description": "Instagram only: how many carousel cards the delete also destroys, as the unconfirmed call reported",
            "type": "number"
          }
        },
        "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": {
        "title": "Save file(s) to Google Drive",
        "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": {
        "title": "List Google Drive files",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Get a Drive file’s details",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Rename / move / trash a Drive file",
        "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": {
        "title": "Delete a Drive file",
        "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": {
        "title": "Create a Drive folder",
        "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": {
        "title": "Create a Google Sheet",
        "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": {
        "title": "Append rows to a Google Sheet",
        "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": {
        "title": "Read a Google Sheet range",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "export_swipefile_deck",
      "title": "Swipefile to Google Slides",
      "description": "Turn a SWIPEFILE COLLECTION into a real Google Slides deck — one slide per saved ad, carrying the creative, the brand, the ad copy, the run dates with the run length, and the platform. This is the thing a marketer actually presents to a client or a team; until now the swipefile’s only export was JSON. Returns the presentation id + URL. Creates a NEW deck every time: under the drive.file scope Hermoso can only touch files it created, so it cannot add slides to a deck the user already has. A creative whose ad-library link has expired cannot be embedded — Meta signs those URLs with a short expiry — so that slide says so in words and keeps its copy and run dates, and the reply reports how many. Needs Google Drive connected (Settings ▸ Connectors ▸ Google Drive — one connection covers Drive, Sheets, Docs and Slides).",
      "group": "files",
      "section": "GOOGLE SLIDES: a swipefile collection as a real presentation the app creates (drive.file scope)",
      "annotations": {
        "title": "Swipefile to Google Slides",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "collection": {
            "description": "the swipefile collection to export, by name or id (default: the first collection)",
            "type": "string"
          },
          "title": {
            "description": "deck title (default: the collection name)",
            "type": "string"
          },
          "limit": {
            "description": "max ads to include, 1-60 (default 30)",
            "type": "number"
          }
        },
        "$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": {
        "title": "Create a Google Doc",
        "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": {
        "title": "Append text to a Google Doc",
        "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": {
        "title": "Read a Google Doc",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "list_sheet_tabs",
      "title": "List the tabs in a Google Sheet",
      "description": "The tabs in a Google Spreadsheet, each with its name, numeric sheetId, row/column count and position. Call this BEFORE naming a tab in update_sheet / clear_sheet_range / manage_sheet_tabs / format_sheet, and before proposing to delete one — it is how you learn what the file actually contains instead of guessing at a name. Read-only, free.",
      "group": "files",
      "section": "The Sheets / Docs WRITE surface (2026-08-05)",
      "annotations": {
        "title": "List the tabs in a Google Sheet",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "spreadsheetId": {
            "description": "the spreadsheet id (from create_sheet, or list_drive_files for one the user picked)",
            "type": "string"
          },
          "sheetUrl": {
            "description": "a Google Sheets URL — the id is extracted from it",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_sheet",
      "title": "Write to a range in a Google Sheet",
      "description": "CORRECT cells in a Google Sheet — write values to an exact range, overwriting whatever is there. This is the fix append_to_sheet cannot make: appending only ever adds rows at the bottom, so without this a wrong number stays wrong forever and the only \"correction\" is a second row contradicting the first. Pass `range` (e.g. \"B2:C5\", or \"Q3 Report!B2\" to name a tab — list_sheet_tabs gives the names) and `values` as an array of row arrays; an anchor cell like \"B2\" is fine and the block is written down and right from it. Writing into EMPTY cells goes straight through. Writing OVER cells that already hold values is REFUSED first, naming exactly how many filled cells would be overwritten — show the user that, get a yes, then call again with confirm:true. The result is READ BACK from the sheet, so what you report is what the sheet now holds rather than what Google accepted.",
      "group": "files",
      "section": "The Sheets / Docs WRITE surface (2026-08-05)",
      "annotations": {
        "title": "Write to a range in a Google Sheet",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "spreadsheetId": {
            "type": "string"
          },
          "sheetUrl": {
            "type": "string"
          },
          "range": {
            "description": "A1 range or anchor cell, e.g. \"B2:C5\", \"B2\", or \"Q3 Report!B2\" (default A1)",
            "type": "string"
          },
          "values": {
            "description": "array of row arrays to write",
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "anyOf": [
                  {
                    "type": "string"
                  },
                  {
                    "type": "number"
                  },
                  {
                    "type": "boolean"
                  }
                ]
              }
            }
          },
          "updates": {
            "description": "write SEVERAL disjoint ranges in one call, instead of range+values",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "range": {
                  "type": "string"
                },
                "values": {
                  "type": "array",
                  "items": {
                    "type": "array",
                    "items": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "number"
                        },
                        {
                          "type": "boolean"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "valueInputOption": {
            "description": "USER_ENTERED (default) parses formulas, dates and numbers the way typing them would; RAW stores every value as literal text",
            "type": "string",
            "enum": [
              "USER_ENTERED",
              "RAW"
            ]
          },
          "confirm": {
            "description": "required only when the target range already holds values",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "clear_sheet_range",
      "title": "Clear a range in a Google Sheet",
      "description": "Empty a range of cells in a Google Sheet, leaving the rows themselves in place. DESTRUCTIVE: call it WITHOUT confirm first and nothing is cleared — you get back the real number of filled cells in that exact range. Show the user that number, get an unambiguous yes, then call again with confirm:true AND confirmCells set to it. The echo is not ceremony: it is what catches naming A1:Z1000 when you meant A1:Z10, which is the mistake that actually happens. There is deliberately NO default range. The clear is read back and reported as confirmed only if the range really is empty afterwards. To remove a whole tab instead, use manage_sheet_tabs.",
      "group": "files",
      "section": "The Sheets / Docs WRITE surface (2026-08-05)",
      "annotations": {
        "title": "Clear a range in a Google Sheet",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "spreadsheetId": {
            "type": "string"
          },
          "sheetUrl": {
            "type": "string"
          },
          "range": {
            "type": "string",
            "description": "the range to clear, e.g. \"A2:D50\" or \"Sheet1!A2:D50\""
          },
          "confirm": {
            "type": "boolean"
          },
          "confirmCells": {
            "description": "echo back the filled-cell count the unconfirmed call reported",
            "type": "number"
          }
        },
        "required": [
          "range"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_sheet_tabs",
      "title": "Add, rename or delete a sheet tab",
      "description": "Add, rename or delete a tab in a Google Spreadsheet. action:\"add\" + title · action:\"rename\" + tab + newTitle · action:\"delete\" + tab. Name the tab by its TITLE or its numeric sheetId (list_sheet_tabs gives both); an unknown tab is refused with the real list rather than a Google error nobody can map back. DELETING a tab destroys everything on it: call it without confirm first to get the filled-cell count, then confirm:true + confirmCells. Google does not allow removing the LAST remaining tab in a file, and that is refused by name with the way out (clear it, or delete the whole file with delete_drive_file). Every action is read back from the spreadsheet before it is reported as done.",
      "group": "files",
      "section": "The Sheets / Docs WRITE surface (2026-08-05)",
      "annotations": {
        "title": "Add, rename or delete a sheet tab",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "spreadsheetId": {
            "type": "string"
          },
          "sheetUrl": {
            "type": "string"
          },
          "action": {
            "type": "string",
            "enum": [
              "add",
              "rename",
              "delete"
            ]
          },
          "tab": {
            "description": "which tab — its title or numeric sheetId (rename / delete)",
            "type": "string"
          },
          "title": {
            "description": "the name for the new tab (action:\"add\")",
            "type": "string"
          },
          "newTitle": {
            "description": "what to rename the tab to (action:\"rename\")",
            "type": "string"
          },
          "confirm": {
            "type": "boolean"
          },
          "confirmCells": {
            "type": "number"
          }
        },
        "required": [
          "action"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "format_sheet",
      "title": "Format a Google Sheet",
      "description": "Make an exported sheet readable: bold the header row, FREEZE it so it stays visible while scrolling, and auto-size the columns so nothing is cut off. Worth calling right after create_sheet — a raw export with unsized columns and a header that scrolls away is the difference between a spreadsheet someone reads and one they close. Changes no cell VALUE, so it is never gated. The defaults do all three on the first tab; pass `tab` to pick another, freezeRows:0 to skip freezing, boldHeader:false or autoResize:false to skip those.",
      "group": "files",
      "section": "The Sheets / Docs WRITE surface (2026-08-05)",
      "annotations": {
        "title": "Format a Google Sheet",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "spreadsheetId": {
            "type": "string"
          },
          "sheetUrl": {
            "type": "string"
          },
          "tab": {
            "description": "tab title or numeric sheetId (default: the first tab)",
            "type": "string"
          },
          "boldHeader": {
            "type": "boolean"
          },
          "freezeRows": {
            "description": "how many top rows to freeze (default 1, 0 = none)",
            "type": "number"
          },
          "autoResize": {
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_doc",
      "title": "Edit a Google Doc in place",
      "description": "EDIT a Google Doc — the correction append_to_doc cannot make, which until now meant a doc could only ever grow and a wrong line stayed in it forever. Two shapes: `replacements:[{find, replace}]` rewrites specific text wherever it appears (call read_doc first and match the text EXACTLY; matchCase:false ignores case), or `rewrite:\"…\"` replaces the ENTIRE body (rewrite:\"\" empties it). Find/replace runs immediately and REPORTS how many occurrences changed — zero matches is reported as a FAILURE to match, never as a quiet success, because a text edit that silently does nothing is worse than one that visibly fails. A whole-body rewrite is destructive: call it without confirm first to get the character count, then confirm:true + confirmCells. Both are index-free by design — an agent cannot reliably compute Google’s character offsets, and a wrong offset deletes the wrong sentence.",
      "group": "files",
      "section": "The Sheets / Docs WRITE surface (2026-08-05)",
      "annotations": {
        "title": "Edit a Google Doc in place",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "documentId": {
            "description": "the document id (from create_doc, or list_drive_files for one the user picked)",
            "type": "string"
          },
          "docUrl": {
            "description": "a Google Docs URL — the id is extracted from it",
            "type": "string"
          },
          "replacements": {
            "description": "find/replace pairs, applied in order",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "find": {
                  "type": "string"
                },
                "replace": {
                  "type": "string"
                },
                "matchCase": {
                  "type": "boolean"
                }
              },
              "required": [
                "find"
              ]
            }
          },
          "rewrite": {
            "description": "replace the WHOLE body with this text (\"\" empties the doc)",
            "type": "string"
          },
          "confirm": {
            "type": "boolean"
          },
          "confirmCells": {
            "description": "echo back the character count the unconfirmed call reported (rewrite only)",
            "type": "number"
          }
        },
        "$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": {
        "title": "Save file(s) to OneDrive",
        "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": {
        "title": "List OneDrive files",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Get a OneDrive file’s details",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Rename / move a OneDrive file",
        "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": {
        "title": "Delete a OneDrive file",
        "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": {
        "title": "Convert a OneDrive file to PDF or JPG",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Create a OneDrive folder",
        "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": {
        "title": "Plan an ad concept",
        "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). A length that fits ONE clip of the render model renders as a single continuous pass; anything longer is STITCHED from acts filled to that model’s clip maximum with the remainder last (on a 15s-clip model, 40 → 15+15+10 and 17 → 13+4) — never time-compressed. The maximum is the model’s own: 15s on most, 30s on the longest-clip model, so a 30s ad can be one unbroken take rather than two acts. 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. NOTE that the saved-brand hydration also decides the ENGINE: attaching product photos routes the render to the compositing model, so a `model` you named is only honoured when no references ride — pass raw:true (or useBrand:false) to render on exactly the model you asked for. model = a catalog id from hermoso_capabilities (omit for the default). RAW MODEL ACCESS: raw:true dispatches your prompt to the model BYTE-IDENTICAL — no rewriting, no appended guidance, no negative prompt, no brand references attached on your behalf. Credits, the durable delivery of the finished asset and the per-model validation are unchanged. Fast (seconds). Spends credits.",
      "group": "create",
      "section": "image (synchronous)",
      "annotations": {
        "title": "Generate ad image",
        "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"
          },
          "raw": {
            "description": "RAW MODEL ACCESS: run the caller’s prompt on the named model with no Hermoso adjustments at all — the prompt reaches the provider byte-identical (no hex-to-colour-name rewrite, no prepended fidelity preamble) and NO saved-brand product photos are attached, so the model you name is the model that renders. Use it to drive the raw catalog; leave it off for an on-brand ad. Billing, the durable Library landing and per-model validation are unchanged.",
            "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": {
        "title": "Make video thumbnail",
        "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) — a name that engine does not have is REFUSED for free with its real list, and a few engines generate their own voice and take no preset at all (the reply says which voice actually spoke). Paid (a couple of credits by length; ≤900 characters).",
      "group": "create",
      "section": "raw playground: voice (TTS) + writing models",
      "annotations": {
        "title": "Generate voiceover",
        "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": "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). BY DEFAULT the model answers as a marketing copywriter (a short house system prompt is applied, which is what you want for ad copy); pass raw:true for a plain, unstyled answer from the model itself with NO system prompt at all. 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": {
        "title": "Generate text",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "prompt": {
            "type": "string",
            "description": "the writing task / question"
          },
          "raw": {
            "description": "RAW MODEL ACCESS: send the prompt with NO Hermoso system prompt — the model answers as itself rather than as an ad copywriter. Use it whenever the ask is not marketing copy (analysis, code, extraction, a plain question). Default false: the copywriter framing is applied.",
            "type": "boolean"
          },
          "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 storyboard that FITS ONE CLIP OF THE RENDER MODEL renders as a single continuous pass; anything longer automatically renders as STITCHED ACTS (the fewest balanced clips, each at most one model clip) — never time-compressed into one clip. That threshold is the render model’s own maximum, not a fixed number: most models cap a clip at 15s and the longest-clip one goes to 30s, so use dryRun:true to see the act split this plan will actually get, for free, before spending. 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": {
        "title": "Render ad video",
        "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. A length that fits ONE clip of the render model renders as one continuous pass; longer is stitched from acts filled to that model’s clip maximum with the remainder last — the maximum is 15s on most models and 30s on the longest-clip one, so 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": "'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render (the longest-clip 30s model, for one, tops out at 720p). Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k.",
            "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": {
        "title": "Make template ad",
        "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": {
        "title": "Finish video",
        "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": {
        "title": "Post-production edit",
        "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": {
        "title": "Fix a video beat",
        "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. THE VERTICAL REFRAME IS SUBJECT-AWARE: a few stills per clip go to ONE cheap vision call, which decides a SINGLE crop offset that is held for that clip's whole length — so a speaker sitting camera-left is not cropped out of their own clip, while the framing still never drifts INSIDE a clip (a per-frame crop truncates to whole pixels and shimmers, so it is deliberately not tracked). It costs one small vision call per clip, billed as its own event. When nothing is being discarded, or no single subject can be located, the crop stays dead centre exactly as before — read `reframedToSubject` and each clip's `reframeWhy` back off the result rather than assuming either way. 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. SUBTITLES ARE BURNED IN BY DEFAULT — slim white CAPS, thin black outline, bottom safe band, no box and no plate — because short-form is watched on mute; pass captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL: each cue is anchored to the transcript's own per-sentence timestamp and split inside a sentence by character count, so it tracks the speech closely but is not frame-accurate sync — never promise that. Read captionsBurned back off the result: it counts the clips that actually carry a burned track, and captionNote says why any are bare.",
      "group": "create",
      "section": "THREE BUILT LANES (clipper / explainer / hypermotion). Each has been a real WORKERS entry on POST /api/jobs for",
      "annotations": {
        "title": "Clip a long video",
        "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"
            ]
          },
          "captions": {
            "description": "burn subtitles into every clip. DEFAULT TRUE — a clip cut from a podcast or a talk is watched on mute, and the words are the product. Set false for clean footage. A clip whose window carries no readable speech is delivered bare rather than captioned with a guess, and the result says which.",
            "type": "boolean"
          }
        },
        "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": {
        "title": "Make an explainer video",
        "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": {
        "title": "Product sizzle (music-led)",
        "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 — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them and not by omitting the field. 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). RAW MODEL ACCESS: your prompt is NOT dispatched verbatim by default — a few small guards are appended (packaging/label safety when no reference image rides, a negative prompt on the models that take one, reference-binding lines when references ride) and hex colour codes are rewritten to colour names. Pass raw:true for none of that. raw:true dispatches your prompt to the model BYTE-IDENTICAL — no rewriting, no appended guidance, no negative prompt, no brand references attached on your behalf. Credits, the durable delivery of the finished asset and the per-model validation are unchanged. 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": {
        "title": "Generate video",
        "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)"
          },
          "raw": {
            "description": "RAW MODEL ACCESS: dispatch this prompt to the model BYTE-IDENTICAL — no appended packaging/label guidance, no negative prompt, no reference-binding lines, no hex-to-colour-name rewrite. Use it when you want the model itself rather than Hermoso's render craft. Two provider-mandated corrections still apply, because the vendor hard-fails without them: an @ImageN token that outnumbers the references actually shipped is dropped, and a prompt past the endpoint's published character cap is trimmed at a sentence boundary. Billing, durable delivery and per-model validation are unchanged.",
            "type": "boolean"
          },
          "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 (never a generic guess: the lists differ per model, from 4–8s on the short models up to 30s on the longest-clip one). This is a single continuous generation, so it CANNOT exceed that model’s longest clip: a longer ask is REFUSED with nothing rendered and nothing charged (it is never quietly truncated). Past ~15s only the long-clip models qualify, and an unnamed render is routed by the narrower auto-pool — so NAME the model in `model` when you are asking for a long single take. For a spot longer than any one clip, use plan_ad with durationSeconds then render_ad, which stitches acts of at most one model clip each (on a 15s-clip model, 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": "'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render (the longest-clip 30s model, for one, tops out at 720p). Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k.",
            "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": {
        "title": "Generate talking avatar",
        "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": "'1080p' (default) or '480p'/'720p' 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 clip of the chosen model. A multi-beat ad that FITS one clip renders better and cheaper as ONE single-pass generate_video/render_ad (a single generation carries the whole hook→demo→payoff arc) — never stitch those. What fits is the model’s own maximum from hermoso_capabilities, not a fixed number: 15s on most models, 30s on the longest-clip one, so a 30s spot need not be stitched at all if you name that model. 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": {
        "title": "Stitch multi-scene video",
        "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": "1080p (default), or 480p/720p for a cheaper draft",
            "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": {
        "title": "Get render job",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List skills",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Get skill",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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, a named specialist persona like “our founder-story style” or “short-form ad strategist”). 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 / Brand / Connectors / Team / raw store (r-m-w over the store seam)",
      "annotations": {
        "title": "Save a skill",
        "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 / Brand / Connectors / Team / raw store (r-m-w over the store seam)",
      "annotations": {
        "title": "Delete a custom skill",
        "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 / Brand / Connectors / Team / raw store (r-m-w over the store seam)",
      "annotations": {
        "title": "List memory",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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 / Brand / Connectors / Team / raw store (r-m-w over the store seam)",
      "annotations": {
        "title": "Remember a fact",
        "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 / Brand / Connectors / Team / raw store (r-m-w over the store seam)",
      "annotations": {
        "title": "Forget a memory",
        "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": {
        "title": "List the swipefile",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Save ads to the swipefile",
        "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": {
        "title": "List playbooks",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Save a playbook",
        "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": {
        "title": "Delete a playbook",
        "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_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": {
        "title": "List saved creators",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Save a creator",
        "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": {
        "title": "Delete a creator",
        "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": {
        "title": "Update brand fields",
        "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. Read-only, free. Allowed keys: heist.memory.v1, heist.skills.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 / 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": {
        "title": "Read a workspace store",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Read app settings",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Change app settings",
        "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": {
        "title": "List connectors",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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_ads, linkedin_ads, reddit_ads, apple_ads, microsoft_ads, google_business, google_analytics, snapchat_ads, x_ads, tiktok_ads, google_tag_manager, google_search_console, bing_webmaster. Read-only, free.",
      "group": "workspace",
      "section": "CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two",
      "annotations": {
        "title": "List a connector’s accounts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "provider": {
            "type": "string",
            "enum": [
              "meta",
              "google_ads",
              "linkedin",
              "pinterest_ads",
              "linkedin_ads",
              "reddit_ads",
              "apple_ads",
              "microsoft_ads",
              "google_business",
              "google_analytics",
              "snapchat_ads",
              "x_ads",
              "tiktok_ads",
              "google_tag_manager",
              "google_search_console",
              "bing_webmaster"
            ],
            "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_ads, linkedin_ads, reddit_ads, apple_ads, microsoft_ads, google_business, google_analytics, snapchat_ads, x_ads, tiktok_ads, google_tag_manager, google_search_console, bing_webmaster. Free.",
      "group": "workspace",
      "section": "CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two",
      "annotations": {
        "title": "Choose which accounts a brand may use",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "provider": {
            "type": "string",
            "enum": [
              "meta",
              "google_ads",
              "linkedin",
              "pinterest_ads",
              "linkedin_ads",
              "reddit_ads",
              "apple_ads",
              "microsoft_ads",
              "google_business",
              "google_analytics",
              "snapchat_ads",
              "x_ads",
              "tiktok_ads",
              "google_tag_manager",
              "google_search_console",
              "bing_webmaster"
            ],
            "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 always drops the stored credentials, so every tool for that provider stops working immediately and posts/campaigns already published are NOT affected. WHETHER IT ALSO REVOKES THE GRANT AT THE PROVIDER DEPENDS ON THE PROVIDER — a few (Threads, Microsoft) publish no revocation endpoint, so the authorisation stays in place until the user removes it in that provider's own settings. The unconfirmed call reports which it is for this provider (list_connectors also carries it as revokesAtProvider) — relay that verbatim rather than promising a revoke. 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": {
        "title": "Disconnect a connected account",
        "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": "leave_connector",
      "title": "Remove my own account from a shared connection",
      "description": "On a connector that several teammates can contribute their OWN account to (see list_connectors — the row reports multiContributor), remove YOURS from this brand: your stored credential is dropped and the accounts you shared stop being shared. Your teammates' accounts on the same connection keep working, and nothing changes at the provider — reconnecting in a browser shares again. Use this instead of disconnect_connector when the connection is not yours to remove: disconnect_connector revokes the grant at the provider and only the person who created the connection may call it.",
      "group": "workspace",
      "section": "REMOVE ONLY WHAT I CONTRIBUTED (2026-08-11)",
      "annotations": {
        "title": "Remove my own account from a shared connection",
        "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. \"linkedin\", \"tiktok_ads\", \"meta\""
          }
        },
        "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": "REMOVE ONLY WHAT I CONTRIBUTED (2026-08-11)",
      "annotations": {
        "title": "List team members",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "REMOVE ONLY WHAT I CONTRIBUTED (2026-08-11)",
      "annotations": {
        "title": "Invite a teammate",
        "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": "REMOVE ONLY WHAT I CONTRIBUTED (2026-08-11)",
      "annotations": {
        "title": "Remove a teammate",
        "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": "REMOVE ONLY WHAT I CONTRIBUTED (2026-08-11)",
      "annotations": {
        "title": "Change a teammate’s role",
        "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": "REMOVE ONLY WHAT I CONTRIBUTED (2026-08-11)",
      "annotations": {
        "title": "List render jobs",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List errors users hit",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Error detail",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Find competitors",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Pull competitor ads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "set_competitor_watch",
      "title": "Set the competitor watch",
      "description": "Set (or STOP) this workspace's standing COMPETITOR WATCH — the weekly job that re-checks each named brand's ad libraries and reports what is NEW since last time. The same watch the web app's Ad Spy ▸ Watching tab manages, and the same one the weekly digest email is sent from (turn that email on/off with update_settings({watchEmail})). This REPLACES the whole watched list, it does not add to it — pass every brand you want watched, every time. Max 5 brands (the server trims past that). Pass an EMPTY list to stop the watch entirely, which also clears the findings. Give a `domain` wherever you know one: Google Ads Transparency is looked up BY DOMAIN and is skipped for a brand without one, and the domain is what resolves the right Meta page for a brand with an ambiguous name. The run itself spends ScrapeCreators credits against the ad libraries (roughly 3 per brand on Meta, 1 each on Google and LinkedIn) and is hard-capped per run server-side, so an oversized watch is trimmed rather than allowed to run away. Setting the list is free; only a run spends. runNow:true runs it once IMMEDIATELY (a background job — it spends now) and then keeps the weekly cadence; leave it off and the first check is a week out. The country and the platform mix are NOT settable here — a re-set inherits whatever the pending run already carried (US / Meta for a watch that has never been configured otherwise). Read the findings back with list_watch_findings.",
      "group": "research",
      "section": "COMPETITOR WATCH — the STANDING research order, not a one-off pull (2026-08-18). `update_settings` could",
      "annotations": {
        "title": "Set the competitor watch",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "competitors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string",
                  "description": "the brand name, as it advertises"
                },
                "domain": {
                  "description": "its domain, e.g. ridge.com — required for Google Ads Transparency, and what disambiguates a common brand name on Meta",
                  "type": "string"
                }
              },
              "required": [
                "name"
              ],
              "additionalProperties": {}
            },
            "description": "the brands to watch — the COMPLETE list, replacing whatever was set before. Empty array = stop watching."
          },
          "runNow": {
            "description": "true to run one check immediately (spends credits now) instead of waiting a week for the first one",
            "type": "boolean"
          }
        },
        "required": [
          "competitors"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_watch_findings",
      "title": "Read the competitor watch",
      "description": "Read what the standing COMPETITOR WATCH has found — the new ads each watched brand has launched since the last check, plus the watch's own state (who is watched, when it last ran, when it runs next, and whether the last run actually succeeded). The same board the web app's Ad Spy ▸ Watching tab renders. Use it to answer \"what are our competitors running that's new?\", to feed a teardown, or to save something worth keeping with save_to_swipefile. Findings marked `seed:true` are NOT new launches — the first check of a brand has nothing to diff against, so it seeds the board with what that brand is running right now; only later runs surface genuine changes. Read-only and free — it returns the stored results of past runs and never triggers a check (set_competitor_watch({runNow:true}) is what runs one).",
      "group": "research",
      "section": "COMPETITOR WATCH — the STANDING research order, not a one-off pull (2026-08-18). `update_settings` could",
      "annotations": {
        "title": "Read the competitor watch",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "competitor": {
            "description": "only findings for this watched brand (exact name as returned in `watching`) — omit for all of them",
            "type": "string"
          },
          "limit": {
            "description": "max findings to return (default 25, max 75 — the server keeps at most 75, and at most 15 per brand)",
            "type": "number"
          }
        },
        "$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": "COMPETITOR WATCH — the STANDING research order, not a one-off pull (2026-08-18). `update_settings` could",
      "annotations": {
        "title": "Research ads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Search Meta ads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Search Google ads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Search LinkedIn ads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Search TikTok",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Search Instagram",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Search YouTube",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Search Reddit",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Search Threads",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Fetch ScrapeCreators endpoint",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Get saved brand",
        "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": {
        "title": "Draft brand profile",
        "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": {
        "title": "List library",
        "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": {
        "title": "Fetch asset",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Analyze video",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Score ad",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Reframe video",
        "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": {
        "title": "Upscale video",
        "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": {
        "title": "Edit a video clip",
        "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": {
        "title": "Dub video",
        "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": {
        "title": "Change narrator voice",
        "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": {
        "title": "Recast motion",
        "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": {
        "title": "Plan ad variations",
        "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": {
        "title": "Competitor teardown",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Check ad policy",
        "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": {
        "title": "Remix a static ad",
        "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": {
        "title": "Mine customer angles",
        "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": {
        "title": "List product photos",
        "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 and SAVE it as this brand's default product, so every later plan_ad / render_ad / generate_image grounds on the true packaging without being told again. 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 or saved (the summary says why). On PASS it persists the photo to a DURABLE url, writes it to the brand's product library as the DEFAULT, and READS THE BRAND BACK to confirm — `savedToBrand` and the summary report what the brand ACTUALLY holds now, never what was asked for, so if it did not become the default you are told instead of finding out from a paid render. 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": {
        "title": "Set product photo",
        "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": {
        "title": "Fetch App Store screens",
        "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": {
        "title": "List the Page’s / Instagram account’s own posts",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "List what this brand has published",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "The hook + setting libraries, and which hooks are working",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": {
        "title": "Which hooks and subjects are getting traction",
        "readOnlyHint": true,
        "destructiveHint": false,
        "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": "diagnose_posts",
      "title": "What to fix next, post by post",
      "description": "WHAT TO FIX NEXT, post by post — and the tool that fixes it. post_performance tells you which hook is AHEAD; this tells you what is WRONG with a given post and where the next edit goes. Under-distributed is A HOOK PROBLEM (change the opening: mine_angles, then list_hooks, then plan_variations). Seen but not held is A RETENTION PROBLEM (plan_variations to rebuild the middle against the same hook). Seen, held, and still not converting is AN OFFER PROBLEM. FOUR REFUSALS, AND YOU SHOULD REPEAT THEM RATHER THAN PAPER OVER THEM: (1) a post younger than ~24h is TOO EARLY and is never called a failure — it has not had its run; (2) a metric the platform does not publish is UNMEASURED, never zero — Facebook has published no post reach since 2026-06-15, Reddit publishes no impressions, and Google Business publishes nothing per-post at all; (3) below 5 measured posts on a channel there is no baseline of the brand's own, and the ONLY fallback is a published short-video hook floor that is NOT our measured number and does not transfer off TikTok/Instagram/YouTube — it is attributed in the output and you should attribute it too; (4) it does not always find a problem, and 'nothing here needs fixing' is a real answer rather than a failure to look. Hermoso cannot see conversions for an organic post — no channel reports installs or purchases against a post id — so the offer rung runs ONLY when the user tells you they are not converting and you pass converting:false. Print `summary` verbatim. Read-only, 0 credits.",
      "group": "publish",
      "section": "POST PERFORMANCE (2026-08-04)",
      "annotations": {
        "title": "What to fix next, post by post",
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "channel": {
            "description": "restrict to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest",
            "type": "string"
          },
          "limit": {
            "description": "how many recent posts to diagnose (default 25, max 200). The baseline is always built from EVERY post recorded for the brand, never only these, so a bad month can never become its own definition of normal.",
            "type": "number"
          },
          "converting": {
            "description": "pass false ONLY when the user has told you these posts are getting seen and are not converting — it re-reads the ones that are earning their reach as an offer problem instead of a win. Omit when you do not know; we cannot measure it.",
            "type": "boolean"
          }
        },
        "$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": {
        "title": "Read how the recorded posts performed",
        "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": {
        "title": "Import a channel’s past posts",
        "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#"
      }
    }
  ]
}
