LIAM · LINKEDIN ADS MANAGER

Docs

Everything you need to run Liam: the LinkedIn app, the local MCP server, the hosted endpoint you can use with your own credentials, the CLI, and the playbook skills on top. This page mirrors the repo README.

Overview

What Liam is.

Liam is an ad manager for LinkedIn. You describe the campaign in plain language and Liam drafts the audience, the ad groups, and the ads, from Claude over MCP or from a CLI. It covers creation, matched audiences (including building one straight from Salesforce), conversion selection, performance reporting, competitor ad intelligence, and a change journal with before and after lift.

Everything Liam creates is a draft. There is deliberately no activate tool or command anywhere in the codebase, so nothing spends until you switch a campaign on yourself in Campaign Manager.

Hierarchy mapping

LinkedIn names its levels differently than Google or Meta.

Common termLinkedIn entityHolds
CampaignCampaign Groupstatus, total or shared budget
Ad groupCampaigntargeting, budget, bid, schedule, format
AdCreativethe rendered ad (status: DRAFT)
AudienceDMP Segmentattached to a Campaign's targeting

Step 0 · once

Create a LinkedIn app.

Liam runs against your own LinkedIn developer app, so your credentials and your ad account stay yours.

  1. Create an app at linkedin.com/developers/apps. It must be associated with your company's LinkedIn Page.
  2. On the Products tab, request access to the products below.
  3. On the Auth tab, add http://localhost:53682/callback as an authorized redirect URL, and note your Client ID and Client Secret.
  4. Once the Advertising API is approved, map your ad account under Products > Advertising API > View Ad Accounts. Skip this and accounts list comes back empty.
ProductNeeded forRequired?
Advertising APIcampaigns, ads, targeting, reporting, conversionsYes
Audiencesuploading CSV contact and company lists as matched audiencesOnly for audience upload
LinkedIn Ad Librarycompetitor ad metadata via the official APIOptional (the browser scraper works without it)

OAuth scopes

liam auth login requests rw_ads (create and edit campaigns), r_ads_reporting (reporting), rw_conversions (conversion tracking), and w_organization_social (image ads create a post owned by your LinkedIn Page). If you add a product or scope later, run auth login again; a token refresh keeps only the scopes you originally granted.

Step 1 · once

Build and authenticate.

Requires git, Node.js 20 or newer, and pnpm.

git clone https://github.com/stan-default/liam.git
cd liam
pnpm install && pnpm -r build

# App credentials (alternatively set LIADS_CLIENT_ID / LIADS_CLIENT_SECRET env vars)
mkdir -p ~/.liads
echo '{ "clientId": "...", "clientSecret": "...", "linkedinVersion": "202605" }' > ~/.liads/config.json

node packages/cli/dist/index.js auth login      # opens the browser; tokens land in ~/.liads
node packages/cli/dist/index.js accounts list   # verify, then set defaultAccountId in config.json

Option 1

Install as a local MCP server.

Register the server with your MCP client; it reads the ~/.liads credentials from step 1.

# Claude Code
claude mcp add liam -- node /abs/path/liam/packages/mcp/dist/index.js

# Claude Desktop, Cursor, or any MCP client, in its MCP config JSON:
{
  "mcpServers": {
    "liam": { "command": "node", "args": ["/abs/path/liam/packages/mcp/dist/index.js"] }
  }
}

Ask for something read-only to confirm it works: "List my ad accounts" or "How did my account do in the last 30 days?"

Option 1b · nothing to deploy

Use the hosted MCP with your own credentials.

The hosted MCP server at

https://liam-mcp.vercel.app/api/mcp

is multi-tenant, bring your own credentials: you pass your LinkedIn developer app's details as request headers, and every call runs against your app and your ad account. The server holds no state for you. Credentials are used in memory to call LinkedIn and never logged or persisted.

HeaderValueRequired?
X-Liads-Client-Idyour LinkedIn app's Client IDYes
X-Liads-Client-Secretyour LinkedIn app's Client SecretYes
X-Liads-Refresh-Tokena refresh token from your auth login (about 365 days)Yes
X-Liads-Account-Idnumeric ad account id used when a call omits oneNo
X-Liads-Linkedin-VersionAPI version pin (YYYYMM), defaults to the server'sNo

Do step 0 and step 1 above once. LinkedIn's OAuth consent has to happen in your browser, so the token mint is the one local step. Then print the ready-to-run connect command:

node packages/cli/dist/index.js auth export --mcp
# claude mcp add --transport http liam https://liam-mcp.vercel.app/api/mcp \
#   --header "X-Liads-Client-Id: ..." \
#   --header "X-Liads-Client-Secret: ..." \
#   --header "X-Liads-Refresh-Token: ..." \
#   --header "X-Liads-Account-Id: ..."

Any MCP client that supports custom headers works the same way (for clients without native HTTP transport: npx mcp-remote <url> --header ...). Verify with a read-only call ("list my ad accounts"), and from then on the connection is just a URL plus headers, usable from machines that never cloned the repo.

Know the trade-off: your client secret and refresh token travel with every request to that server, so only point them at a deployment you trust, or self-host the identical endpoint (next section). The scopes they carry can manage ads but never activate them; the draft-only rule is enforced in the tool layer itself.

Hosted limitations

All by design, they need local resources: upload_audience_csv reads a CSV path on the server, audience_from_salesforce needs your local sf CLI, competitor-ads scraping falls back to API-only metadata (no browser), and the change journal and lift tools need the local ~/.liads/changelog.jsonl. Run those through the local MCP server or the CLI.

Put a skill on top

The clean split for teams: your MCP client's config holds the credentials (the headers above), and a small skill or system prompt holds your playbook: default ad account, naming conventions, standing exclusions, house rules like "audience expansion always off". The skills folder in the repo is a working example; copy one, swap in your defaults, and your assistant drives the hosted tools with your rules. Credentials never belong in a skill file.

Option 1c

Self-host the same endpoint on Vercel.

Run your own instance so credentials never touch shared infrastructure, and so you also get a private env-configured tenant.

  1. Get your env values locally: node packages/cli/dist/index.js auth export.
  2. Deploy apps/web to Vercel (set the project Root Directory to apps/web).
  3. In Vercel project settings, add the env vars from .env.example (LIADS_CLIENT_ID, LIADS_CLIENT_SECRET, LIADS_REFRESH_TOKEN, LIADS_LINKEDIN_VERSION, and a strong MCP_AUTH_TOKEN). In Deployment Protection settings, disable Vercel Authentication for production (or add a protection-bypass secret); otherwise MCP clients can't reach the endpoint.
  4. Your MCP endpoint is https://<your-app>.vercel.app/api/mcp. Connect with the secret in the header.
claude mcp add --transport http liam https://<your-app>.vercel.app/api/mcp \
  --header "Authorization: Bearer <MCP_AUTH_TOKEN>"

Requests carrying MCP_AUTH_TOKEN use the env credentials (your tenant). Requests carrying the X-Liads-* headers use the caller's credentials instead, so one self-hosted instance can serve your whole team, each member on their own LinkedIn app.

Option 2

Install the terminal CLI.

Step 1 already left a working CLI at node packages/cli/dist/index.js. To get a global liam command instead of the long node path:

cd packages/cli && pnpm link --global
liam accounts list

# A sensible first session:
liam targeting search titles "demand generation"   # resolve targeting URNs
liam report summary -p last_30_days                # account rollup + flags
liam launch --brief examples/brief.json            # creates DRAFTS only

Tools · MCP

Every tool the server exposes.

AreaTools
Accountslist_ad_accounts
Targetinglist_targeting_facets, search_targeting (typeahead a facet for entity URNs), list_facet_entities, estimate_audience. Talk in plain language and Liam resolves the facets, estimates reach, then builds the campaign.
Audiencesupload_audience_csv (auto-cleans the CSV: normalizes columns, hashes emails, converts company domains to URLs; contact and company lists), audience_from_salesforce (SOQL to matched audience), get_audience_status
Conversionslist_conversions. Campaign creation accepts conversionIds or conversionName, falling back to the config default.
Campaignscreate_campaign_group, create_campaign, create_text_ad, create_image_ad, list_campaigns (drafts included, unlike reporting), list_ads, delete_ad. LinkedIn ignores post edits on a live ad, so to change copy you recreate: delete plus create.
Orchestratorlaunch_from_brief (audience + group + campaign + draft creatives in one call)
Reportingperformance_summary, get_performance, performance_trend. KPIs: CTR, CPC, CPM, CPL, conversion rate, cost per conversion.
Competitor intelinspect_competitor_ads: read any company's ads from the LinkedIn Ad Library. Engines: api (metadata, works hosted), scraper (ad copy via local browser), auto (both).
Change journallog_ad_change, list_ad_changes, compute_lift (before vs after performance for each recorded change)

Targeting spec

Structured targeting uses short facet names mapped to entity URNs (resolve URNs with search_targeting). URNs within a facet are ORed; facets are ANDed; excluded facets are ORed.

{ "include": { "locations": ["urn:li:geo:103644278"], "seniorities": ["urn:li:seniority:7"] },
  "exclude": { "industries": ["urn:li:industry:47"] } }

CLI reference

The full CLI reference.

liam auth login                         # OAuth, stores tokens in ~/.liads
liam auth export                        # print env vars for a self-hosted (Vercel) server
liam auth export --mcp                  # print the hosted-MCP connect command with your headers
liam accounts list                      # list accessible ad accounts
liam targeting search <facet> <query>   # typeahead a facet for entity URNs
liam targeting estimate <facet> <urns>  # audience size for one facet's URNs
liam audience upload -n <name> -f <csv> # clean + upload a CSV as a matched audience
liam audience upload ... --dry-run      # preview the cleaned CSV, no upload
liam audience from-salesforce -n <name> -q "<SOQL>"   # Salesforce query -> matched audience
liam audience status <segmentId>        # matching status + resolved size
liam conversions list                   # account conversions (pick one to track)
liam campaigns list [--group <id>]      # campaign groups + ad groups, drafts included
liam ad list <campaignId>               # ads in an ad group, drafts included
liam ad delete <creativeId>             # delete an ad (creative + its DSC post)
liam report summary [-p <period>]       # account rollup: totals, top performers, flags
liam report perf <level> [--parent <id>] # per-entity KPI rows
liam report trend <level> <id> [-b weekly|monthly]  # trend with deltas
liam launch --brief <brief.json>        # audience + group + campaign + draft creatives
liam competitor ads <advertiser>        # any company's ads from the public Ad Library
liam changelog list [-t <type>] [-i <id>]           # recorded ad changes, newest first
liam changelog add -t <type> -i <id> -f <field> --after <v>   # log a change made elsewhere
liam lift <level> <id> [-w <days>]      # before vs after performance per recorded change

# Periods: last_7_days, last_30_days, last_90_days, month_to_date, last_month.
# --account defaults to defaultAccountId from config where applicable.

Skills

Playbooks on top of Liam.

The skills directory ships ten portable agent skills that turn Liam's raw tools into opinionated workflows. Each encodes a methodology: what to pull, how to judge it, significance floors, caveats, and a fixed report format. Analysis and monitoring skills are read-only; the operating skills create drafts only, after confirmation.

  • Analyze: liam-spend, liam-performance, liam-leads, liam-competitors
  • Monitor: liam-weekly (fixed-format weekly digest), liam-health (silent-unless-fire daily guardrails)
  • Operate: liam-launch, liam-experiments, liam-audiences, liam-account-audit
./skills/install.sh   # symlinks them into ~/.claude/skills (Claude Code personal skills)

Change journal & lift

Every change is journaled, so lift is measurable.

Liam keeps an append-only journal of every change made to an ad entity, so you can measure how performance differed in the window before a change versus after it.

  • Where it lives: ~/.liads/changelog.jsonl, a plain local file. No database, no account, no setup.
  • Auto-capture: every create or update Liam makes is journaled at one HTTP chokepoint, so it can't be forgotten.
  • Manual entries: log changes made in Campaign Manager, or attach a hypothesis, with liam changelog add and a label naming the test.
  • Lift: liam lift <level> <id> compares the window before each change (default 14 days) against the window after, with per-metric deltas.

This is a directional pre and post comparison, not a controlled experiment. It is confounded by seasonality, the LinkedIn learning phase after an edit, and concurrent budget changes, so treat the deltas as a signal.

Configuration

One small config file.

Local config lives in ~/.liads/config.json (mode 0600, never in the repo). OAuth tokens are stored separately in ~/.liads/credentials.json. Hosted equivalents are the LIADS_* env vars plus MCP_AUTH_TOKEN.

FieldPurpose
clientId, clientSecretLinkedIn app credentials
linkedinVersionpinned API version, e.g. 202605
defaultAccountIdad account used when a command or brief omits one
defaultConversionNameconversion auto-selected for new campaigns when none is given
mcpAuthTokenbearer secret for a self-hosted MCP endpoint

Salesforce

From a Salesforce query to a matched audience.

Liam reads Salesforce by shelling out to the authenticated sf CLI, so it reuses your existing login and needs no new credentials. Give it a SOQL query that selects an email column and it becomes a matched audience, closing the loop from "accounts flagged in Salesforce" to "LinkedIn targeting".

liam audience from-salesforce -n "Q3 target accounts" \
  -q "SELECT Email FROM Contact WHERE Account.Target_List__c = true AND Email != null"

Safety

The safety rules.

  • Every campaign and creative is created DRAFT or PAUSED. Activation is a separate, explicit step you take in Campaign Manager.
  • Matched-audience matching takes up to 48 hours, and a campaign needs about 300 matched members to serve.
  • LinkedIn does not expose person-level ad-view data; cross-referencing is account and segment level.
  • Secrets never enter the repo. Local: ~/.liads. Hosted: your Vercel env vars, or headers you control.