Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions briefs/linkedin_live_test_brief.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Campaign Brief — TensorOps AdTech Demo (LIVE TEST)

## Advertiser
TensorOps

## Product
AI agents for adtech / media buying.

## Objective
Drive awareness and engagement for the June 17 TensorOps adtech session.

## KPIs
- **Primary:** Cost per engagement
- **Secondary:** CTR ≥ 0.5% on Sponsored Content

## Budget
€500 EUR total, across the flight.

## Flight
2026-06-16 → 2026-06-30 (about two weeks).

## Platforms
LinkedIn only.

## Audience
Marketing and advertising decision-makers in the US, UK, and Portugal who work
in adtech, media buying, or programmatic advertising.

## Creatives
One creative at launch:

### Creative 1 — June 17 session promo
- **Headline:** How are digital ads chosen in milliseconds?
- **Primary text:** Join TensorOps on June 17 to see how agents run programmatic media buying.
- **Call to action:** Learn More
- **Landing URL:** tensorops.ai
- **Existing post:** urn:li:share:7459747919521333248

## Notes
This creative advertises a post already published by hand on the TensorOps
Company Page (referenced above), so the agent references it directly instead of
minting a new Direct Sponsored Content post. Keep everything in **DRAFT** —
nothing gets activated in Campaign Manager without sign-off.
25 changes: 25 additions & 0 deletions src/yieldagent/integrations/linkedin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,31 @@ async def delete_post(self, post_urn: str) -> None:
key = quote(str(post_urn), safe="")
await self._request("DELETE", f"/posts/{key}")

async def typeahead_targeting_entities(self, *, facet: str, query: str) -> list[dict[str, Any]]:
"""Search a targeting facet's open taxonomy (industries, titles, skills).

Returns the relevance-ranked entities `[{urn, name, facetUrn}, ...]`. Only
facets whose `availableEntityFinders` include `TYPEAHEAD` accept this — the
closed enums (seniorities, jobFunctions) 400 here and must use the
standardized-data endpoints instead.
"""
res = await self._request(
"GET",
"/adTargetingEntities",
params={"q": "typeahead", "query": query, "facet": facet},
)
return res.get("elements", [])

async def list_seniorities(self) -> list[dict[str, Any]]:
"""Standardized seniority taxonomy: `[{id, name:{localized:{en_US}}}, ...]` (10)."""
res = await self._request("GET", "/seniorities", params={"count": 50})
return res.get("elements", [])

async def list_functions(self) -> list[dict[str, Any]]:
"""Standardized job-function taxonomy: `[{id, name:{localized:{en_US}}}, ...]` (26)."""
res = await self._request("GET", "/functions", params={"count": 50})
return res.get("elements", [])

async def list_campaigns(self) -> dict[str, Any]:
"""List campaigns under the configured ad account.

Expand Down
32 changes: 7 additions & 25 deletions src/yieldagent/integrations/linkedin/mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,12 @@ def campaign_run_schedule(flights: list[Flight]) -> dict[str, int]:


def audience_to_targeting(audience: Audience) -> dict[str, Any]:
"""Build a LinkedIn `targetingCriteria` payload.

Only geo targeting is wired in this slice. B2B facets (industries, job
functions, seniorities, company sizes, skills) require URN resolution via
the typeahead endpoint — a follow-up. They are present on the Audience for
Brief round-trip fidelity, but are not pushed to the API here. If any B2B
facets are set, a hint is included in the payload under `_unresolved_b2b`
so the caller can log and surface them at approval time.
"""Build a geo-only LinkedIn `targetingCriteria` payload.

This is the static, client-free baseline (locations only). Live B2B facet
resolution — industries, job functions, titles, seniorities, company sizes,
skills — needs API lookups and lives in `targeting.TargetingResolver`, which
the publish flow uses. This helper remains for inspection/test scaffolding.
"""
includes: list[str] = []
for code in audience.geos:
Expand All @@ -105,7 +103,7 @@ def audience_to_targeting(audience: Audience) -> dict[str, Any]:
# LinkedIn requires at least one location; default to US.
includes.append(ISO_TO_LINKEDIN_GEO_URN["US"])

criteria: dict[str, Any] = {
return {
"include": {
"and": [
{
Expand All @@ -117,22 +115,6 @@ def audience_to_targeting(audience: Audience) -> dict[str, Any]:
}
}

unresolved = {
k: v
for k, v in {
"industries": audience.industries,
"job_functions": audience.job_functions,
"job_titles": audience.job_titles,
"seniorities": audience.seniorities,
"company_sizes": audience.company_sizes,
"skills": audience.skills,
}.items()
if v
}
if unresolved:
criteria["_unresolved_b2b"] = unresolved
return criteria


def line_item_locale(audience: Audience) -> dict[str, str]:
"""LinkedIn campaigns require a `locale` (country + language).
Expand Down
27 changes: 10 additions & 17 deletions src/yieldagent/integrations/linkedin/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
from .config import LinkedInConfig
from .mapping import (
DEFAULT_CAMPAIGN_TYPE,
audience_to_targeting,
campaign_objective,
campaign_run_schedule,
creative_content_reference,
Expand All @@ -32,6 +31,7 @@
post_article_content,
post_commentary,
)
from .targeting import TargetingResolver

mcp = FastMCP("yieldagent-linkedin")

Expand All @@ -40,13 +40,6 @@ def _client() -> LinkedInClient:
return LinkedInClient(LinkedInConfig.from_env())


def _strip_unresolved(targeting: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
"""Split the mapping's targeting payload into wire payload + unresolved B2B notes."""
wire = {k: v for k, v in targeting.items() if not k.startswith("_")}
unresolved = targeting.get("_unresolved_b2b", {})
return wire, unresolved


class _Created:
"""Tracks resources created during a publish so they can be rolled back.

Expand Down Expand Up @@ -218,12 +211,11 @@ async def publish_draft_campaign(campaign: dict[str, Any]) -> dict[str, Any]:

line_item_urns: dict[str, str] = {}
unresolved_by_li: dict[str, dict[str, Any]] = {}
resolver = TargetingResolver(client)
for li in parsed.line_items:
targeting, unresolved = _strip_unresolved(
audience_to_targeting(li.targeting.audience)
)
if unresolved:
unresolved_by_li[li.name] = unresolved
resolved = await resolver.resolve(li.targeting.audience)
if resolved.unresolved:
unresolved_by_li[li.name] = resolved.unresolved
run_schedule = flight_to_run_schedule(li.flight)
created_li = await client.create_campaign(
campaign_group_urn=group_urn,
Expand All @@ -232,7 +224,7 @@ async def publish_draft_campaign(campaign: dict[str, Any]) -> dict[str, Any]:
campaign_type=DEFAULT_CAMPAIGN_TYPE,
total_budget=money_to_linkedin_amount(li.budget.amount, li.budget.currency),
run_schedule=run_schedule,
targeting_criteria=targeting,
targeting_criteria=resolved.criteria,
locale=line_item_locale(li.targeting.audience),
)
created.campaigns.append(created_li["id"])
Expand Down Expand Up @@ -302,9 +294,10 @@ async def publish_draft_campaign(campaign: dict[str, Any]) -> dict[str, Any]:
if unresolved_by_li:
result["notes"]["unresolved_b2b_targeting"] = unresolved_by_li
result["notes"]["unresolved_b2b_hint"] = (
"These B2B facets are present on the Brief audience but were not pushed to "
"LinkedIn — they require URN resolution via the typeahead endpoint, which is "
"not wired in this slice. Add them manually in Campaign Manager before activation."
"These facet values came from the Brief but matched no LinkedIn targeting "
"entity (typeahead/standardized lookup returned nothing), so they were not "
"pushed — we never guess a URN. Add them manually in Campaign Manager before "
"activation, or refine the wording to match LinkedIn's taxonomy."
)

return result
Expand Down
Loading