Skip to content

Commit d3c8e9b

Browse files
authored
Integrate Meta Ads (#87)
* fix buyer booking flow with seller * meta ad booking flow using graph api * GAM and Meta Reporting Integration * naming refactor * docs for meta ads and Reporting * fallback allocation if llm fails * Access GAM report via seller agent * lint formatting and unit test fixes * replace cli with api * Fix lint (ruff format + E501 + I001) and CrewAI >=1.14 Flow.__init__ state kwargs * Fix 7 test failures introduced by Meta integration merge - test_deal_library_agent_has_memory / test_linear_tv_agent_has_memory: patch settings.crew_memory_enabled=True inside each test so the assertion holds regardless of the .env default (CREW_MEMORY_ENABLED=false) - test_approve_specific_recommendations / test_approve_all_recommendations: mock _book_via_seller_api — this method now makes real HTTP calls after the Meta integration added it to _execute_bookings; integration tests must not reach out to a live seller URL - test_execute_bookings_no_store / test_execute_bookings_persists_records: same mock for the same reason - test_crew_with_memory_true: skipif crewai[bedrock] not installed locally; crewai[bedrock] is in core deps so CI runs the test normally * Fix DealBookingFlow.__init__ state kwargs for crewai >=1.15 crewai 1.15 restructured Flow as a BaseModel — extra kwargs passed to super().__init__() are silently dropped by pydantic instead of being forwarded to _initialize_state. Fix: call super().__init__() with no kwargs, then apply state_kwargs directly via setattr on self.state. This works across all crewai versions. * fix channel allocation: add social to BudgetAllocationOutput and restore LLM-driven split with equal-split fallback * restore crew_memory_enabled=True to match main branch default * Fix booking mock and ruff format drift exposed by PR #110 merge test_spend_ceiling.py's budget-ceiling tests used a bare MagicMock client, which broke once DealBookingFlow's booking path (from the Meta integration work) started reading a real base_url and issuing HTTP calls via _book_via_seller_api. Mock that call the same way the rest of the suite does. Also applies a ruff-format-only fix to helpers.py that was already failing `ruff format --check` on main. * Fix get_latest_pacing_snapshot to order by insertion time, not business time PacingStore.get_latest_pacing_snapshot() ordered by the timestamp column, which is caller-supplied business/simulated time and not guaranteed to be monotonic with write order. The campaign demo's READY-stage snapshot is stamped with real wall-clock time while its later ACTIVE-stage snapshot uses a simulated "35% through flight" timestamp — once real time passes that simulated point, the stale zero-value READY snapshot outranks the real one, and every reporting consumer (campaign_report.py, mcp_server.py) reads zero spend for an active campaign. created_at already records true insertion order and is the correct column to sort by.
1 parent 162fd7f commit d3c8e9b

27 files changed

Lines changed: 1958 additions & 276 deletions

.env.example

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,28 @@ POSTGRES_POOL_MAX=10
4444
ENVIRONMENT=development
4545
LOG_LEVEL=INFO
4646

47+
# CrewAI
48+
CREW_MEMORY_ENABLED=true
49+
50+
# =============================================================================
51+
# Meta Ads API Integration
52+
# =============================================================================
53+
# System user access token — generate in Meta Business Manager:
54+
# Business Settings → System Users → <your system user> → Generate Token
55+
# Required scopes: ads_management, ads_read, business_management
56+
META_ACCESS_TOKEN=
57+
58+
# Ad account ID — assign system user to ad account first:
59+
# Business Settings → System Users → Add Assets → Ad Accounts
60+
# Format: act_XXXXXXXXX
61+
META_AD_ACCOUNT_ID=
62+
63+
# Facebook Page ID — Business Manager → Pages → click page → copy ID from URL
64+
META_PAGE_ID=
65+
66+
# Graph API version (for reach estimates)
67+
META_API_VERSION=v21.0
68+
4769
# IAB Diligence Platform Integration (via SafeGuard Privacy)
4870
# optional; inert when SGP_API_KEY is empty
4971
SGP_API_KEY=

docs/integration/meta-ads.md

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# Meta Ads Integration
2+
3+
The buyer agent integrates with the Meta Marketing API to book and report on social channel campaigns across Facebook and Instagram. This page covers authentication setup, the booking flow, and reporting endpoints.
4+
5+
The buyer agent calls `graph.facebook.com` directly using a system user access token. This is the same endpoint used by the official Meta Marketing API SDKs — no browser OAuth is required.
6+
7+
## Configuration
8+
9+
| Variable | Type | Default | Description |
10+
|---|---|---|---|
11+
| `META_ACCESS_TOKEN` | `str` | `""` | System user access token from Meta Business Manager |
12+
| `META_AD_ACCOUNT_ID` | `str` | `""` | Ad account ID — format `act_XXXXXXXXX` |
13+
| `META_PAGE_ID` | `str` | `""` | Facebook Page ID — required for ad creative creation |
14+
| `META_API_VERSION` | `str` | `v21.0` | Meta Graph API version |
15+
16+
Add these to your `.env` file:
17+
18+
```bash
19+
META_ACCESS_TOKEN=your-system-user-token
20+
META_AD_ACCOUNT_ID=act_XXXXXXXXX
21+
META_PAGE_ID=XXXXXXXXX
22+
META_API_VERSION=v21.0
23+
```
24+
25+
### Generating a System User Token
26+
27+
1. Open **Meta Business Manager → Business Settings → System Users**
28+
2. Create or select a system user
29+
3. Click **Generate Token** → select your app
30+
4. Required scopes: `ads_management`, `ads_read`, `business_management`
31+
5. Assign the system user to your ad account: **Business Settings → System Users → Add Assets → Ad Accounts**
32+
33+
### Installation
34+
35+
```bash
36+
pip install -e ".[meta]"
37+
```
38+
39+
!!! note "Sandbox accounts"
40+
Meta provides sandbox ad accounts for development. Campaigns created in a sandbox account are API-only and not visible in the Ads Manager UI. They are created in `PAUSED` state and never serve impressions.
41+
42+
---
43+
44+
## Booking Flow
45+
46+
When a booking brief includes `"channels": ["social"]` or `"channels": ["meta"]`, the buyer agent routes through the Meta booking path.
47+
48+
```mermaid
49+
sequenceDiagram
50+
participant Buyer as Buyer Agent
51+
participant Meta as graph.facebook.com
52+
53+
Buyer->>Meta: GET /{account}/reachestimate
54+
Meta-->>Buyer: Reach + CPM estimates
55+
56+
Note over Buyer: Awaiting approval
57+
58+
loop Per placement (Instagram Reels, Facebook Feed, etc.)
59+
Buyer->>Meta: POST /act_{id}/campaigns
60+
Meta-->>Buyer: campaign_id (PAUSED)
61+
62+
Buyer->>Meta: POST /act_{id}/adsets
63+
Meta-->>Buyer: ad_set_id (PAUSED)
64+
end
65+
```
66+
67+
### Research Phase
68+
69+
The `SocialCrew` uses `MetaInventoryTool` to call `GET /{account}/reachestimate` and estimate reach and CPM for four placements:
70+
71+
- Instagram Reels
72+
- Facebook Video Feeds
73+
- Instagram Feed
74+
- Facebook Feed
75+
76+
If the reach estimate API returns an error, the tool falls back to static estimates.
77+
78+
### Booking Phase
79+
80+
After the user approves recommendations, the buyer agent creates two resources per placement:
81+
82+
| Step | API Call | Status |
83+
|---|---|---|
84+
| 1 | `POST /act_{id}/campaigns` | PAUSED |
85+
| 2 | `POST /act_{id}/adsets` | PAUSED |
86+
87+
!!! note "Creative step"
88+
Ad creative creation (step 3) requires an uploaded image asset. This step is skipped — campaign and ad set creation is sufficient to confirm booking.
89+
90+
### Objective Mapping
91+
92+
| IAB Objective | Meta Objective |
93+
|---|---|
94+
| `brand_awareness`, `reach` | `OUTCOME_AWARENESS` |
95+
| `traffic` | `OUTCOME_TRAFFIC` |
96+
| `conversions` | `OUTCOME_SALES` |
97+
| `video_views` | `OUTCOME_ENGAGEMENT` |
98+
| `lead_generation` | `OUTCOME_LEADS` |
99+
100+
### Budget Allocation
101+
102+
The `PortfolioCrew` LLM allocates budget across channels based on campaign objectives, audience fit, and KPIs. If `channels` is specified in the brief, the LLM is instructed to allocate only to those channels:
103+
104+
```json
105+
{
106+
"channels": ["branding", "ctv", "social"],
107+
"budget": 15000
108+
}
109+
```
110+
111+
The LLM will distribute the `$15,000` across `branding`, `ctv`, and `social` based on which best fits the campaign objectives.
112+
113+
### Example Booking Request
114+
115+
```
116+
POST /bookings
117+
Content-Type: application/json
118+
```
119+
120+
```json
121+
{
122+
"brief": {
123+
"name": "Summer Campaign 2026",
124+
"objectives": ["brand_awareness", "reach"],
125+
"budget": 5000,
126+
"start_date": "2026-06-01",
127+
"end_date": "2026-06-30",
128+
"channels": ["social"],
129+
"target_audience": {
130+
"demographics": {"age": "18-45"},
131+
"interests": ["technology", "gaming"]
132+
}
133+
},
134+
"auto_approve": false
135+
}
136+
```
137+
138+
Poll `GET /bookings/{job_id}` until `status: awaiting_approval`, then approve:
139+
140+
```
141+
POST /bookings/{job_id}/approve-all
142+
```
143+
144+
Booked lines for the social channel will have `booking_status: "paused"`.
145+
146+
---
147+
148+
## Reporting
149+
150+
### List Campaigns
151+
152+
Returns all campaigns in the ad account — no booking job ID required.
153+
154+
```
155+
GET /meta/campaigns?limit=10
156+
```
157+
158+
| Parameter | Type | Default | Description |
159+
|---|---|---|---|
160+
| `limit` | `int` | `10` | Number of campaigns to return |
161+
162+
```json
163+
{
164+
"ad_account_id": "act_XXXXXXXXX",
165+
"campaigns": [
166+
{
167+
"id": "23856xxxxxxxxx",
168+
"name": "Summer Campaign 2026 — Instagram Reels",
169+
"effective_status": "PAUSED",
170+
"objective": "OUTCOME_AWARENESS",
171+
"daily_budget": "125000",
172+
"created_time": "2026-05-11T15:35:59+0530"
173+
}
174+
],
175+
"count": 10
176+
}
177+
```
178+
179+
### Campaign Report
180+
181+
Returns campaign details combined with delivery insights for one or more campaign IDs.
182+
183+
```
184+
GET /meta/report?campaign_ids=CAMPAIGN_ID_1,CAMPAIGN_ID_2&date_preset=last_30d
185+
```
186+
187+
| Parameter | Type | Default | Description |
188+
|---|---|---|---|
189+
| `campaign_ids` | `string` | required | Comma-separated Meta campaign IDs |
190+
| `date_preset` | `string` | `last_30d` | `last_7d` / `last_14d` / `last_30d` / `last_90d` |
191+
192+
```json
193+
{
194+
"ad_account_id": "act_XXXXXXXXX",
195+
"date_preset": "last_30d",
196+
"campaigns": [
197+
{
198+
"campaign_id": "23856xxxxxxxxx",
199+
"campaign_name": "Summer Campaign 2026 — Instagram Reels",
200+
"status": "PAUSED",
201+
"objective": "OUTCOME_AWARENESS",
202+
"daily_budget": "125000",
203+
"created_time": "2026-05-11T15:35:59+0530",
204+
"spend": 0.0,
205+
"impressions": 0,
206+
"reach": 0,
207+
"frequency": 0.0,
208+
"clicks": 0,
209+
"ctr": 0.0,
210+
"cpm": 0.0
211+
}
212+
],
213+
"summary": {
214+
"total_spend": 0.0,
215+
"total_impressions": 0,
216+
"total_clicks": 0,
217+
"total_reach": 0
218+
}
219+
}
220+
```
221+
222+
### Job-Scoped Report
223+
224+
To report on all campaigns booked within a specific job:
225+
226+
```
227+
GET /reports/{job_id}?date_range=last_30d
228+
```
229+
230+
The buyer agent automatically identifies Meta campaign IDs from `booked_lines` and pulls insights for each. IAB OpenDirect order IDs in the same job are routed to the seller agent's delivery performance endpoint. See [Bookings API](../api/bookings.md) for details.
231+
232+
!!! tip "Access token security"
233+
The Meta access token is never exposed in API error responses. It is automatically redacted to `***` before any error message reaches the HTTP response.
234+
235+
---
236+
237+
## Related
238+
239+
- [Seller Agent Integration](seller-agent.md) --- How buyer and seller agents communicate
240+
- [Bookings API](../api/bookings.md) --- Full booking flow reference
241+
- [Configuration Reference](../guides/configuration.md) --- All environment variables

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ nav:
105105
- Integration:
106106
- Seller Agent Guide: integration/seller-agent.md
107107
- OpenDirect Protocol: integration/opendirect.md
108+
- Meta Ads: integration/meta-ads.md
108109
- IAB Diligence Platform Approval: integration/iab-diligence-platform.md
109110
- AI Assistant Setup:
110111
- Claude (Desktop & Web): claude-desktop-setup.md

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ dependencies = [
2222
]
2323

2424
[project.optional-dependencies]
25+
meta = [
26+
"meta-ads-cli>=0.1.0",
27+
]
2528
dev = [
2629
"pytest>=8.0.0",
2730
"pytest-asyncio>=0.24.0",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Author: Green Mountain Systems AI Inc.
2+
# Donated to IAB Tech Lab
3+
4+
"""Social media channel specialist agent (Meta Ads)."""
5+
6+
from crewai import Agent
7+
8+
from ...config.settings import settings
9+
10+
11+
def create_social_agent() -> Agent:
12+
"""Create the Social Media Specialist agent for Meta Ads campaigns."""
13+
return Agent(
14+
role="Social Media Advertising Specialist",
15+
goal=(
16+
"Identify the best Meta Ads placements (Facebook, Instagram, Audience Network) "
17+
"for campaigns targeting social media audiences. Evaluate reach, CPM, and "
18+
"audience alignment. Only recommend placements actually returned by the "
19+
"search_meta_placements tool."
20+
),
21+
backstory=(
22+
"Expert in Meta Ads ecosystem with deep knowledge of Facebook Feed, Instagram Reels, "
23+
"Stories, and Audience Network inventory. Skilled at matching campaign objectives "
24+
"(brand awareness, reach, conversions) to optimal Meta placements "
25+
"and bidding strategies."
26+
),
27+
llm=settings.manager_llm_model,
28+
verbose=settings.crew_verbose,
29+
allow_delegation=False,
30+
max_iter=settings.crew_max_iterations,
31+
)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Author: Green Mountain Systems AI Inc.
2+
# Donated to IAB Tech Lab
3+
4+
"""Meta Ads reach-estimate client — Graph API reach estimates only.
5+
6+
Used in the research phase to estimate audience reach + CPM before booking.
7+
All other operations (booking, reporting, lifecycle) use MetaAdsClient
8+
(meta_ads_client.py).
9+
"""
10+
11+
import json
12+
13+
import httpx
14+
15+
16+
class MetaAdsAPIClient:
17+
"""Graph API httpx client scoped to reach estimation.
18+
19+
Calls graph.facebook.com/v{version}/{account}/reachestimate
20+
directly using a system user access token.
21+
"""
22+
23+
def __init__(
24+
self,
25+
access_token: str,
26+
ad_account_id: str,
27+
api_version: str = "v21.0",
28+
):
29+
self._token = access_token
30+
self._account_id = (
31+
ad_account_id if ad_account_id.startswith("act_") else f"act_{ad_account_id}"
32+
)
33+
self._base = f"https://graph.facebook.com/{api_version}"
34+
35+
def _get(self, path: str, params: dict | None = None) -> dict:
36+
all_params = {"access_token": self._token, **(params or {})}
37+
r = httpx.get(f"{self._base}/{path}", params=all_params, timeout=30.0)
38+
r.raise_for_status()
39+
return r.json()
40+
41+
def get_reach_estimate(
42+
self,
43+
targeting: dict,
44+
daily_budget: float,
45+
optimize_for: str = "REACH",
46+
) -> dict:
47+
"""Estimate reach for a targeting + daily budget combination.
48+
49+
Args:
50+
targeting: Graph API targeting spec:
51+
{
52+
"geo_locations": {"countries": ["US"]},
53+
"age_min": 25, "age_max": 54
54+
}
55+
daily_budget: Daily budget in USD (converted to cents internally)
56+
optimize_for: REACH | IMPRESSIONS | LINK_CLICKS
57+
58+
Returns:
59+
{ "users_lower_bound": int, "users_upper_bound": int,
60+
"estimate_ready": bool }
61+
"""
62+
return self._get(
63+
f"{self._account_id}/reachestimate",
64+
{
65+
"targeting_spec": json.dumps(targeting),
66+
"optimize_for": optimize_for,
67+
"daily_budget": int(daily_budget * 100),
68+
},
69+
)
70+
71+
def get_ad_account(self) -> dict:
72+
"""Get ad account metadata — name, currency, timezone."""
73+
return self._get(
74+
self._account_id,
75+
{"fields": "id,name,currency,timezone_name,account_status"},
76+
)

0 commit comments

Comments
 (0)