An agentic, LLM-powered campaign monitoring system built with LangGraph and Claude (via AWS Bedrock) that continuously watches programmatic ad campaigns, diagnoses delivery issues using AI reasoning, takes autonomous corrective actions through tool-calling, and escalates high-risk decisions to campaign managers with data-driven narratives.
- Architecture
- How It Works
- Why LLM-Powered (Not Just Rules)
- Agent Deep-Dive
- Campaign Simulator (Dynamic Mock DSP)
- Configuration System
- Cooldown System
- Campaign Manager Notifications
- LLM Configuration
- Tool System
- LangGraph Workflow Engine
- Display Module
- Campaign Parameters Tracked
- Setup and Running
- Sample Output
- Project Structure
- Cost Estimates
- How to Extend
The system is implemented as a LangGraph StateGraph with conditional routing. Three of the four agents are powered by Claude Opus 4.6 on AWS Bedrock with native tool-calling support:
+-----------------------------------------------------+
| LangGraph StateGraph Pipeline |
+-----------------------------------------------------+
+-----------+ +-------------+ +-------------------+
| Fetch |----->| Monitor |----->| Diagnosis |
| Campaigns | | Agent | | Agent (LLM) |
| (API/Sim) | | (Rules) + | | Claude + Tools |
+-----------+ | Cooldown | +--------+----------+
| Filter | |
+------+------+ |
| v
| no alerts +----------------+
| (or all in | Action |
| cooldown) | Agent (LLM) |
| | Claude + Tools |
| +-------+--------+
| |
| +------------+------------+
| | | |
| v v v
| Autonomous Cooldown Bid-Change
| Actions Recorded Escalations
| Executed (2 min)
| | |
| v v
| +----------------+ +---------------+
| | CM Notif (FYI) | | Escalation |
| | Actions Taken | | Agent (LLM) |
| +----------------+ | Claude Writer |
| +-------+-------+
| |
v v
END END
State: { campaigns, alerts, cooldown_skipped, diagnoses, actions_executed,
escalations, action_reasoning, action_notifications, notifications }
Conditional edges:
| From | Condition | Routes to | Rationale |
|---|---|---|---|
| Monitor | alerts list is empty (after cooldown filter) |
END |
No actionable issues = no LLM calls |
| Monitor | alerts list non-empty |
Diagnosis |
Issues found on eligible campaigns |
| Action | escalations is empty |
END |
No bid changes = no notifications |
| Action | escalations non-empty |
Escalation |
Bid changes need manager approval |
-
Continuous Loop --
main.pycompiles the LangGraph workflow and invokes it every 60 seconds (configurable inconfig.yaml) in awhile Trueloop. Each invocation is one cycle. -
State Initialization -- Every cycle starts with an empty
PipelineState. Thefetchnode calls the campaign simulator to pull the latest metrics for all 6 campaigns. -
Monitor (Rules) + Cooldown Filter -- The monitor agent scans all campaigns against 11 threshold-based checks. Any violation generates an alert. Then the cooldown filter separates alerts into two buckets:
- Actionable alerts: Campaigns NOT in cooldown -- proceed to Diagnosis.
- Cooldown-skipped alerts: Campaigns still cooling down from previous actions -- displayed but not acted on.
-
Short-circuit -- If no actionable alerts remain (either no issues, or all flagged campaigns are in cooldown), the graph routes directly to
END-- no LLM calls, no cost. -
Diagnosis (Claude LLM + Tools) -- Claude receives the actionable alerts and full campaign data. It reasons about causality across multiple interacting signals, optionally calling read-only tools to inspect campaigns in detail. It outputs structured JSON with root cause, contributing factors, and recommended actions.
-
Action (Claude LLM + Tool-Calling) -- Claude enters an agentic tool-calling loop (up to 15 rounds). It inspects campaigns, executes autonomous actions (expand_audience, rotate_creative, adjust_frequency_cap, adjust_daily_budget), and flags bid changes for human approval (recommend_bid_change). After execution:
- Cooldown is recorded for every acted-on campaign (default 2 minutes).
- FYI notifications are generated for the campaign manager listing all autonomous actions taken.
-
Escalation (Claude LLM) -- For bid-change recommendations, Claude writes professional, data-driven notifications with cost/benefit analysis, projected impact, and consequences of inaction.
-
Campaign Manager Notifications -- Two types of notifications are sent to the campaign manager:
- FYI (blue panel): Informational notifications about autonomous actions taken. No approval needed.
- Escalation (red panel): Urgent bid-change recommendations requiring explicit approval.
-
Display -- After each cycle, the
display.pymodule renders Rich-formatted panels: cooldown status, monitor alert table, diagnosis summaries, action confirmations with reasoning, FYI notifications, and escalation notifications. -
Feedback Loop -- Because the simulator is stateful and actions modify campaign parameters, the effects of autonomous actions are reflected in the next cycle's metrics. Combined with cooldown, this creates a stable closed-loop: act → wait → observe → decide if more action needed.
The diagnosis and action problems are fundamentally about reasoning over multiple interacting signals. A campaign might be underpacing because of bid compression, audience exhaustion, creative fatigue, or -- critically -- a combination of all three where one is the primary cause and the others are amplifying factors.
What the LLM provides that rules cannot:
| Capability | Rules-Based | LLM-Powered |
|---|---|---|
| Detect threshold violations | Yes | Not needed (Monitor handles this) |
| Identify PRIMARY root cause among multiple issues | No (would need exponential if/elif) | Yes -- reasons about causality |
| Explain WHY a metric is problematic | No | Yes -- "bid is $2.38 below clearing, losing 96/100 auctions" |
| Creative naming for new segments | No (hardcoded) | Yes -- "Pulmonologists US, Oncology Nurses/NPs" |
| Sequencing decisions | No | Yes -- "expand audience BEFORE rotating creative" |
| Write professional notifications | No | Yes -- cost/benefit analysis, consequences |
| Adapt to novel combinations | No | Yes -- handles unseen multi-signal interactions |
Example LLM reasoning:
"The bid is $12.00 below the $14.38 clearing price, which explains the 3.7% win rate. But even with a bid increase, the programmatic audio market is supply-constrained. A bid of $15.82 (10% above clearing) should lift win rate to 15-20%, boosting daily impressions from 71K to 200K+. At the higher CPM, daily spend increases to $3,200-3,400..."
This kind of multi-step causal reasoning with specific numbers is what makes the system truly agentic.
File: agents/monitor.py
The monitor runs 11 independent checks against every campaign each cycle. Each check compares a metric to a hard-coded threshold (configurable in config.yaml) and emits an alert when violated. This is intentionally NOT LLM-powered -- threshold checks should be fast, deterministic, and free.
| # | Check | Metric | Threshold | Severity Logic |
|---|---|---|---|---|
| 1 | Flight Pacing (under) | pacing_ratio |
< 0.75 | critical if < 0.50, else warning |
| 2 | Flight Pacing (over) | pacing_ratio |
> 1.25 | critical if > 1.50, else warning |
| 3 | Daily Pacing | daily_pacing_ratio |
< 0.60 | warning |
| 4 | Win Rate | win_rate_pct |
< 6% | critical if < 4%, else warning |
| 5 | Budget Burn Rate | projected spend vs remaining | projected > remaining x 1.3 | warning |
| 6 | Creative Fatigue | fatigue_factor per creative |
< 0.70 | warning |
| 7 | Audience Saturation | saturation_pct per segment |
> 70% | warning |
| 8 | Frequency Cap Saturation | users_at_cap_pct |
> 40% | critical if > 60%, else warning |
| 9 | Viewability | viewability_pct |
< 55% | warning |
| 10 | Completion Rate | completion_rate_pct |
< 65% | warning |
| 11 | Invalid Traffic / Brand Safety | invalid_traffic_rate_pct |
> 3% IVT; > 3 brand safety flags | critical (IVT), warning (brand safety) |
Output per alert:
{
"campaign_id": "CMP-1004",
"campaign_name": "FinServ Retirement Planning — Programmatic Audio",
"issue": "Low win rate — losing auctions, bid may be too low",
"severity": "critical",
"metric": "win_rate=3.7%, clearing_price=$14.38, bid=$12.00",
"details": {"win_rate": 0.037, "clearing_price": 14.38, "current_bid": 12.0}
}File: agents/diagnosis.py
The diagnosis agent uses Claude Opus 4.6 with access to read-only tools to perform root cause analysis. It enters an agentic tool-calling loop (up to 5 rounds) where it can pull additional campaign data before rendering its final diagnosis.
How it works internally:
- Receives only actionable alerts (cooldown-filtered) and full campaign data for flagged campaigns.
- System prompt encodes deep adtech domain knowledge about causal relationships between metrics.
- Claude optionally calls tools (
get_campaign_details,get_audience_segments,get_creative_performance) to inspect specific campaigns beyond what's in the alert. - The LLM reasons about causality: not just "what's wrong" but "why it's wrong" and "what's making it worse."
- Returns structured JSON array with diagnoses for each affected campaign.
System prompt encodes these causal patterns:
| Signal Combination | Root Cause Diagnosis |
|---|---|
| Low win rate + bid below clearing price | Bid compression -- campaign can't buy inventory |
| High audience saturation + underpacing | Audience exhaustion -- not enough users left |
| Creative fatigue factor < 0.70 + CTR decay | Creative wear-out -- users ignoring the ad |
| High frequency cap saturation + small audience | Over-frequency on limited pool |
| Overpacing + high frequency | Budget burning on diminishing-return impressions |
| High IVT + low viewability | Fraud or low-quality supply sources |
LLM tool-calling loop:
Claude receives alerts → (optionally) calls get_campaign_details for deeper data
→ reasons about causality → (optionally) calls get_audience_segments to check pools
→ formulates root cause → returns structured JSON diagnosis
Output structure:
[
{
"campaign_id": "CMP-1004",
"campaign_name": "FinServ Retirement Planning",
"root_cause": "Bid compression — bid is $12.00 vs clearing price $14.38, a gap of $2.38",
"contributing_factors": [
"Win rate at 3.7% — losing 96 out of every 100 auctions",
"Programmatic audio is supply-constrained with higher clearing prices",
"Campaign needs 262K impressions/day but winning only 71K/day"
],
"recommended_actions": [
{
"action": "adjust_bid",
"value": "$15.82 (10% above clearing)",
"requires_approval": true,
"reason": "Must exceed clearing price to win auctions and recover pacing"
}
]
}
]JSON parsing robustness: The diagnosis response parser handles multiple LLM output formats:
- Markdown code fences (
\``json ... ````) - Direct JSON text
- JSON embedded in prose (bracket matching)
- Single object (wraps in array)
- Multi-part content blocks from Bedrock
File: agents/action.py
The action agent uses Claude Opus 4.6 with the full tool suite bound. It enters an agentic tool-calling loop (up to 15 rounds) where it reads campaign details, executes autonomous actions, and recommends bid changes for approval. After execution, it triggers cooldown and generates FYI notifications.
Agentic behavior -- what makes this truly an "agent":
- Claude decides WHICH actions to take and in WHAT ORDER (not predetermined)
- It inspects campaigns before acting (read tools) to confirm hypotheses
- It creates descriptive names for new audiences and creatives (not hardcoded strings)
- It makes sequencing decisions ("expand audience BEFORE rotating creative because there's no point showing new creative to exhausted users")
- It can take multiple actions per campaign across multiple campaigns in one invocation
- It checks results of each tool call and decides if more actions are needed
- It writes a final reasoning trace explaining its decision logic
Autonomous Actions (executed immediately via tool-calling):
| Tool | What It Does | Example LLM Call |
|---|---|---|
expand_audience |
Adds new audience segments to the campaign | "Pulmonologists US, Oncology Nurses/NPs (~120K users)" |
rotate_creative |
Adds new creative variants with fresh fatigue | "Summer Sale V2 -- Category-specific deals native card" |
adjust_frequency_cap |
Reduces/increases frequency cap | Cap 12 -> 6 |
adjust_daily_budget |
Modifies the daily budget | $16,667 -> $10,810 |
Escalated Actions (requires human approval):
| Tool | Why It Needs Approval | Example |
|---|---|---|
recommend_bid_change |
Bid changes directly affect CPM costs and total spend across the campaign's entire supply pool | "$12.00 -> $15.82" |
Post-action effects:
- All acted-on campaigns enter cooldown (configurable, default 2 minutes)
- FYI notifications are generated per-campaign listing all autonomous actions taken
- Bid-change recommendations are collected for the Escalation Agent
Key design decision: Bid changes ALWAYS require human approval because they alter auction economics globally for the campaign. All other actions are considered safe to execute autonomously.
File: agents/escalation.py
The escalation agent uses Claude Opus 4.6 (text generation only, no tools) to write professional campaign manager notifications for bid-change recommendations. It only runs when the Action Agent has flagged bid changes needing approval.
What Claude writes for each notification:
- Problem description with specific numbers (not vague)
- Recommended bid change with justification
- Cost/benefit analysis (projected win rate improvement, CPM change, additional spend)
- Delivery forecast (can the campaign still hit its target with the new bid?)
- Consequences of inaction (what happens if the manager ignores this)
- Explicit action required (one-line decision point)
Example output:
CRITICAL DELIVERY RISK -- CMP-1004 (WealthFirst Retirement Planning) has delivered only 1.02M of 6M target impressions (17%) through day 6 of 25, pacing at just 0.708x. The root cause is clear: the current $12.00 bid is $2.38 below the $14.38 clearing price, yielding a 3.7% win rate -- the campaign is losing 96 out of every 100 auctions. We recommend increasing the bid to $15.82 (10% above clearing), which should lift the win rate to 15-20% and boost daily impressions from 71K to 200K+. Without this increase, the campaign will finish at roughly 2.5M impressions -- a 58% shortfall against the WealthFirst Advisors commitment.
Why LLM here instead of templates: A template produces: "Suggested bid: $16.47. Current bid: $12.00. Win rate: 3.8%." The LLM produces actionable narratives with cause/effect reasoning that gets a campaign manager to approve at 7 AM instead of putting it off.
File: campaign_simulator.py
The simulator is NOT static -- it's a fully dynamic, stateful mock DSP that returns different results every tick and responds to agent actions. It's called via Python method calls (not HTTP API) through a module-level singleton.
The simulator exposes the same interface a real DSP adapter would:
# These 3 functions are the entire interface between agents and data source.
# In production, swap the implementation to call DV360/TTD/Xandr APIs.
def get_campaigns() -> list[dict] # Called by fetch node each cycle
def get_campaign(campaign_id) -> dict # Called by LLM read tools
def apply_action(campaign_id, action) -> dict # Called by LLM write toolsThe agents don't know (or care) whether they're talking to a simulator or a real DSP. The dict schema is identical either way.
Every call to get_campaigns() advances an internal _tick counter and applies issue-specific drift with randomized noise to all 6 campaigns. No two calls return the same numbers:
def _drift_metrics(self):
for cid, c in self._campaigns.items():
noise = lambda lo, hi: random.uniform(lo, hi)
if cid == "CMP-1001": # underpacing -- delivers only 15-25% of target
new_imps = int(c.target_daily_impressions * noise(0.15, 0.25))
c.win_rate = max(0.03, c.win_rate + noise(-0.005, 0.002))
elif cid == "CMP-1002": # overpacing -- delivers 120-150% of target
new_imps = int(c.target_daily_impressions * noise(1.2, 1.5))
elif cid == "CMP-1003": # creative fatigue -- fatigue decays per tick
for cr in c.creatives:
cr.fatigue_factor = max(0.3, cr.fatigue_factor - noise(0.02, 0.06))
cr.ctr = max(0.003, cr.ctr * cr.fatigue_factor)
elif cid == "CMP-1004": # bid compression -- clearing price drifts UP
c.avg_clearing_price += noise(-0.20, 0.40) # Net upward drift
if bid_gap > 0: # Still below clearing
new_imps = int(c.target_daily_impressions * noise(0.10, 0.18))
c.win_rate = max(0.02, c.win_rate - noise(0.002, 0.008))
elif cid == "CMP-1005": # frequency saturation -- users_at_cap climbs
c.users_at_cap = min(0.85, c.users_at_cap + noise(0.01, 0.04))
c.avg_frequency = min(c.frequency_cap, c.avg_frequency + noise(0.1, 0.3))
else: # healthy -- fluctuates ±5% around target
new_imps = int(c.target_daily_impressions * noise(0.85, 1.05))What drifts each tick:
| Metric Category | What Changes | How |
|---|---|---|
| Delivery (impressions) | New impressions added per tick | Issue-specific multiplier × daily target × random noise |
| Creative fatigue | fatigue_factor decays |
-0.02 to -0.06 per tick, floors at 0.30 |
| Creative CTR | Decays with fatigue | CTR = CTR × fatigue_factor (compounding) |
| Bid compression | Clearing price drifts up | +$0.00 to +$0.40 per tick (net positive) |
| Win rate | Changes based on bid vs clearing | Decays when bid < clearing, improves when bid > clearing |
| Frequency | Users at cap increases | +1-4% per tick, caps at 85% |
| Avg frequency | Approaches cap | +0.1-0.3 per tick toward cap value |
| Audience saturation | All segments saturate | +0.5-2% per tick toward 95% |
| Spend | Accumulates with impressions | (new_imps / 1000) × CPM × noise |
| Auction metrics | Bid requests, bids submitted, wins | Derived from impressions and win rate |
| Computed metrics | CTR, CPC, CPA, CVR, CPM | Recomputed from raw counts each tick |
When the Action Agent calls tools, apply_action() mutates the live campaign state. These changes persist across ticks and alter future drift behavior:
| Action | What It Modifies | Effect on Next Tick |
|---|---|---|
expand_audience |
Appends new AudienceSegment with 0% saturation and random reach (500K-3M) |
More addressable users → higher delivery potential for underpacing campaigns |
add_creative |
Appends new Creative with fatigue_factor=1.0 |
Fresh creative reduces overall fatigue drag on CTR |
adjust_frequency_cap |
Updates frequency_cap field |
Drift logic respects new cap; users_at_cap may decrease if new cap is higher |
adjust_daily_budget |
Updates daily_budget field |
Pacing ratio recalculates; overpacing campaigns slow down |
adjust_bid |
Clamps to [bid_floor, bid_ceiling], updates current_bid |
If bid > clearing: win rate improves next tick. If bid < clearing: continues degrading |
Example feedback loop across 3 cycles:
Cycle 1: CMP-1005 detected with 55% users at frequency cap, single audience segment
→ Action Agent: reduce cap 12→6, expand audience (Lapsed Subscribers 60d)
→ Cooldown starts (2 minutes)
Cycle 2: CMP-1005 still in cooldown → skipped (shown in Cooldown panel)
→ Monitor still shows alerts but no action taken
→ Other campaigns may be diagnosed and acted on
Cycle 3: CMP-1005 cooldown expired → re-evaluated
→ New segment has 0% saturation, frequency pressure reduced
→ Monitor may show improved metrics or flag remaining issues
| Campaign ID | Name | Type | Issue Demonstrated | Key Starting Metrics |
|---|---|---|---|---|
| CMP-1001 | PharmaCo Branded Display -- HCP Targeting | Display | Underpacing / Narrow Audience | 275K total HCP audience, 8% win rate, 72% saturation, delivers 15-25% of daily target |
| CMP-1002 | AutoBrand CTV -- Q2 Launch | CTV | Overpacing / Budget Burn | $24.50 bid (above $21.80 clearing), 35% win rate, delivers 120-150% of daily target, budget exhausting early |
| CMP-1003 | RetailMax Summer Sale -- Native | Native | Creative Fatigue + IVT | Only 2 creatives with fatigue decaying toward 0.30, 3.2% invalid traffic, 5 brand safety flags |
| CMP-1004 | FinServ Retirement Planning -- Programmatic Audio | Audio | Bid Compression | $12.00 bid vs $14.50+ clearing, 4% win rate falling each tick, clearing price drifting up |
| CMP-1005 | StreamCo Subscription Retargeting -- Display | Display | Frequency Cap Saturation | Single segment (420K users), 52% at cap, avg frequency 8.9 climbing toward cap of 12 |
| CMP-1006 | TechGiant Cloud Platform -- Video Awareness | Video | Healthy (Control) | Pacing ~1.0, 18% win rate, good metrics. Occasionally spikes on daily pacing |
For the POC, method calls via a module-level singleton (_simulator = CampaignSimulator()) provide:
- Zero network latency (tools respond instantly)
- In-process state mutation (no persistence layer needed)
- Same dict interface as a real DSP adapter
In production, you'd replace campaign_simulator.py with an HTTP adapter that calls your DSP's REST API -- the tool signatures and dict schema stay identical.
File: config.yaml (settings) + config.py (loader)
All configurable settings live in a single YAML file. No code changes needed to tune behavior.
# Monitoring loop timing
cycle_interval_seconds: 60
# Cooldown — prevents repeated actions on same campaign
cooldown:
default_minutes: 2
per_action:
expand_audience: 2
rotate_creative: 2
adjust_frequency_cap: 2
adjust_daily_budget: 2
recommend_bid_change: 5 # Bid changes need longer to propagate
# Campaign manager notification settings
notifications:
notify_on_autonomous_actions: true
notify_on_escalations: true
channels:
- type: console # Always active
- type: email # Future integration
enabled: false
- type: slack # Future integration
enabled: false
# LLM settings
llm:
model_id: "us.anthropic.claude-opus-4-6-v1"
temperature: 0.3
max_tokens: 16384
read_timeout_seconds: 300
# AWS settings
aws:
region: "us-east-1"
profile: "default"
# Monitor thresholds (tune alerting sensitivity)
thresholds:
pacing_underpace: 0.75
pacing_overpace: 1.25
win_rate_low: 0.06
creative_fatigue: 0.70
audience_saturation: 0.70
# ... (all 11 checks configurable)Environment variables take precedence over config.yaml values:
| Variable | Overrides | Example |
|---|---|---|
BEDROCK_MODEL_ID |
llm.model_id |
us.anthropic.claude-sonnet-4-6 |
AWS_REGION |
aws.region |
us-west-2 |
AWS_PROFILE |
aws.profile |
prod-profile |
CYCLE_INTERVAL |
cycle_interval_seconds |
120 |
COOLDOWN_MINUTES |
cooldown.default_minutes |
5 |
from config import CONFIG
# Access any setting
cooldown = CONFIG["cooldown"]["default_minutes"] # → 2
model = CONFIG["llm"]["model_id"] # → "us.anthropic.claude-opus-4-6-v1"
threshold = CONFIG["thresholds"]["win_rate_low"] # → 0.06The config is loaded once at import time and cached as a module-level CONFIG dict.
File: cooldown.py
After the Action Agent takes action on a campaign, that campaign enters a cooldown period (configurable per action type). During cooldown, the pipeline still detects alerts (for visibility) but does NOT invoke LLM diagnosis or take further action -- it waits for the previous fix to propagate.
Without cooldown, the agent would:
- Expand an audience, then immediately expand again 60s later before the DSP has propagated the first expansion
- Rotate creative 3 times in 3 minutes, flooding the campaign with variants
- Adjust budget every cycle, creating oscillating instability
With cooldown, the agent acts once, waits for metrics to reflect the change, then re-evaluates.
Cycle 1: Alert detected → Diagnosis → Action taken → Cooldown starts (2 min)
Cycle 2: Alert still detected → Campaign in cooldown → SKIPPED (shown in gray panel)
Cycle 3: Alert still detected → Campaign in cooldown → SKIPPED
Cycle 4: Cooldown expired → Campaign re-evaluated → Metrics improved? → Done or act again
In config.yaml:
cooldown:
default_minutes: 2 # Default for any action
per_action: # Override per action type
expand_audience: 2 # Audience propagation takes ~1-2 min in DSPs
rotate_creative: 2 # Creative approval/activation time
adjust_frequency_cap: 2 # Cap changes are immediate
adjust_daily_budget: 2 # Budget changes are immediate
recommend_bid_change: 5 # Bid changes take longer to affect win ratesIn production with real DSPs where changes take 5-15 minutes to propagate:
cooldown:
default_minutes: 10
per_action:
expand_audience: 30 # Audience segments take time to populate in DMP
rotate_creative: 15 # Creative needs approval + CDN propagation
adjust_frequency_cap: 5 # Near-instant in most DSPs
adjust_daily_budget: 5 # Near-instant
recommend_bid_change: 15 # Win rate changes lag 10-15 minutes- Per-campaign tracking: Cooldown is recorded per campaign_id, not per action type. The entire campaign is paused from further intervention.
- In-memory singleton: The
CooldownTrackerpersists across cycles within a process. Resets on restart. - Visible in terminal: Skipped campaigns appear in a dimmed "Cooldown" panel showing remaining seconds.
The system sends two types of notifications to the campaign manager:
When: After the Action Agent executes any autonomous action (audience expansion, creative rotation, frequency cap change, budget adjustment).
Purpose: Keep the campaign manager informed about what the system did, without requiring any approval. Transparency and auditability.
Format (terminal display):
╭──────── Campaign Manager Notifications (Autonomous Actions) ────────╮
│ Campaign: PharmaCo Branded Display — HCP Targeting │
│ → Expanded audience: Pulmonologists US (~120K users) │
│ → Expanded audience: Oncology Nurses/NPs (~80K users) │
│ → Adjusted frequency cap to 10 │
│ ✉ Notification sent to campaign manager (FYI — no approval needed) │
│ │
│ Campaign: RetailMax Summer Sale — Native │
│ → Rotated creative: Summer Sale V2 — Category-specific deals │
│ → Rotated creative: Summer Sale V3 — Urgency countdown card │
│ ✉ Notification sent to campaign manager (FYI — no approval needed) │
╰───────────────────────────────────────────────────────────────────────╯
Data structure:
{
"campaign_id": "CMP-1001",
"campaign_name": "PharmaCo Branded Display — HCP Targeting",
"type": "autonomous_action_fyi",
"priority": "info",
"actions_taken": [
"Expanded audience: Pulmonologists US (~120K users)",
"Adjusted frequency cap to 10"
],
"message": "Automated actions taken on PharmaCo... These actions were executed autonomously and do not require approval. Next evaluation after cooldown period."
}When: The Action Agent recommends a bid change via recommend_bid_change tool.
Purpose: Bid changes affect CPM costs and total spend across the campaign's entire supply pool. They require explicit human approval before execution.
Format (terminal display):
╭──── Escalation Agent (LLM) — Campaign Manager Notification ────╮
│ Campaign: FinServ Retirement Planning Priority: HIGH │
│ CRITICAL — Bid $12.00 is $2.38 below $14.38 clearing, │
│ yielding 3.7% win rate. Recommend $15.82 (10% above │
│ clearing). Should lift win rate to 15-20%. Without this, │
│ campaign finishes at 2.5M vs 6M target (58% shortfall). │
│ Action required: Approve bid increase $12.00 -> $15.82 │
│ → Notification sent to campaign manager │
╰─────────────────────────────────────────────────────────────────╯
Currently outputs to console. Configured for future integrations:
notifications:
channels:
- type: console # Always: Rich terminal output
- type: email # Future: SMTP integration
enabled: false
recipients: ["campaign-manager@company.com"]
- type: slack # Future: Slack webhook/API
enabled: false
channel: "#campaign-alerts"File: llm.py
The system uses Claude Opus 4.6 via AWS Bedrock's Converse API, configured for analytical reasoning. All settings are in config.yaml.
def get_llm() -> ChatBedrockConverse:
# 1. Create boto3 session with SSO credentials
# - Clears AWS_BEARER_TOKEN_BEDROCK (Claude Code sets this, breaks SigV4)
# - Sets CA bundle for corporate proxy (Netskope)
session = _create_boto3_session()
# 2. Create Bedrock runtime client
# - SSL verification disabled (corporate proxy issues)
# - 300s read timeout (Opus is slow on complex prompts)
# - Adaptive retries (handles throttling)
bedrock_client = session.client("bedrock-runtime", ...)
# 3. Return LangChain ChatBedrockConverse instance
# - Converse API (not InvokeModel) for native tool-calling
# - Temperature 0.3 for consistent analytical output
# - 16K max tokens for multi-campaign JSON responses
return ChatBedrockConverse(model=MODEL_ID, client=bedrock_client, ...)| Parameter | Value | Rationale |
|---|---|---|
| Model | us.anthropic.claude-opus-4-6-v1 |
Deepest reasoning for complex multi-signal analysis |
| Temperature | 0.3 | Low randomness for consistent analytical output |
| Max Tokens | 16,384 | Large enough for multi-campaign JSON diagnosis output |
| Read Timeout | 300s (5 min) | Opus can take 60-120s on complex prompts |
| Retries | 3 (adaptive) | Handles transient Bedrock throttling |
| SSL Verify | False |
Corporate Netskope proxy causes intermittent issues |
The Bedrock Converse API (ChatBedrockConverse) provides:
- Native tool-calling: Structured function calls without prompt-engineering hacks
- Multi-turn conversations: Tool results feed back as proper ToolMessages
- Consistent interface: Same API regardless of underlying model
BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-6 python main.py # Faster (10-20s)
BEDROCK_MODEL_ID=us.anthropic.claude-haiku-4-5-20251001-v1:0 python main.py # Cheapest (3-5s)File: tools.py
Tools are implemented as LangChain @tool-decorated functions. They are thin wrappers around the campaign simulator -- all business logic lives in campaign_simulator.py. This means you can swap the simulator for a real DSP adapter and the tool signatures stay identical.
@tool
def expand_audience(campaign_id: str, segment_description: str) -> str:
"""
Add a new audience segment to a campaign to expand addressable reach.
[LLM reads this docstring to understand when/how to call the tool]
"""
result = sim.apply_action(campaign_id, {"expand_audience": segment_description})
return json.dumps(result, indent=2)When bind_tools() is called:
- LangChain converts the
@toolschema (name, description, parameters) to Bedrock's tool format - Bedrock includes this in the system context so Claude knows what's available
- Claude responds with
tool_callswhen it decides to use a tool - Our agentic loop executes the call and feeds the result back as a
ToolMessage
| Tool | Parameters | Returns | When to Use |
|---|---|---|---|
get_campaign_details |
campaign_id: str |
Full campaign dict (all metrics) | First call to understand campaign state |
get_audience_segments |
campaign_id: str |
Segments with saturation data | Check if audience is exhausted |
get_creative_performance |
campaign_id: str |
Creatives with fatigue/CTR | Assess creative rotation health |
| Tool | Parameters | Mutates Campaign State | Safety |
|---|---|---|---|
expand_audience |
campaign_id, segment_description |
Adds segment with 0% saturation, 500K-3M reach | Autonomous |
rotate_creative |
campaign_id, creative_description |
Adds creative with fatigue=1.0 | Autonomous |
adjust_frequency_cap |
campaign_id, new_cap |
Updates cap (min 1) | Autonomous |
adjust_daily_budget |
campaign_id, new_daily_budget |
Updates budget (min $10) | Autonomous |
recommend_bid_change |
campaign_id, suggested_bid, rationale |
Does NOT mutate -- creates escalation record | Requires approval |
READ_TOOLS = [get_campaign_details, get_audience_segments, get_creative_performance]
ALL_TOOLS = READ_TOOLS + [expand_audience, rotate_creative, adjust_frequency_cap,
adjust_daily_budget, recommend_bid_change]- Diagnosis Agent gets
READ_TOOLS(inspect only, can't modify) - Action Agent gets
ALL_TOOLS(full read + write + escalation)
File: workflow.py
The LangGraph StateGraph orchestrates the entire pipeline. It manages shared state, conditional routing, and node execution.
class PipelineState(TypedDict):
campaigns: list[dict] # From fetch node (raw campaign data)
alerts: list[dict] # From monitor node (actionable alerts only)
cooldown_skipped: list[dict] # From monitor node (alerts filtered by cooldown)
diagnoses: list[dict] # From diagnosis node (LLM root cause analysis)
actions_executed: list[dict] # From action node (autonomous actions taken)
escalations: list[dict] # From action node (bid changes for approval)
action_reasoning: str # From action node (LLM reasoning trace)
action_notifications: list[dict] # From action node (FYI notifications)
notifications: list[dict] # From escalation node (manager notifications)Each node reads from state and returns a dict of updates. LangGraph merges updates automatically:
| Node | Reads From State | Writes To State | LLM? |
|---|---|---|---|
fetch |
(nothing) | campaigns |
No |
monitor |
campaigns |
alerts, cooldown_skipped |
No |
diagnose |
alerts, campaigns |
diagnoses |
Yes (Claude + tools) |
act |
diagnoses, campaigns |
actions_executed, escalations, action_reasoning, action_notifications |
Yes (Claude + tools) |
escalate |
escalations, campaigns |
notifications |
Yes (Claude text) |
def should_diagnose(state):
return "diagnose" if state["alerts"] else "end"
def should_escalate(state):
return "escalate" if state["escalations"] else "end"workflow = StateGraph(PipelineState)
workflow.add_node("fetch", fetch_campaigns)
workflow.add_node("monitor", monitor_node)
workflow.add_node("diagnose", diagnosis_node)
workflow.add_node("act", action_node)
workflow.add_node("escalate", escalation_node)
workflow.set_entry_point("fetch")
workflow.add_edge("fetch", "monitor")
workflow.add_conditional_edges("monitor", should_diagnose, {"diagnose": "diagnose", "end": END})
workflow.add_edge("diagnose", "act")
workflow.add_conditional_edges("act", should_escalate, {"escalate": "escalate", "end": END})
workflow.add_edge("escalate", END)
app = workflow.compile() # Called once; reused across all cyclesFile: display.py
The display module renders clean Rich-formatted terminal output. It's completely decoupled from the agents -- it only reads the final pipeline state. You could swap Rich for Slack messages, email, or a web dashboard without touching agent code.
| Panel | Color | Shows | When |
|---|---|---|---|
| Cooldown Status | Gray/dim | Campaigns skipped due to cooldown with time remaining | When any campaigns are in cooldown |
| Monitor Agent | Yellow | Alert table (campaign, issue, severity, metric) | When alerts detected |
| Diagnosis Agent (LLM) | Magenta | Root cause + contributing factors per campaign | When diagnosis runs |
| Action Agent (LLM + Tools) | Green | Autonomous actions executed + truncated reasoning | When actions taken |
| CM Notifications (FYI) | Blue | Autonomous actions sent to campaign manager | When autonomous actions exist |
| Escalation Agent (LLM) | Red | Bid-change notifications with cost/benefit | When bid changes recommended |
| Status (healthy) | Green | "No issues detected" | When no alerts |
- Scannable: Campaign manager can glance and know what happened
- Non-cluttered: No raw JSON dumps, no debug logs
- Color-coded: Red = critical, Yellow = warning, Green = actions, Blue = FYI, Cyan = healthy
- Severity-aware: Critical alerts show in red, warnings in yellow
- Truncated reasoning: LLM reasoning trace capped at 500 chars to keep output readable
The system monitors a comprehensive set of programmatic advertising metrics:
| Category | Metric | Field Path | Description |
|---|---|---|---|
| Budget | Total Budget | budget.total |
Lifetime campaign budget |
| Daily Budget | budget.daily |
Target daily spend | |
| Total Spend | budget.spent_total |
Cumulative spend to date | |
| Today's Spend | budget.spent_today |
Spend accumulated today | |
| Remaining | budget.remaining |
Budget left in flight | |
| Utilization % | budget.utilization_pct |
spent_total / total_budget |
|
| Schedule | Flight Day | schedule.flight_day |
Current day of the campaign flight |
| Total Days | schedule.total_days |
Full flight duration | |
| Days Remaining | schedule.days_remaining |
Days left in the flight | |
| Delivery | Target Impressions | delivery.target_impressions |
Lifetime impression goal |
| Delivered Total | delivery.delivered_total |
Impressions delivered to date | |
| Delivered Today | delivery.delivered_today |
Impressions delivered today | |
| Target Daily | delivery.target_daily |
Expected impressions per day | |
| Pacing Ratio | delivery.pacing_ratio |
(delivered / target) / (day / total_days) -- 1.0 = on pace |
|
| Daily Pacing Ratio | delivery.daily_pacing_ratio |
Intraday pacing against hourly expectation | |
| Bidding | Base Bid | bidding.base_bid |
Original configured bid |
| Current Bid | bidding.current_bid |
Active bid (may be adjusted by agent) | |
| Bid Floor | bidding.bid_floor |
Minimum allowable bid | |
| Bid Ceiling | bidding.bid_ceiling |
Maximum allowable bid | |
| Win Rate % | bidding.win_rate_pct |
Percentage of auctions won | |
| Avg Clearing Price | bidding.avg_clearing_price |
Average market clearing price | |
| Performance | Clicks | performance.clicks |
Total clicks |
| CTR % | performance.ctr_pct |
Click-through rate | |
| Conversions | performance.conversions |
Total conversions | |
| CVR % | performance.cvr_pct |
Conversion rate | |
| Viewability % | performance.viewability_pct |
Percentage of impressions viewable | |
| Completion Rate % | performance.completion_rate_pct |
Video/audio completion rate | |
| CPM | performance.cpm |
Cost per thousand impressions | |
| CPC | performance.cpc |
Cost per click | |
| CPA | performance.cpa |
Cost per acquisition | |
| ROAS | performance.roas |
Return on ad spend | |
| Frequency | Avg Frequency | frequency.avg_frequency |
Mean impressions per unique user |
| Frequency Cap | frequency.frequency_cap |
Maximum impressions per user | |
| Unique Reach | frequency.unique_reach |
Number of distinct users reached | |
| Users at Cap % | frequency.users_at_cap_pct |
% of reached users at the frequency cap | |
| Audience | Segment Reach | audience[].reach |
Estimated addressable users in segment |
| Matched Users | audience[].matched |
Users matched so far | |
| Saturation % | audience[].saturation_pct |
Proportion of segment already reached | |
| Creative | Impressions | creatives[].impressions |
Impressions served per creative |
| CTR % | creatives[].ctr_pct |
Creative-level click-through rate | |
| Fatigue Factor | creatives[].fatigue_factor |
1.0 = fresh, decays toward 0 as creative wears out | |
| Supply | Available Inventory | supply.available_inventory |
Addressable supply in target placements |
| Bid Requests | supply.bid_requests |
Total bid requests received | |
| Bids Submitted | supply.bids_submitted |
Bids the DSP placed | |
| Auctions Won | supply.auctions_won |
Auctions won | |
| Fill Rate % | supply.fill_rate_pct |
auctions_won / bids_submitted |
|
| Quality | Brand Safety Flags | quality.brand_safety_flags |
Count of brand safety violations |
| Invalid Traffic Rate % | quality.invalid_traffic_rate_pct |
Suspected fraudulent/invalid traffic rate | |
| Domain Block Rate % | quality.domain_block_rate_pct |
Rate of blocked domains in supply |
| Requirement | Version | Notes |
|---|---|---|
| Python | 3.12 or 3.13 | 3.14 has Pydantic compatibility issues as of Apr 2026 |
| uv (recommended) | latest | Fast Python package manager from Astral |
| AWS CLI | v2 | For SSO login to access Bedrock |
| AWS Account | With Bedrock access | Must have Claude models enabled in us-east-1 |
| OS | macOS / Linux / WSL | Tested on macOS (Darwin/ARM64) |
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Or via Homebrew (macOS)
brew install uv
# Verify installation
uv --versiongit clone <repo-url>
cd langgraph-agentic-dsp-optimizer# Creates .venv/ using Python 3.13
uv venv --python 3.13
# Activate the virtual environment
source .venv/bin/activate # macOS / Linux / WSL
# .venv\Scripts\activate # Windows (cmd)If Python 3.13 is not installed on your system:
# uv downloads and manages Python versions for you
uv python install 3.13
uv venv --python 3.13# Install from pyproject.toml
uv pip install -e .
# Or install packages directly
uv pip install langgraph langchain-core langchain-aws boto3 rich pyyamlThe agent uses AWS Bedrock to call Claude. You need an AWS profile configured with SSO access to Bedrock.
~/.aws/config should contain a profile like:
[default]
sso_session = my-session
sso_account_id = 123456789012
sso_role_name = BedrockAccess
region = us-east-1
[sso-session my-session]
sso_start_url = https://your-org.awsapps.com/start
sso_region = us-east-1
sso_registration_scopes = sso:account:accessThen login:
aws sso login --profile defaultEdit config.yaml to adjust cooldown duration, model, thresholds, or cycle interval:
# Example: reduce cooldown to 1 minute for faster iteration
sed -i '' 's/default_minutes: 2/default_minutes: 1/' config.yaml
# Or use env vars for one-off changes
COOLDOWN_MINUTES=1 BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-6 python main.pypython main.pyThe agent starts a continuous monitoring loop, executing one cycle every 60 seconds. Press Ctrl+C to stop.
| Package | Version | Purpose |
|---|---|---|
langgraph |
>= 0.4.0 | LangGraph StateGraph workflow engine |
langchain-core |
>= 0.3.0 | Core abstractions, messages, tool decorators |
langchain-aws |
>= 0.2.0 | ChatBedrockConverse -- Claude via Bedrock Converse API |
boto3 |
>= 1.35.0 | AWS SDK for Bedrock runtime client |
rich |
>= 13.0.0 | Terminal formatting -- panels, tables, color output |
pyyaml |
>= 6.0 | YAML config file parsing |
uv venv --python 3.13 && source .venv/bin/activate && uv pip install -e . && aws sso login && python main.py| Variable | Purpose | Default |
|---|---|---|
BEDROCK_MODEL_ID |
Override the Claude model | us.anthropic.claude-opus-4-6-v1 |
AWS_REGION |
AWS region for Bedrock | us-east-1 |
AWS_PROFILE |
AWS profile name for SSO credentials | default |
CYCLE_INTERVAL |
Override cycle interval (seconds) | 60 |
COOLDOWN_MINUTES |
Override default cooldown duration (minutes) | 2 |
LANGSMITH_API_KEY |
Enable LangSmith tracing for workflow observability | (none) |
LANGSMITH_PROJECT |
LangSmith project name for trace grouping | default |
Model options:
# Deepest reasoning (default) -- 60-120s per call
BEDROCK_MODEL_ID=us.anthropic.claude-opus-4-6-v1 python main.py
# Faster analysis -- 10-20s per call
BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-6 python main.py
# Fastest/cheapest -- 3-5s per call
BEDROCK_MODEL_ID=us.anthropic.claude-haiku-4-5-20251001-v1:0 python main.py| Problem | Solution |
|---|---|
Python 3.14 -- TypeError: _eval_type() |
Pydantic does not support Python 3.14 yet. Use 3.12 or 3.13. |
ExpiredTokenException |
Run aws sso login --profile default to refresh SSO token. |
ReadTimeoutError after 60s |
Opus can take 2+ minutes. The config sets 300s timeout; ensure network is stable. |
IncompleteSignatureException |
The AWS_BEARER_TOKEN_BEDROCK env var may interfere. The code clears it automatically. |
| SSL certificate errors (Netskope/corporate proxy) | SSL verification is disabled by default (verify=False). |
ValidationException: model identifier invalid |
Check model ID with aws bedrock list-inference-profiles. |
| Diagnosis shows "Parse error" | Ensure max_tokens >= 16384 in config.yaml. Opus needs space for multi-campaign JSON. |
ModuleNotFoundError: langchain_aws |
Run uv pip install langchain-aws boto3. |
ModuleNotFoundError: yaml |
Run uv pip install pyyaml. |
| Campaigns never re-evaluated | Cooldown too long. Reduce cooldown.default_minutes in config.yaml. |
Below is actual output from a single monitoring cycle (Cycle 1, then Cycle 2 showing cooldown):
Campaign Pacing Agent
Model: us.anthropic.claude-opus-4-6-v1 via AWS Bedrock (us-east-1)
Monitoring interval: 60s · Cooldown: 2 min · Press Ctrl+C to stop
──────────────────────── Cycle 1 · 09:34:25 ────────────────────────
╭──────────────────────── Monitor Agent ────────────────────────╮
│ Campaign Issue Severity Key Metric │
│ PharmaCo Branded Underpacing -- behind WARNING pacing=0.66 │
│ Display -- HCP delivery schedule (target=1.0) │
│ PharmaCo Branded Audience saturation -- WARNING saturation=72.8% │
│ Display -- HCP 'Oncologists' 72.8% reach=131,065/180,000 │
│ AutoBrand CTV Overpacing -- ahead of CRITICAL pacing=1.74 │
│ delivery schedule (target=1.0) │
│ AutoBrand CTV Budget overrun WARNING projected=$598K vs │
│ projected remaining=$194K │
│ RetailMax Summer Sale Creative fatigue -- WARNING fatigue=0.50 │
│ 'Summer Sale Hero' │
│ RetailMax Summer Sale High invalid traffic CRITICAL ivt=3.2% │
│ FinServ Retirement Low win rate CRITICAL win_rate=3.7%, │
│ Planning clearing=$14.38 │
│ bid=$12.00 │
│ StreamCo Retargeting Frequency cap WARNING users_at_cap=55.8% │
│ saturation avg_freq=9.07 │
╰───────────────────────────────────────────────────────────────╯
╭──────────────── Diagnosis Agent (LLM) ─────────────────╮
│ PharmaCo Branded Display -- HCP Targeting │
│ Root cause: Audience exhaustion -- combined pool of │
│ Oncologists (180K) and Hematologists (95K) totals │
│ only 275K users with 72.8% saturation, leaving too │
│ few unreached users to sustain 500K daily target │
│ · Low win rate 8.0% despite bidding $8.50 above │
│ $7.20 clearing -- structurally limited inventory │
│ · Needs 585K/day vs current 328K/day -- 78% gap │
│ │
│ FinServ Retirement Planning │
│ Root cause: Bid compression -- $12.00 bid is $2.38 │
│ below $14.38 clearing price, yielding 3.7% win rate │
│ · 8.06M bids submitted, only 319K won (4% fill) │
│ · Needs 262K/day, winning only 71K/day -- 3.7x gap │
╰─────────────────────────────────────────────────────────╯
╭──────────────── Action Agent (LLM + Tools) ────────────────╮
│ Autonomous Actions Taken: │
│ ✓ [CMP-1001] Expanded audience: 'Pulmonologists US, │
│ Immunologists US, Oncology Nurses/NPs' │
│ ✓ [CMP-1002] Daily budget -> $10,810.00 │
│ ✓ [CMP-1002] Frequency cap -> 6 │
│ ✓ [CMP-1003] Rotated creative: 'Summer Sale V2' │
│ ✓ [CMP-1003] Rotated creative: 'Summer Sale V3' │
│ ✓ [CMP-1005] Expanded audience: 'Lapsed Subscribers 60d' │
│ ✓ [CMP-1005] Frequency cap -> 6 │
╰─────────────────────────────────────────────────────────────╯
╭──────── Campaign Manager Notifications (Autonomous Actions) ────────╮
│ Campaign: PharmaCo Branded Display — HCP Targeting │
│ → Expanded audience: Pulmonologists US, Oncology Nurses/NPs │
│ ✉ Notification sent to campaign manager (FYI — no approval needed) │
│ │
│ Campaign: RetailMax Summer Sale — Native │
│ → Rotated creative: Summer Sale V2 │
│ → Rotated creative: Summer Sale V3 │
│ ✉ Notification sent to campaign manager (FYI — no approval needed) │
╰───────────────────────────────────────────────────────────────────────╯
╭──── Escalation Agent (LLM) -- Campaign Manager Notification ────╮
│ Campaign: FinServ Retirement Planning Priority: HIGH │
│ CRITICAL -- Bid $12.00 is $2.38 below $14.38 clearing, │
│ yielding 3.7% win rate. Recommend $15.82 (10% above │
│ clearing). Should lift win rate to 15-20%. Without this, │
│ campaign finishes at 2.5M vs 6M target (58% shortfall). │
│ Action required: Approve bid increase $12.00 -> $15.82 │
│ -> Notification sent to campaign manager │
╰─────────────────────────────────────────────────────────────────╯
──────────────────────── Cycle 2 · 09:35:25 ────────────────────────
╭──────────────── Cooldown — Skipped Campaigns ────────────────╮
│ ⏸ PharmaCo Branded Display — cooldown 58s remaining │
│ ⏸ AutoBrand CTV — Q2 Launch — cooldown 58s remaining │
│ ⏸ RetailMax Summer Sale — cooldown 58s remaining │
│ ⏸ FinServ Retirement Planning — cooldown 238s remaining │
│ ⏸ StreamCo Subscription Retargeting — cooldown 58s │
╰──────────────────────────────────────────────────────────────╯
╭──────────────────── Status ─────────────────────╮
│ No actionable issues this cycle. │
│ All flagged campaigns are in cooldown. │
╰──────────────────────────────────────────────────╯
campaign-pacing-agent/
├── agents/
│ ├── __init__.py # Package init
│ ├── monitor.py # Monitor Agent — 11 threshold checks (rules, no LLM)
│ ├── diagnosis.py # Diagnosis Agent — Claude LLM with read tools (5 rounds)
│ ├── action.py # Action Agent — Claude LLM with full tool suite (15 rounds)
│ └── escalation.py # Escalation Agent — Claude LLM text generation
├── campaign_simulator.py # Dynamic mock DSP — 6 campaigns, tick-based drift, agent feedback
├── config.py # Configuration loader — reads config.yaml + env overrides
├── config.yaml # All configurable settings (cooldown, thresholds, model, etc.)
├── cooldown.py # Cooldown tracker — prevents repeated actions on same campaign
├── llm.py # LLM factory — ChatBedrockConverse (Claude Opus 4.6 via Bedrock)
├── tools.py # LangChain @tool definitions (3 read + 4 write + 1 escalation)
├── workflow.py # LangGraph StateGraph — conditional routing + cooldown integration
├── display.py # Rich terminal display — panels, tables, color-coded output
├── main.py # Entry point — configurable monitoring loop
├── pyproject.toml # Project metadata and dependencies
└── .python-version # Python 3.13
| File | Lines | Purpose | LLM? |
|---|---|---|---|
main.py |
Entry point | Compiles workflow, runs 60s loop, displays results | No |
workflow.py |
Orchestration | StateGraph definition, conditional edges, cooldown integration | No |
agents/monitor.py |
Detection | 11 rule-based checks, alert generation | No |
agents/diagnosis.py |
Analysis | Root cause analysis via Claude with read tools | Yes |
agents/action.py |
Execution | Agentic tool-calling loop (15 rounds), action tracking | Yes |
agents/escalation.py |
Communication | Professional notification writing | Yes |
campaign_simulator.py |
Data source | Stateful campaign simulation with drift and feedback | No |
llm.py |
Infrastructure | boto3 session, Bedrock client, ChatBedrockConverse factory | No |
tools.py |
Interface | @tool wrappers bridging LLM to simulator | No |
config.py + config.yaml |
Configuration | All tunable settings in one place | No |
cooldown.py |
Rate limiting | Prevents action storms on same campaign | No |
display.py |
Presentation | Rich panels for human consumption | No |
Each cycle with ~5 flagged campaigns involves 3 LLM invocations (diagnosis, action, escalation):
| Model | Cost per Cycle | Cost per Hour | Latency per Cycle | Best For |
|---|---|---|---|---|
| Claude Opus 4.6 | ~$0.05-0.10 | ~$3-6/hour | 60-180s | Deep analysis, complex reasoning |
| Claude Sonnet 4.6 | ~$0.01-0.02 | ~$0.60-1.20/hour | 15-40s | Production monitoring |
| Claude Haiku 4.5 | ~$0.001-0.003 | ~$0.06-0.18/hour | 5-15s | High-frequency, simple issues |
Cost optimization:
- Clean cycles (no alerts) incur zero LLM cost -- only the rule-based monitor runs
- Cooldown reduces LLM calls by skipping recently-acted campaigns
- Conditional edges prevent unnecessary LLM invocations (no escalation if no bid changes)
Replace campaign_simulator.py with an adapter that calls your actual DSP:
# campaign_simulator.py (real implementation)
import your_dsp_sdk
def get_campaigns() -> list[dict]:
"""Fetch live campaigns from DV360, The Trade Desk, Xandr, etc."""
return your_dsp_sdk.get_active_campaigns(format="dict")
def get_campaign(campaign_id: str) -> dict | None:
"""Fetch single campaign details."""
return your_dsp_sdk.get_campaign(campaign_id)
def apply_action(campaign_id: str, action: dict) -> dict:
"""Push changes back to the DSP via API."""
return your_dsp_sdk.update_campaign(campaign_id, **action)The rest of the pipeline works unchanged -- it only depends on the dict schema.
Modify the display/escalation layer to send real notifications:
# notification_sender.py
import slack_sdk
import smtplib
def send_notification(notification: dict, channels: list[dict]):
for channel in channels:
if channel["type"] == "slack" and channel.get("enabled"):
client = slack_sdk.WebClient(token=os.environ["SLACK_TOKEN"])
client.chat_postMessage(
channel=channel["channel"],
text=notification["message"],
)
elif channel["type"] == "email" and channel.get("enabled"):
send_email(notification, channel["recipients"])Add checks in agents/monitor.py by following the existing pattern:
def _check_your_new_metric(campaign: dict, alerts: list):
value = campaign["your_metric"]["field"]
if value > YOUR_THRESHOLD:
alerts.append({
"campaign_id": campaign["campaign_id"],
"campaign_name": campaign["campaign_name"],
"issue": f"Your issue description -- value is {value}",
"severity": "warning",
"metric": f"value={value}",
"details": {"raw_value": value},
})Add tools in tools.py using the @tool decorator:
from langchain_core.tools import tool
@tool
def pause_campaign(campaign_id: str, reason: str) -> str:
"""Pause a campaign entirely. Use when budget is exhausted or fraud detected."""
result = sim.apply_action(campaign_id, {"pause": reason})
return json.dumps(result)
# Add to the appropriate tool list
ALL_TOOLS = READ_TOOLS + WRITE_TOOLS + [pause_campaign]In config.yaml, tune per-action cooldowns based on your DSP's propagation times:
cooldown:
default_minutes: 10
per_action:
expand_audience: 30 # DMP takes 20-30 min to activate new segments
rotate_creative: 15 # Creative approval + CDN propagation
adjust_frequency_cap: 5 # Near-instant in most DSPs
adjust_daily_budget: 5 # Near-instant
recommend_bid_change: 15 # Win rate changes lag 10-15 minChange the default model in config.yaml or use environment variables:
# Use Sonnet for faster iteration during development
BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-6 python main.py
# Use Haiku for cost-sensitive production monitoring
BEDROCK_MODEL_ID=us.anthropic.claude-haiku-4-5-20251001-v1:0 python main.pyAdd logging to track agent decisions over time:
import json
from datetime import datetime
def _log_action(action_record: dict):
with open("action_log.jsonl", "a") as f:
action_record["timestamp"] = datetime.now().isoformat()
f.write(json.dumps(action_record) + "\n")All thresholds are in config.yaml under the thresholds section:
thresholds:
pacing_underpace: 0.75 # more lenient: 0.65, stricter: 0.85
win_rate_low: 0.06 # adjust based on your auction environment
creative_fatigue: 0.70 # lower = tolerate more fatigue before alerting
audience_saturation: 0.70 # percentage of segment already reached
frequency_cap_warning: 0.40 # users at cap before alertingBuilt with LangGraph, Claude Opus 4.6 on AWS Bedrock, and Rich.