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.
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.Claude (claude.ai)
Settings → Connectors → Add custom connector, the same address, then sign in with your key.
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/tokenwith 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"}
| Field | Meaning |
|---|---|
consumer | Your business's name in berg.ad. |
lead_id | Meta'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_id | The page the form belongs to. |
form_id, ad_id | The form and the ad the lead came from; null when Meta gave none. |
created_time | When 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_data | The 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. |
platform | The 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_error | Only 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,untilanddateare days in it.- Metrics in
totaland 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_idis 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. stageper ad set:warmup(outside the business's market),target(its market), ornull(not recorded).hourly: true(at most 7 days): per-ad rows withhour_utc,date,hour_local. Meta cannot split hours by placement, so an hourly row'splatformis 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,adsetorad). Costs arenullwhen there is nothing to divide by. platform:"facebook"or"instagram"when the row's ad sets place on that one app, elsenull.purpose: the stage (warmup/target) and what the campaign is for, fromcreate_lead_adorset_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_normisnullwhen the stage is not recorded or the goal has no norm.leads_received(levelad): leads berg.ad delivered, with the outcomes you reported (report_lead_outcomes) andcost_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",LEADSorMESSAGES): 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
platformscan name both. budgetis per post per run, a lifetime budget in minor units of the account currency. A run startsdelay_hoursafter the post (not before now) and lastshours.posts.max_per_weeklimits how many posts start in a rolling 7 days. Withmax_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_capper run (minor units, likebudget): the ad set is created with Meta'sCOST_CAPbid strategy and that bid amount, so Meta aims to keep the average cost per result under it. Not forREACH. 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_adis 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.
expertstring · 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_idstring · required · pattern ^[a-z0-9_-]{2,32}$namestring · required · min length 1, max length 80focusstring · required · min length 1, max length 300
one line: what this expert optimises forbodystring · 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.
expertstring · required · pattern ^[a-z0-9_-]{2,32}$advicestring · required · min length 1, max length 1000object_idstring · 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_idstring | null
null clears itmarketobject | null
null clears itdefault_expertstring | null
an expert_id from list_playbooks; null goes back to Billreport_emailstring | null
where the Monday report is emailed besides Boosterberg's operator; null clears itshare_audiences_with_bbboolean
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.
limitinteger · default10· ≥ 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.
namestring · required · min length 1questionsarray of object · min items 1
left out with qualifiers: true, built from the business profilequestions[].typestring · required
FULL_NAME, EMAIL, PHONE, CITY, ... or CUSTOMquestions[].keystringquestions[].labelstringquestions[].optionsarray of string
CUSTOM multiple choice answersquestions[].disqualifyarray of string
answers from options that mean the lead doesn't qualify, e.g. a budget too smallqualifiersboolean
true without questions: name, email, phone, then each get_business_profile qualifier as a short-answer questionnon_qualifierobject
end screen for people who give a disqualify answer; goes into manual_stepsnon_qualifier.titlestring · requirednon_qualifier.bodystring · requirednon_qualifier.button_textstringnon_qualifier.website_urlstringintroobjectintro.titlestring · requiredintro.bodystring · requiredthank_youobject · requiredthank_you.titlestring · requiredthank_you.bodystring · requiredthank_you.button_textstring · requiredthank_you.website_urlstring · requiredlocalestring · default"en_US"higher_intentboolean · defaultfalse
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.
namestring · required · min length 1objective"LEADS" | "ENGAGEMENT" | "VIDEO_VIEWS" | "REACH" | "MESSAGES" · requireddestination"MESSENGER" | "INSTAGRAM_DIRECT" | "MESSAGING_INSTAGRAM_DIRECT_MESSENGER"
MESSAGES only: where the conversation opens; default MESSENGER for a FB post, INSTAGRAM_DIRECT for an IG postgreetingstring · min length 1, max length 300
MESSAGES only: the welcome message people see when the conversation opensice_breakersarray of string · min items 1, max items 3
MESSAGES only, with greeting: up to 3 questions people can tap to startvideo_goal"TWO_SECOND" | "THRUPLAY" · default"TWO_SECOND"form_idstring
defaults to the consumer's default form for LEADS (list_forms)post_idstring · required
FB post id (pageid_postid) or IG media idsource"facebook" | "instagram" · default"facebook"targetingobject · requiredtargeting.geoobject · requiredtargeting.geo.countriesarray of stringtargeting.geo.country_groupsarray of string
e.g. worldwide, europetargeting.geo.excluded_countriesarray of stringtargeting.geo.location_typesarray of "home" | "recent" | "travel_in" · default["home","recent"]targeting.localesarray of integer
Meta locale ids, e.g. 1001 = English (All)targeting.age_mininteger · ≥ 13, ≤ 65targeting.age_maxinteger · ≥ 13, ≤ 65targeting.interestsarray of objecttargeting.interests[].idstring · requiredtargeting.interests[].namestring · requiredtargeting.advantage_audienceboolean · defaultfalsetargeting.custom_audience_idsarray of stringtargeting.excluded_custom_audience_idsarray of stringplatformsarray 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 bothlifetime_budgetinteger · required
minor units of the ad account currency (500 = 5.00)cost_capinteger
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_timestring · requiredend_timestring · requiredcta_type"SIGN_UP" | "LEARN_MORE" | "GET_QUOTE" | "APPLY_NOW" | "SUBSCRIBE" · default"SIGN_UP"ad_textstring · 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 marketpurposestring · required · min length 1
what this campaign is for, e.g. "social proof views before the EU run"success_metricstring
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.
idstring · requiredlevel"campaign" | "adset" | "ad" · requiredstatus"ACTIVE" | "PAUSED" | "ARCHIVED" · requiredconfirmboolean
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_idstring · requiredend_timestring
new end, ISO 8601, in the futurelifetime_budgetinteger
new lifetime total, minor units of the ad account currencyadd_budgetinteger
minor units added to the current lifetime budgetexpected_budgetinteger
with add_budget and confirm: the new total the preview returned; a retry or a budget changed since can't add twicefrom_budgetinteger · ≥ 0
with lifetime_budget and confirm: the budget the preview started fromgeoobject
replaces the geo; the rest of the targeting staysgeo.countriesarray of stringgeo.country_groupsarray of string
e.g. worldwide, europegeo.excluded_countriesarray of stringgeo.location_typesarray of "home" | "recent" | "travel_in"
default: the ad set's current onesstatus"ACTIVE" | "PAUSED"confirmbooleanreasonstring · 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"sincestring · required · pattern ^\d{4}-\d{2}-\d{2}$untilstring · required · pattern ^\d{4}-\d{2}-\d{2}$campaign_idsarray 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.
sincestring · required · pattern ^\d{4}-\d{2}-\d{2}$untilstring · required · pattern ^\d{4}-\d{2}-\d{2}$hourlyboolean · defaultfalse
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_idstring · requiredstage"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 marketpurposestring · required · min length 1success_metricstring
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.
outcomesarray of object · required · min items 1, max items 99outcomes[].lead_idstring · requiredoutcomes[].status"accepted" | "rejected" | "no_answer" | "booked" | "sold" · requiredoutcomes[].value_minorinteger · ≥ 0outcomes[].notestring · 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.
rulesobject 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] }enabledbooleanconfirmboolean
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_marketboolean · defaultfalse
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.
daysinteger · default14· ≥ 1, ≤ 30classesobject of "screening" | "nurture" | "ask"
post_id -> class, where you read a caption differentlyexpertsarray 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 betterexpertsarray 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.
weekstring · 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.
avatarstring | nullofferstring | nullqualifiersarray of string | nullbuying_cycle_daysinteger | nulllanguagestring | nullposting_cadencestring | nullsales_capacitystring | nullstage"seasoning" | "growing" | "nurturing" | "asking" | nullverticalstring | nullareastring | nullregionstring | nullcountrystring | nullcohort_opt_inboolean | nullmarketobject | null
where the business sells, as set_consumer_settings takes it; null clears itspecial_ad_category"NONE" | "HOUSING" | "EMPLOYMENT" | "FINANCIAL_PRODUCTS_SERVICES" | "ISSUES_ELECTIONS_POLITICS"
Meta's special ad category; new campaigns use itmonthly_budgetinteger
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 amountconfirmboolean
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.
pathstring · required
e.g. /act_123/adsets or /<adset_id>paramsobject 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 (
platformsnames both to run on both). Autopilot promotes Instagram media too. - The lead webhook payload carries
platform;get_resultsandget_post_statsrows name theirs. get_resultsandget_post_statsreturncurrency, the ad account's.get_resultscomputes 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_caponcreate_lead_adand autopilot runs: Meta's cost cap bid strategy on the ad set. berg.adandwww.berg.adredirect tomcp.boosterberg.com. These docs, atdocs.berg.adand/docs.
2026-10-17
- OAuth sign-in, so ChatGPT and claude.ai connect with the berg.ad key. The bare address
https://mcp.boosterberg.comis the MCP URL (/mcpstill 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_adad_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.