# TRPL Meta Ads — instructions for agents This repo is the planning and review dashboard for the Theodore Roosevelt Presidential Library's $150,000 Meta ad credit (expires 2027-01-20). Live site: https://ads.trlibrary.com (GitHub Pages, deployed by .github/workflows/pages.yml on every push to main). Owner: Matt Briney, CCMO (matt@trlibrary.com). Outline: TRPL / Meta Ad Credit — AdCampaigns Dashboard. ## The one rule NOTHING goes live in Meta without Matt approving that specific campaign by name in chat. - Proposed campaigns live only in this repo (data/campaigns.json) as mockups. - When Matt approves one, create it through the Meta Ads connector with status PAUSED, then show him and wait for a second OK before activating. - Put the plan ID in brackets at the end of the Meta campaign name, e.g. "TICKETS: Plan your summer trip [tix-summer]". The dashboard links live campaigns to proposals by that bracketed ID. - Never change budgets, targeting or status on a live campaign without Matt's OK. ## Account facts - Ad account: TRPL FB Grant, act_960679053196120 (business portfolio 1969051273778035). Ignore ad account 18555278; it is Matt's personal account. - Page: Theodore Roosevelt Presidential Library (144035749389560). Instagram: @trlibrary (17841426268646366). - Pixel/dataset: TRPL Website, 1069854392776858. Installed on www.trlibrary.com via Google Tag Manager GTM-N3888GS9 (tag "Custom HTML_META"). Shopify store sends events through the Facebook & Instagram channel (Enhanced: pixel + Conversions API). NOT yet on access.trlibrary.com (ACME ticketing/membership) — ACME is adding it. Until then, ticket and membership campaigns optimize for Landing page views, not Purchase. - Donations go to a DonorPerfect form (no pixel). Land giving ads on trlibrary.com/support-trpl. - Meta's US restriction on social issue/election/political ads runs Oct 27–Nov 3, 2026. Keep conservation, citizenship and civic copy out of that window and out of the special ad category; never name current political figures. ## Files - scripts/plan.py ............ SOURCE OF TRUTH for proposals. Edit here, then run it. - data/campaigns.json ........ generated by plan.py — do not hand-edit. - data/live.json ............. snapshot of what is really in the ad account (see "Refreshing live"). - creative/dam/ .............. Library photos pulled from the Acquia DAM press portal + catalog.json. - creative/video/ ............ thumbnails of the Commercials playlist (YouTube IDs in plan.py). - creative/video/vertical/ ... 9:16 cuts for Stories/Reels (+ .jpg poster, .json crop log). - creative/kennerly/ ......... stills from the In the Room preview video (temporary until real exhibit photos). - creative/events/ ........... event creative. atb/ = America the Beautiful media kit (trailer cut before the distributor end card in 4:5, 16:9 and 9:16; poster; stills). Source: the distributor's shared Drive folder. Local video files use media {type:"video", file, src(poster), feed:{file,src,ratio}, vertical:{src,poster}}. - scripts/vertical.py ........ makes 9:16 versions of 16:9 commercials (see below). - creative/saltscoria/ ....... Salt+Scoria food photos from saltandscoria.com (not yet in the DAM). - creative/brand/avatar.jpg .. profile image used in the mockups. - index.html, app.js, mock.js, styles.css — the static dashboard. mock.js draws the Facebook Feed, Instagram Feed and Stories/Reels mockups. ## Workflow: change or add a proposed campaign 1. Edit scripts/plan.py (use the camp(...) helper; each ad needs id, name, format, primary, headline/desc or cards, cta, url, media). 2. python3 scripts/plan.py && python3 scripts/validate.py (must report 0 errors) 3. Preview: python3 -m http.server 8000, open http://localhost:8000 4. Commit and push to main; the Action redeploys the site. ## Workflow: add photos - Public press portal (no token): python3 scripts/fetch_dam.py list python3 scripts/fetch_dam.py sheet action --out sheets → look at the contact sheets python3 scripts/fetch_dam.py get action - Full DAM (token): see Outline "Acquia DAM (Widen) — API Access". Never commit the token. - Preview URLs from Acquia are signed and expire; always download into creative/. - Use real Library photos only. No AI images, no architectural renderings. ## Workflow: vertical (9:16) video - YouTube blocks cloud IPs. Download on Matt's Mac (linked computer) with yt-dlp in ~/Developer/AdCampaigns/.work/venv (gitignored): yt-dlp --js-runtimes node -f "bv*+ba" -- (prefix IDs that start with "-" with "--"). The Library owns these commercials. - AV1 downloads must be transcoded to h264 first (ffmpeg -c:v libx264) or OpenCV can't read them. - python3 scripts/vertical.py src.mp4 creative/video/vertical/-9x16.mp4 --mode reframe [--fit-until 6 --fit-from 73.5] → crops each shot around the people in it; keeps titles and end cards as "fit" (whole frame on a blurred fill). Use --mode fit for videos with burned-in text (Resolute, Arena). Make a poster: ffmpeg -ss 5 -i out.mp4 -frames:v 1 out.jpg - Register the slug in VERT in plan.py; the Stories mock then plays the vertical cut. - An editor-made 15-second vertical edit beats both modes. Boardwalk and Discover are summer-heavy and feature the outgoing CEO; don't use them this winter. ## Season rule - creative/dam/catalog.json tags each photo season (summer/fall/any) and scene (indoor/outdoor). - Ads that run Nov 15–Mar 31 use indoor or fall/any photos. Summer photos only when the ad sells a summer trip, marked "sells": "summer" on the ad. validate.py warns otherwise. - Winter's pitch is the indoor galleries (eight chapters; names from trlibrary.com/visit/exhibits) and the temporary exhibition. Spring break/Easter and summer trips are sold to people farther away starting late December/January, when search interest starts climbing. ## Planning inputs (read before changing strategy) - Market Positioning Study (Outline: TRPL): best message is presidential history + the raw beauty of the Badlands; second is a regional journey with Mount Rushmore, Yellowstone and the national park; third is "step into history" through immersive exhibits. Barriers are cost, lodging and distance, so give prices, a 3½-hour visit and trip context. Core market is a 10-hour drive (ND, SD, MN, MT, WY, CO, NE, IA, IL), ages 35–54, $75K+. Building/ architecture messaging tested weakly; don't lead with the building. - Tourism data (tourismdata.labs.trlibrary.com, repo TourismData/docs/data): ticket buyers are ND 41%, MN 15%, MT 5%, SD 5%, then TX, WI, CO, CA, FL, AZ, IL. 78–100% of tickets sell online, so the ACME pixel matters. July/August ~100K tickets each vs ~11K in September. ## Testing and budget protection Every campaign in plan.py has spend.mode: - "test": for test_days every ad runs at test_frac of the planned daily (schedule phase "test"). On spend.decide_on, judge each ad on spend.metric once it has ~$75 or 1,500 impressions: pause ads over 1.5x the best ad's cost per result, keep the top two, then move to the "scale" phase daily and raise no more than 25% every 3 days while on target. - "burst": the moment is happening now or ends soon (exhibition closing, screenings, Giving Tuesday, Museum Store Sunday, holiday decor). All ads on from day one, Advantage+ placements, highest-volume bidding, spend it all. Move reserve in the same day if it delivers. - Guardrails (PLAN.guardrails, shown on Pacing): lifetime budgets equal to plan, account spending limit $150K, no budget raised >30% in 72h, pause CTR <0.4% after $100 or frequency >4/week regionally. - Daily check (weekday mornings once anything is live): refresh data/live.json, compare each campaign's spend to its schedule, list proposed changes (pause/scale/reserve moves) for Matt. Make no change without his OK. Log what was approved in the Outline Decision Log. - The reserve (plan.py RESERVE) funds test winners after week one and burst campaigns that deliver, so the full credit is spent by Jan 19. ## Refreshing live - Daily the Action runs scripts/sync_live.py if the repo secret META_ACCESS_TOKEN exists (system-user token with ads_read on the ad account). - Without that secret, refresh by hand: use the Meta Ads connector (ads_get_ad_entities, insights) and write data/live.json in the shape sync_live.py produces: {as_of, source, account:{id,name,currency,amount_spent}, campaigns:[{id,name,status, effective_status,objective,daily_budget,lifetime_budget,start_time,stop_time, insights:{spend,impressions,reach,clicks,ctr,results,result_type}, ads:[{id,name, effective_status, creative:{title,body,description,image_url,thumbnail_url,video_id, call_to_action_type,link}}]}]} Budgets are in cents (Meta's format); amount_spent is in dollars. ## Copy rules (validate.py enforces the first three) - No current or future years in ad copy ("next summer", not a year). - A Library visit is 3½ hours. Never imply less. The free final hour is a first look, not a visit. - No "stunning", "breathtaking", "world-class", "transformative", "amazing". - Short and direct. No throat-clearing. Friendly, never academic. - Quote Theodore Roosevelt only verbatim from a verified source; otherwise describe the idea. - Nonpartisan always. Free-final-hour ads carry the NEH credit line if grant-funded. - Prices and hours come from trlibrary.com/visit and /membership — re-check before launch. - Every ad links with UTMs: utm_source=meta&utm_medium=paid_social&utm_campaign=&utm_content=. ## Budget logic Total plan + reserve must equal $150,000 and every campaign must end by Jan 19. Priority is revenue (tickets, memberships, giving, store, Salt+Scoria), then the brand video that builds retargeting audiences. plan.py computes each campaign's daily schedule (test then scale, or flat burst); the Pacing tab charts it against the credit. The reserve is reassigned to the best performers (see "Testing and budget protection"). ## After any session that changes plans or the account Log one line in Outline: Decisions & Logs / Decision Log, format "YYYY-MM-DD · [TRPL] · executed · ", and update the Outline doc above.