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 term | LinkedIn entity | Holds |
|---|---|---|
| Campaign | Campaign Group | status, total or shared budget |
| Ad group | Campaign | targeting, budget, bid, schedule, format |
| Ad | Creative | the rendered ad (status: DRAFT) |
| Audience | DMP Segment | attached 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.
- Create an app at linkedin.com/developers/apps. It must be associated with your company's LinkedIn Page.
- On the Products tab, request access to the products below.
- On the Auth tab, add
http://localhost:53682/callbackas an authorized redirect URL, and note your Client ID and Client Secret. - Once the Advertising API is approved, map your ad account under Products > Advertising API > View Ad Accounts. Skip this and
accounts listcomes back empty.
| Product | Needed for | Required? |
|---|---|---|
| Advertising API | campaigns, ads, targeting, reporting, conversions | Yes |
| Audiences | uploading CSV contact and company lists as matched audiences | Only for audience upload |
| LinkedIn Ad Library | competitor ad metadata via the official API | Optional (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.jsonOption 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.
| Header | Value | Required? |
|---|---|---|
X-Liads-Client-Id | your LinkedIn app's Client ID | Yes |
X-Liads-Client-Secret | your LinkedIn app's Client Secret | Yes |
X-Liads-Refresh-Token | a refresh token from your auth login (about 365 days) | Yes |
X-Liads-Account-Id | numeric ad account id used when a call omits one | No |
X-Liads-Linkedin-Version | API version pin (YYYYMM), defaults to the server's | No |
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.
- Get your env values locally:
node packages/cli/dist/index.js auth export. - Deploy
apps/webto Vercel (set the project Root Directory toapps/web). - 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 strongMCP_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. - 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.
| Area | Tools |
|---|---|
| Accounts | list_ad_accounts |
| Targeting | list_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. |
| Audiences | upload_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 |
| Conversions | list_conversions. Campaign creation accepts conversionIds or conversionName, falling back to the config default. |
| Campaigns | create_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. |
| Orchestrator | launch_from_brief (audience + group + campaign + draft creatives in one call) |
| Reporting | performance_summary, get_performance, performance_trend. KPIs: CTR, CPC, CPM, CPL, conversion rate, cost per conversion. |
| Competitor intel | inspect_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 journal | log_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 addand 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.
| Field | Purpose |
|---|---|
clientId, clientSecret | LinkedIn app credentials |
linkedinVersion | pinned API version, e.g. 202605 |
defaultAccountId | ad account used when a command or brief omits one |
defaultConversionName | conversion auto-selected for new campaigns when none is given |
mcpAuthToken | bearer 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.