Boosterbergberg.ad

berg.ad docs

Connect, leads, stats, autopilot and every tool.

berg.ad is Boosterberg's MCP server for Facebook and Instagram ads. Home: mcp.boosterberg.com.

Connect

Address: https://mcp.boosterberg.com (https://mcp.boosterberg.com/mcp works too). You need the berg.ad key Boosterberg gave your business.

  1. ChatGPT

    Settings → Apps & Connectors → Advanced → turn on Developer mode → Create. Name berg.ad, URL https://mcp.boosterberg.com, authentication OAuth. Sign in with your key and click Allow. Then start a new chat; if it answers without berg.ad, turn berg.ad on from the + menu under the message box.

  2. Claude (claude.ai)

    Settings → Connectors → Add custom connector, the same address, then sign in with your key.

  3. Claude Code

    claude mcp add --transport http bergad https://mcp.boosterberg.com --header "Authorization: Bearer <key>"

Keys and sign-in

  • Static key. Send it as Authorization: Bearer <key> on every request (Claude Code, your own code). It is checked first.
  • OAuth (ChatGPT, claude.ai). The app registers at /register (https redirect URIs, or http on a loopback host), sends you to /authorize, where you paste your key and click Allow, then trades the code at /token with PKCE S256. The app gets its own token, never your key. It stays signed in until 30 days without use. 20 registrations and 20 wrong keys per IP per hour, then try again in an hour.
  • Full key: every tool below. Read key: only the tools it was issued for, and only read-only ones: get_playbook, list_playbooks, list_forms, list_posts, get_results, get_post_stats, get_autopilot, list_audiences, review_posts, health_check, plan_campaigns, weekly_report, get_benchmarks, get_business_profile, graph. Other tools are not listed to it and cannot be called. OAuth sign-in with a read key keeps the same limit.

Boosterberg issues and rotates keys. Only a key's sha256 is stored.

Lead webhook

Each lead from an Instant Form on your page is POSTed to your webhook URL as JSON. berg.ad gets it from Meta's leadgen webhook and also reads your page's forms every 15 minutes (leads of the last 2 days), whichever comes first.

POST <your webhook URL>
Content-Type: application/json
X-BergAd-Signature: sha256=<hex HMAC-SHA256(lead_secret, raw body)>

{"consumer":"acme","lead_id":"123","page_id":"222","form_id":"f1","ad_id":"ad1",
 "created_time":"2026-10-16T10:00:00+0000",
 "field_data":[{"name":"email","values":["a@b.c"]},{"name":"phone_number","values":["0911234567"]}],
 "platform":"instagram"}
FieldMeaning
consumerYour business's name in berg.ad.
lead_idMeta's lead id. A lead can arrive more than once (a retry after your endpoint stored it but answered late): dedupe on lead_id.
page_idThe page the form belongs to.
form_id, ad_idThe form and the ad the lead came from; null when Meta gave none.
created_timeWhen the person sent the form, as Meta reports it (2026-10-16T10:00:00+0000). If Meta could not be read, the webhook's time as 2026-10-16T10:00:00.000Z, or null.
field_dataThe answers, as Meta gives them: [{"name", "values": [...]}]. null when the lead could not be read (see fetch_error). Phone numbers are passed on as Meta gives them and may lack a country code.
platformThe app the lead's ad runs on, read from its ad set's placements: "facebook" or "instagram" when they name one app; null when they name both, are automatic, the ad could not be read, or the lead has no ad.
fetch_errorOnly when berg.ad could not read the lead from Meta (tried with its own token, then the page's): the error type, for example PERMISSION_MISSING or META_ERROR. The lead exists; its answers are in Meta's Leads Center.

Verify the signature

HMAC-SHA256 of the raw body with your lead_secret, hex, prefixed sha256=. Compute it over the bytes you received, not over re-serialised JSON.

import { createHmac, timingSafeEqual } from 'node:crypto'

// raw: the request body exactly as received (a Buffer), before any JSON parsing.
function verify(raw, header, leadSecret) {
  const expected = 'sha256=' + createHmac('sha256', leadSecret).update(raw).digest('hex')
  return !!header && header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected))
}

Delivery

  • One POST per lead attempt. Answer 2xx within 25 seconds.
  • A non-2xx answer, a timeout or a network error is retried by the 15-minute cron: after 15 minutes, then doubling to at most 12 hours apart, 10 attempts in all (about 2 days). After the 10th, berg.ad stops and alerts Boosterberg. Each retry is signed with your current lead_secret.
  • No webhook URL set: nothing is sent.
  • No tool returns lead contents; this webhook is the only way leads leave berg.ad.

Stats

Two read-only tools. Spend, costs and Meta's lead counts are Meta's paid numbers (ad insights); get_results adds what berg.ad delivered (leads_received) and account health. Figures are in the ad account's currency (ISO code; one key is one ad account, so one currency). spend and costs are in major units as Meta reports them (12.34); budgets you set elsewhere are minor units (500 = 5.00). Meta may revise the last ~48 hours.

get_post_stats: per post

{ "timezone": "Europe/Zagreb", "currency": "EUR",
  "posts": [{ "platform", "post_id", "source_post_id", "ad_ids", "total", "daily": [{ "date", ... }],
              "adsets": [{ "adset_id", "adset_name", "campaign_id", "stage", "purpose", "ad_ids", "total", "daily" }] }],
  "hourly": [ ... ],   // only with hourly: true
  "note" }
  • timezone: the ad account's; since, until and date are days in it.
  • Metrics in total and each day: spend, impressions, video_views_2s, likes, comments, shares, saves, leads (= form_leads + dm_leads, Meta's count).
  • platform: the placement's app. Instagram placements count on the Instagram media the ad ran; every other placement on the Facebook post. source_post_id is the organic post the ad was made from.
  • Paid vs organic: organic = the post's own totals minus these paid numbers; join on source_post_id.
  • stage per ad set: warmup (outside the business's market), target (its market), or null (not recorded).
  • hourly: true (at most 7 days): per-ad rows with hour_utc, date, hour_local. Meta cannot split hours by placement, so an hourly row's platform is the ad's one placement in the range, else "all".

get_results: the overview

{ "currency": "EUR",
  "rows": [{ "campaign_id", "adset_id", "ad_id", "name", "spend", "impressions", "reach", "frequency",
             "cpm", "video_views_2s", "cost_per_1k_views", "thruplays", "cost_per_thruplay",
             "avg_watch_seconds", "hold_pct", "engagements", "cost_per_engagement",
             "leads", "form_leads", "dm_leads", "cpl", "purpose", "platform", "vs_norm", "leads_received" }],
  "lead_health", "statuses", "flagged", "delivery", "access", "outside_changes", "note" }
  • Rows per level (campaign, adset or ad). Costs are null when there is nothing to divide by.
  • platform: "facebook" or "instagram" when the row's ad sets place on that one app, else null.
  • purpose: the stage (warmup / target) and what the campaign is for, from create_lead_ad or set_campaign_purpose.
  • vs_norm: the row's key cost for its stage (warmup: cost_per_1k_views; target ThruPlay or engagement: cost_per_thruplay; target leads or DMs: cpl) against the median of this account's other ad sets judged on the same key cost, each with at least 20 events in the last 30 days. Fields: metric, value, median, n (ad sets), events, events_needed (20), pct_better (above 0 = cheaper than the norm; "too_few_events" under 20 events or with no norm yet). vs_norm is null when the stage is not recorded or the goal has no norm.
  • leads_received (level ad): leads berg.ad delivered, with the outcomes you reported (report_lead_outcomes) and cost_per_booking.

Autopilot

With autopilot on, every new post that matches the rules gets one ad set per per-post run, each with its own lifetime budget. Nothing spends until the owner confirms the monthly maximum (set_autopilot with confirm: true); rules that can spend more need a new confirm. Read the current rules and a preview with get_autopilot.

Run types

  • Warmup (stage: "warmup"): cheap views outside the business's market, for social proof. Its viewers are never the audience.
  • Target engage (stage: "target", views or ThruPlay): views in the market; these build the fan audience.
  • Target convert (stage: "target", LEADS or MESSAGES): the ask, a quote form or a DM.
  • Rotating (mode: "rotating"): one always-on ad set per period that shows the newest Facebook posts to a fan audience in the market.

How it spends

  • Each post is promoted on its own app: a Facebook post on Facebook, Instagram media on Instagram. A run's platforms can name both.
  • budget is per post per run, a lifetime budget in minor units of the account currency. A run starts delay_hours after the post (not before now) and lasts hours.
  • posts.max_per_week limits how many posts start in a rolling 7 days. With max_per_week: null, posts are funded in posting order, oldest first, until the month's money is used; the maximum is then the consumer's monthly cap, which must be set.
  • Every activation stays inside the confirmed monthly maximum, the monthly cap and the daily limit. A run that does not fit is skipped and left paused, and re-checked on later ticks (a per-post run for 24 hours, a rotation's ad set hourly until its period ends).
  • Optional cost_cap per run (minor units, like budget): the ad set is created with Meta's COST_CAP bid strategy and that bid amount, so Meta aims to keep the average cost per result under it. Not for REACH. Too low a cap under-delivers. Without it the ad set bids lowest cost.
  • A post that already has its own campaign made with create_lead_ad is left alone.

Tools

28 tools, generated from the server's own tool list. read-only changes nothing; writes changes berg.ad or Meta; full key only is never available to a read key. Everything is created PAUSED; spend starts or rises only with the owner's yes (confirm: true).

get_playbook read-only

The playbook: berg.ad's core (which tool to use for which question §0, safety and money §1, reading delivery §8) around one expert's section (strategy, ranges, reasons). expert defaults to the consumer's default_expert, else Bill. Call it first in every session, before advising or creating anything.

  • expert string · pattern ^[a-z0-9_-]{2,32}$
    an expert_id from list_playbooks

list_playbooks read-only

The experts whose playbook sections berg.ad holds: expert_id, name, focus, version, and which one is this consumer's default (get_playbook reads it when no expert is named).

No parameters.

save_playbook writes full key only

Add or update an expert's section (markdown: strategy, ranges, the factors that move them, the reasons). The core (§0, §1, §8) stays berg.ad's and is put around it. Each save bumps the version. Private to this consumer: other consumers see only Bill and their own. Its # and ## headings are shown as ###; the core wins over it. bill (code-owned) and shared ids are refused. Spends nothing.

  • expert_id string · required · pattern ^[a-z0-9_-]{2,32}$
  • name string · required · min length 1, max length 80
  • focus string · required · min length 1, max length 300
    one line: what this expert optimises for
  • body string · required · min length 1, max length 30000

log_advice writes full key only

Record that the owner followed an expert's advice: the expert, the advice in a sentence, and the object it touched (post, ad set, campaign). weekly_report counts these per expert. Spends nothing.

  • expert string · required · pattern ^[a-z0-9_-]{2,32}$
  • advice string · required · min length 1, max length 1000
  • object_id string · max length 100

test_alert_email writes full key only

Send a test email to Boosterberg's operator, to check that alert and weekly-report email is set up. At most one an hour. Spends nothing.

No parameters.

list_forms read-only

The consumer's Instant Forms: id, name, status, locale, leads so far, question keys, and which one is the default. Reuse a form; one form serves every lead ad (each lead still arrives with its ad). Make a new form only for a new language or new questions.

No parameters.

set_consumer_settings writes

Set the consumer's default Instant Form (create_lead_ad uses it when form_id is left out), market (where the business sells; warmup ads are kept outside it so warmup viewers never join the fan audience) default expert (get_playbook's when none is named), report email (the Monday report's recipient) and share_audiences_with_bb (the business's consent to share its fan audiences, ids only, never members, with Boosterberg's ad account; the operator shares only with it on). Only what is passed changes. Spends nothing.

  • default_form_id string | null
    null clears it
  • market object | null
    null clears it
  • default_expert string | null
    an expert_id from list_playbooks; null goes back to Bill
  • report_email string | null
    where the Monday report is emailed besides Boosterberg's operator; null clears it
  • share_audiences_with_bb boolean
    true only on the owner's yes: Boosterberg may share the fan audiences to its own ad account; false stops new shares

list_posts read-only

Recent Facebook page posts and Instagram media of this consumer with basic metrics, plus an access preflight (permissions, ad account, funding source, page). Use it to pick the post for an ad, to check access before creating anything, and to explain a PERMISSION_MISSING / NO_ACCOUNT_ACCESS / NO_FUNDING_SOURCE error.

  • limit integer · default 10 · ≥ 1, ≤ 50

create_lead_form writes

Create an Instant Form on the consumer's page (privacy URL from the consumer). Returns the form id, read back from Meta; pass it to create_lead_ad (objective LEADS). qualifiers: true without questions builds name, email, phone and one short-answer question per business-profile qualifier. A multiple-choice question can mark disqualify answers; Meta's API can't send those people to another end screen, so the result's manual_steps say how to add it in Ads Manager: tell the owner. Follow-up (conditional) questions are not supported here. Leads from the form reach the consumer's own system (webhook or a 15-minute fetch); no tool returns lead contents.

  • name string · required · min length 1
  • questions array of object · min items 1
    left out with qualifiers: true, built from the business profile
  • questions[].type string · required
    FULL_NAME, EMAIL, PHONE, CITY, ... or CUSTOM
  • questions[].key string
  • questions[].label string
  • questions[].options array of string
    CUSTOM multiple choice answers
  • questions[].disqualify array of string
    answers from options that mean the lead doesn't qualify, e.g. a budget too small
  • qualifiers boolean
    true without questions: name, email, phone, then each get_business_profile qualifier as a short-answer question
  • non_qualifier object
    end screen for people who give a disqualify answer; goes into manual_steps
  • non_qualifier.title string · required
  • non_qualifier.body string · required
  • non_qualifier.button_text string
  • non_qualifier.website_url string
  • intro object
  • intro.title string · required
  • intro.body string · required
  • thank_you object · required
  • thank_you.title string · required
  • thank_you.body string · required
  • thank_you.button_text string · required
  • thank_you.website_url string · required
  • locale string · default "en_US"
  • higher_intent boolean · default false
    Meta "Higher intent" form type (adds a review step)

create_lead_ad writes

Create campaign + ad set + ad from an existing FB post or IG media, all PAUSED. Objective LEADS (needs form_id), MESSAGES (DMs: Messenger / Instagram Direct conversations, target stage only, the post itself only), ENGAGEMENT, VIDEO_VIEWS (ThruPlay or 2s) or REACH. Lifetime budget in minor units; an optional cost_cap (minor units, not REACH) makes Meta hold the average cost per result under it. Uses the post itself when Meta allows; otherwise, or when ad_text is given, a new creative from its media with the post's text or ad_text. Never edits the post. Nothing spends until set_status ACTIVE with the owner's yes. Name campaigns so their purpose is clear (e.g. warmup vs target audience); get_results and get_post_stats show the names.

  • name string · required · min length 1
  • objective "LEADS" | "ENGAGEMENT" | "VIDEO_VIEWS" | "REACH" | "MESSAGES" · required
  • destination "MESSENGER" | "INSTAGRAM_DIRECT" | "MESSAGING_INSTAGRAM_DIRECT_MESSENGER"
    MESSAGES only: where the conversation opens; default MESSENGER for a FB post, INSTAGRAM_DIRECT for an IG post
  • greeting string · min length 1, max length 300
    MESSAGES only: the welcome message people see when the conversation opens
  • ice_breakers array of string · min items 1, max items 3
    MESSAGES only, with greeting: up to 3 questions people can tap to start
  • video_goal "TWO_SECOND" | "THRUPLAY" · default "TWO_SECOND"
  • form_id string
    defaults to the consumer's default form for LEADS (list_forms)
  • post_id string · required
    FB post id (pageid_postid) or IG media id
  • source "facebook" | "instagram" · default "facebook"
  • targeting object · required
  • targeting.geo object · required
  • targeting.geo.countries array of string
  • targeting.geo.country_groups array of string
    e.g. worldwide, europe
  • targeting.geo.excluded_countries array of string
  • targeting.geo.location_types array of "home" | "recent" | "travel_in" · default ["home","recent"]
  • targeting.locales array of integer
    Meta locale ids, e.g. 1001 = English (All)
  • targeting.age_min integer · ≥ 13, ≤ 65
  • targeting.age_max integer · ≥ 13, ≤ 65
  • targeting.interests array of object
  • targeting.interests[].id string · required
  • targeting.interests[].name string · required
  • targeting.advantage_audience boolean · default false
  • targeting.custom_audience_ids array of string
  • targeting.excluded_custom_audience_ids array of string
  • platforms array of "facebook" | "instagram" · min items 1
    placements; default the post's own app only (a FB post on Facebook, IG media on Instagram); name both to run on both
  • lifetime_budget integer · required
    minor units of the ad account currency (500 = 5.00)
  • cost_cap integer
    Meta cost cap: the average cost per result (lead, ThruPlay, 2s view, engagement, conversation) Meta aims to keep under, minor units like lifetime_budget. Not for REACH. A cap too low under-delivers. Omit for no cap (lowest cost).
  • start_time string · required
  • end_time string · required
  • cta_type "SIGN_UP" | "LEARN_MORE" | "GET_QUOTE" | "APPLY_NOW" | "SUBSCRIBE" · default "SIGN_UP"
  • ad_text string · min length 1
    The ad's own primary text instead of the post's caption (e.g. the post's details plus 'Comment LINK for this week, or tap Get quote for any boat, place or date'). LEADS only. Makes the ad its own copy of the post: same media, new text; comments then land on that copy, not on the organic post.
  • stage "warmup" | "target" · required
    warmup = shown outside the business's market (e.g. worldwide views for social proof; never its audience, never retargeted); target = the business's market
  • purpose string · required · min length 1
    what this campaign is for, e.g. "social proof views before the EU run"
  • success_metric string
    how to judge it, e.g. "cost per 2s view", "cost per booking"

set_status writes (destructive)

Set ACTIVE / PAUSED / ARCHIVED on a campaign, ad set or ad of this consumer. ACTIVE starts spend: it needs confirm:true after the owner said yes, and is refused over max_daily and monthly_cap (adjust can also start an ad set, under the same rules). Pausing needs no confirm. To change an ad set's budget, end date or geo, use adjust; goal and creative can't be changed by berg.ad: tell the owner what to change in Ads Manager, or pause.

  • id string · required
  • level "campaign" | "adset" | "ad" · required
  • status "ACTIVE" | "PAUSED" | "ARCHIVED" · required
  • confirm boolean

adjust writes (destructive)

Change an ad set of this consumer: its lifetime budget (lifetime_budget = the new total, or add_budget = the amount to add, e.g. to extend a post that gets cheap leads), end_time (later extends, earlier shortens), geo (market rules by stage, as in create_lead_ad) and status ACTIVE / PAUSED. Reads the ad set first and returns old -> new, what it spent, what it can still spend, the added maximum spend, cap headroom, learning status and warnings. A change that can raise spend (a higher budget, ACTIVE, a new end for an ad set that had ended or has a daily budget, and a later end or geo on an ad set whose budget is on its campaign) changes nothing without confirm:true, given after the owner said yes to the stated amount, and is checked against max_daily and monthly_cap. The confirm call repeats the preview's expected_budget (add_budget) or from_budget (lifetime_budget); a retry after success answers 'already at'. Lowering (not below what was spent), pausing, a new end on a running ad set and geo apply at once. Writes only the changed fields, reads them back, and logs reason. A geo edit, or a budget change over ~20%, while the ad set is learning can restart learning (playbook §7).

  • adset_id string · required
  • end_time string
    new end, ISO 8601, in the future
  • lifetime_budget integer
    new lifetime total, minor units of the ad account currency
  • add_budget integer
    minor units added to the current lifetime budget
  • expected_budget integer
    with add_budget and confirm: the new total the preview returned; a retry or a budget changed since can't add twice
  • from_budget integer · ≥ 0
    with lifetime_budget and confirm: the budget the preview started from
  • geo object
    replaces the geo; the rest of the targeting stays
  • geo.countries array of string
  • geo.country_groups array of string
    e.g. worldwide, europe
  • geo.excluded_countries array of string
  • geo.location_types array of "home" | "recent" | "travel_in"
    default: the ad set's current ones
  • status "ACTIVE" | "PAUSED"
  • confirm boolean
  • reason string · required · min length 1
    why, e.g. the owner's words; kept in the change log

get_results read-only

Start here for any "how is it going" question and before advising on performance. Insights per campaign/adset/ad over a date range (spend, impressions, cpm, reach, frequency, 2-second video views, cost_per_1k_views, ThruPlays, cost per ThruPlay, hook and hold: avg watch seconds and % of 2-second viewers reaching 25/50/75/100%, engagements, cost_per_engagement, leads (form leads + DM conversations started, split as form_leads / dm_leads), CPL; costs are null when there is nothing to divide by; leads are Meta's count, which can differ from leads the consumer received), platform per row (facebook or instagram when its ad sets place on that one app, else null), vs_norm per row: the row's key cost for its stage (warmup: cost_per_1k_views; target ThruPlay or engagement: cost_per_thruplay; target leads or DMs: cpl) against the median of this account's ad sets of the same stage over the last 30 days, with n ad sets, events and pct_better (above 0 = cheaper; too_few_events below events_needed; vs_norm null when the stage is not recorded or the goal has no norm) plus review status and, per active lifetime-budget ad set, delivery: start/end (UTC), hours run and left, share of budget spent vs share of the run gone (pace), learning status (Meta's, else berg.ad's estimate from optimization events in 7 days vs ~50), audience size, a targeting summary, plus access warnings (token expiry, Meta data-access window, missing permissions) and lead_health (leads Meta has per form in the last 2 days vs leads delivered; a stopped or failing lead fetch), and a flag (too_early / on_pace / under_delivering / ahead), and outside_changes: changes people made to the ad account in the date range outside berg.ad (e.g. in Ads Manager: who, what, old and new value). Read playbook §8 before acting on a flag. For per-post, daily or hourly detail (e.g. which hours an ad set delivered), use get_post_stats with hourly: true; its rows carry adset_id. A DISAPPROVED / WITH_ISSUES item means: stopped: needs a person; do not edit and resubmit.

  • level "campaign" | "adset" | "ad" · default "ad"
  • since string · required · pattern ^\d{4}-\d{2}-\d{2}$
  • until string · required · pattern ^\d{4}-\d{2}-\d{2}$
  • campaign_ids array of string

get_post_stats read-only

The detail view next to get_results (the overview). Use it for: paid vs organic on a post (organic = the post's own totals minus these paid numbers; join on source_post_id), how a post did per audience (adsets), and when an ad set delivered (hourly: true, rows carry adset_id). Paid numbers per promoted post. Rows are split by placement (platform): instagram counts on the IG media the ad ran (effective_instagram_media_id — ad-only media unless the ad was made from the IG post), every other placement on the FB post (effective_object_story_id). source_post_id is the organic post the ad was made from (FB post id, or IG media id for ads made from an IG post). Each post is also split by ad set (adsets, with ad_ids and stage: warmup = outside the business's market, target = its market, null = not recorded; set with set_campaign_purpose), i.e. by audience. Dates are in the ad account time zone. hourly: true adds per-ad hourly rows in UTC (with the local date/hour); Meta cannot split hours by placement, so an hourly row's platform is the one placement its ad ran on in the range, else all. Max 7 days with hourly. leads here are Meta's count of form leads + DM conversations started (form_leads / dm_leads); the consumer's received leads are the truth for lead counts.

  • since string · required · pattern ^\d{4}-\d{2}-\d{2}$
  • until string · required · pattern ^\d{4}-\d{2}-\d{2}$
  • hourly boolean · default false

set_campaign_purpose writes

Record a campaign's stage (warmup / target), what it is for and how to judge it (stored in berg.ad, not Meta). get_results returns it on every row, so results are read against the purpose: e.g. a warmup campaign's viewers are not the audience and are never retargeted. Use for campaigns created outside create_lead_ad or to correct one.

  • campaign_id string · required
  • stage "warmup" | "target" · required
    warmup = shown outside the business's market (e.g. worldwide views for social proof; never its audience, never retargeted); target = the business's market
  • purpose string · required · min length 1
  • success_metric string

report_lead_outcomes writes

Record what became of leads berg.ad delivered (the consumer's own follow-up): accepted / rejected / no_answer / booked / sold, with sale value in minor units. Meta only knows form submissions; these outcomes turn cost per lead into cost per booking in get_results (level ad). Upserts by lead_id; only this consumer's leads.

  • outcomes array of object · required · min items 1, max items 99
  • outcomes[].lead_id string · required
  • outcomes[].status "accepted" | "rejected" | "no_answer" | "booked" | "sold" · required
  • outcomes[].value_minor integer · ≥ 0
  • outcomes[].note string · max length 500

get_autopilot read-only

The autopilot: its rules (which new posts get which runs: stage, objective, budget, delay, hours, geo, form, ad text), whether it is on, the most it can spend in a month vs the maximum the owner confirmed, the last cron tick, and a preview: which recent posts qualify now, each run's start, end and budget, and what was already created; rotations: each rotating run's ad set this period and its ads.

No parameters.

set_autopilot writes (destructive)

Set the autopilot's rules and/or turn it on or off. Rules are JSON (get_autopilot shows them with defaults filled in). Partial rules merge: posts fields merge into the current posts; runs, when given, replace all runs. Lowering the maximum takes effect at once. Enabling, or rules that raise the monthly maximum while it is on, needs confirm:true after the owner said yes to the stated maximum; without it nothing changes and the maximum and preview come back. Disabling needs no confirm. Each activation still runs monthly_cap and max_daily.

  • rules object of any
    { posts: { source ("facebook" | "instagram" or both; default both), media, max_age_hours, caption_match, max_per_week }, runs: [{ key, stage, objective, budget, cost_cap (optional, minor units, not REACH), delay_hours, hours, geo, platforms (optional; default the post's own app), ... } per post, or { key, mode: "rotating", audience: "fans_90", budget (per period), cost_cap (optional, THRUPLAY only), period_days, max_ads, objective: "REACH" | "THRUPLAY", frequency_cap (REACH; default 1 per 7 days) } always-on retargeting] }
  • enabled boolean
  • confirm boolean

sync_fan_audience writes

Create or update the fan audiences (30 and 90 days): people who watched 15 seconds of a video of a post that ran target-stage ads; warmup-only posts are left out. Reads each audience back and fixes drift; recreates a deleted one. Returns the audience ids, video count and size. The autopilot runs it daily. Use the audiences only with market geo. Spends nothing.

No parameters.

list_audiences read-only

The ad account's custom audiences: id, name, subtype, size (Meta's approximate range), retention days, created time, delivery and operation status. fan marks berg.ad's fan audiences (days, video count, last sync, bb_share: whether it is shared with Boosterberg's ad account). in_market: true also estimates each audience's size inside the consumer's market, best-effort: 'not estimated' when Meta gives no estimate. Read-only.

  • in_market boolean · default false

review_posts read-only

Bill's weekly look at recent Facebook posts (default 14 days, max 30 posts): per post paid ThruPlays, cost per ThruPlay, average watch seconds, hold %, 2-second views, leads (form + DM; cost per lead only from 50 leads; below that 'too few leads to judge'), split by stage (warmup / target), plus the post's own totals. Classifies each post as screening / nurture / ask from its caption (class_rule says how; pass classes to override). amplify: 3-5 screening or nurture posts worth more money, ranked by seconds watched per unit of in-market cost per ThruPlay, each with its reason and a ready adjust suggestion (add_budget ~20%, end_time when it ends within 4 days). Read-only: it never runs adjust.

  • days integer · default 14 · ≥ 1, ≤ 30
  • classes object of "screening" | "nurture" | "ask"
    post_id -> class, where you read a caption differently
  • experts array of string · min items 1, max items 5
    a second opinion: these experts' sections come back next to the same numbers (list_playbooks)

health_check read-only

Bill's guardrails as checks, each {check, status: ok|warn|problem|unknown, value, rule, action}: spend vs the monthly cap and the month gone, fan audience size and growth against what the cap can nurture, 30-day frequency on fan retargeting (3-5), the Meta connection, days since the last post (7+ is a problem), lead delivery, the autopilot (last tick, failed and skipped runs), changes made outside berg.ad in 7 days, and lead ad sets paused or re-budgeted before 50 leads. actions lists what to do, problems first. Read-only.

No parameters.

plan_campaigns read-only

Bill's plan for the month, computed, never improvised: the stage (seasoning below 1,000 fans; growing until a week of retargeting reaches 60% of fans at 2x; nurturing until 30-day fan frequency reaches 3; then asking), the monthly cap split across views / grow / retarget / ask by the walkthrough's % per stage (minor units, adding up to the cap), how many posts each share buys at the current autopilot prices and the proposed per-post amounts, Bill's frequency maths (fans x 4 / 1000 x in-market CPM of the last 30 days = the monthly retargeting cost; below 3 a month the budget is too small), and proposed autopilot rules (the ask run only on ask posts: caption_match {class: 'ask'}). Retargeting is one always-on rotating run (one ad set a period showing the fans the newest posts). stage overrides the rules (else the profile's stage hint). Read-only: the owner says yes, then set_autopilot takes the rules with confirm.

  • stage "seasoning" | "growing" | "nurturing" | "asking"
    override the stage the numbers give, e.g. when the owner knows better
  • experts array of string · min items 1, max items 5
    a second opinion: these experts' sections come back next to the same numbers (list_playbooks)

weekly_report read-only

The Monday report (walkthrough Routine 5) as data plus a plain-text summary to send: cap used vs month gone, spend / ThruPlays / leads per stage over the last 7 days, fan growth, leads and their outcomes, what the autopilot started or skipped (with reasons), the adjust / set_status changes made, what needs the owner's yes (health_check actions, review_posts picks), and the owner's move. week (any date of that week) returns the report the cron stored that Monday. Read-only.

  • week string · pattern ^\d{4}-\d{2}-\d{2}$
    a past week: any date in it (YYYY-MM-DD); omit for now

get_benchmarks read-only

The funnel in percentages (walkthrough): views → ThruPlay (15s), ThruPlay → local fan, fan → DM/lead per month, lead → booking, over the last 30 days of daily snapshots (the cron writes one a day after 04:00 UTC for consumers with autopilot on). Per step: own rate and events, the cohort rate (same vertical and area, stepping up to region, country, then all businesses in the country; the narrowest level with 5+ opted-in businesses, each with 20+ events at the step; a trimmed mean and n only, never another business's numbers), the blended rate (own weight = events / (events + 50)), and the weakest step against its benchmark. Below 5 businesses: "not enough businesses yet (n of 5)". Needs vertical, area, region and country in the business profile. Read-only, no Meta call.

No parameters.

save_business_profile writes

Store the owner's answers Bill needs before planning: avatar (who loves the business and why), offer, qualifiers, buying cycle days (default 90), language, posting cadence, sales capacity (who answers leads and how fast); the cohort for get_benchmarks: vertical (stored as a lowercase slug), area (city or metro), region (state), country (ISO code), cohort_opt_in (default true: its rates count, only inside averages of 5+ businesses, in others' benchmarks); and on the consumer itself: market (as set_consumer_settings), special ad category (new campaigns use it) and monthly budget (the monthly cap). Partial answers merge; null clears one. Raising the monthly cap changes nothing without confirm:true after the owner's yes to the new amount; a first or lower cap applies at once. Returns the profile and the required answers still missing. Spends nothing.

  • avatar string | null
  • offer string | null
  • qualifiers array of string | null
  • buying_cycle_days integer | null
  • language string | null
  • posting_cadence string | null
  • sales_capacity string | null
  • stage "seasoning" | "growing" | "nurturing" | "asking" | null
  • vertical string | null
  • area string | null
  • region string | null
  • country string | null
  • cohort_opt_in boolean | null
  • market object | null
    where the business sells, as set_consumer_settings takes it; null clears it
  • special_ad_category "NONE" | "HOUSING" | "EMPLOYMENT" | "FINANCIAL_PRODUCTS_SERVICES" | "ISSUES_ELECTIONS_POLITICS"
    Meta's special ad category; new campaigns use it
  • monthly_budget integer
    the consumer's monthly cap, minor units of the ad account currency; raising it needs confirm:true after the owner's yes to the new amount
  • confirm boolean

get_business_profile read-only

The owner's stored answers (save_business_profile) and missing: the required answers not given yet. Interview for those before planning (playbook §0).

No parameters.

graph read-only

Read-only escape hatch to the Graph API: GET under this consumer's ad account, page, Instagram account or their objects. Use it only when no other tool returns the field you need; it never returns leads. Writes go through the other tools.

  • path string · required
    e.g. /act_123/adsets or /<adset_id>
  • params object of any

Changelog

2026-10-18

  • A lead whose POST failed (non-2xx, timeout, network error) is retried by the 15-minute cron, 10 attempts over about 2 days; a lead can arrive more than once, dedupe on lead_id.
  • Each ad runs on its own post's app: a Facebook post on Facebook placements, Instagram media on Instagram placements (platforms names both to run on both). Autopilot promotes Instagram media too.
  • The lead webhook payload carries platform; get_results and get_post_stats rows name theirs.
  • get_results and get_post_stats return currency, the ad account's.
  • get_results computes every cost (null when there is nothing to divide by) and judges each row's key cost against its own stage's 30-day norm (vs_norm).
  • Optional cost_cap on create_lead_ad and autopilot runs: Meta's cost cap bid strategy on the ad set.
  • berg.ad and www.berg.ad redirect to mcp.boosterberg.com. These docs, at docs.berg.ad and /docs.

2026-10-17

  • OAuth sign-in, so ChatGPT and claude.ai connect with the berg.ad key. The bare address https://mcp.boosterberg.com is the MCP URL (/mcp still works).
  • Read keys: a key limited to named read-only tools.
  • Leads are also fetched from each page's forms every 15 minutes, beside Meta's webhook.
  • Autopilot and fan audiences; no weekly limit option (posts funded in posting order until the month is used); always-on retargeting (rotating runs); posts.exclude_match.
  • get_results: delivery pace, learning status, audience size and targeting per ad set, lead health, outside changes, hook and hold.
  • adjust, list_audiences, review_posts, health_check, business profile, plan_campaigns, weekly_report, expert playbooks, cohort benchmarks.
  • DM ads (objective: "MESSAGES"); DM conversations count as leads. create_lead_ad ad_text; default form, list_forms, market (warmup kept outside it).

2026-10-16

  • The lead webhook: Meta leads forwarded, signed, to the business's own URL.
  • get_post_stats: paid numbers per promoted post, split by placement and audience.

2026-10-15

  • berg.ad V1: the MCP server for Meta lead forms and ads.