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
40 changes: 36 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ pnpm + TypeScript monorepo:
math every platform shares. Anything here must be true of both platforms; anything true
of only one belongs in that platform's package.
- `packages/core` — the LinkedIn engine. No UI. Owns the LinkedIn REST client, every
resource module, audience hashing, targeting, conversions, and the Salesforce reader.
resource module, audience hashing, targeting, conversions, and the Salesforce and
HubSpot readers.
- `packages/google` — the Google Ads engine. Same shape as core: config and auth, one HTTP
chokepoint, thin typed resource modules over GAQL, zod schemas reused as MCP inputs.
- `packages/mcp` — MCP server. `src/tools.ts` (LinkedIn) and `src/googleTools.ts`
Expand Down Expand Up @@ -53,14 +54,16 @@ modules are thin typed wrappers over `LinkedInClient.request()`.
`core/src/schemas.ts` and are reused as MCP tool input schemas (`Schema.shape`). Add or
change a field there first, then thread it through the resource module.
- **Secrets never enter the repo.** Local credentials live in `~/.adwarp/` (LinkedIn:
`config.json` + `credentials.json`; Google: `google.json` + `google-credentials.json`,
all mode 0600). Hosted credentials are `ADWARP_LINKEDIN_*` / `ADWARP_GOOGLE_*` env vars, or
`config.json` + `credentials.json`; Google: `google.json` + `google-credentials.json`;
HubSpot: `hubspot.json`, all mode 0600). Hosted credentials are `ADWARP_LINKEDIN_*` / `ADWARP_GOOGLE_*` env vars, or
per-request `X-Adwarp-Linkedin-*` headers for bring-your-own-credentials callers. The config layer resolves
request context first, then env, then files. Never log header credentials.
- **The hosted MCP does not expose the Google tools**, and must not start doing so. That
endpoint is multi-tenant over per-request LinkedIn credentials while Google credentials
would come from shared server env vars, so exposing them would let any caller operate the
server's Google Ads account. `registerTools(server, { google: false })` in the web route.
server's Google Ads account. The same holds for `audience_from_hubspot`, whose token
would come from a server env var: `registerTools(server, { google: false, hubspot: false })`
in the web route.
- **Every name says adwarp, and every pre-rename name keeps working.** Adwarp was Liam, and
its internal names used `liads` and `gads`. Read env vars through `env()` in
`@adwarp/shared`, whose `LEGACY_ENV_NAMES` table maps each `ADWARP_*` name to its
Expand Down Expand Up @@ -206,3 +209,32 @@ change by initializing the MCP endpoint and listing tools (see README).
`core/src/salesforce.ts` shells out to the authenticated `sf` CLI (`sf data query --json`).
No new credentials; reuses the user's existing `sf` login. Read-only. `audienceFromSalesforce`
turns a SOQL email query into a matched-audience DMP segment (reuses `uploadAudienceFromEmails`).

## HubSpot

`core/src/hubspot.ts` reads HubSpot with a private app access token the user creates in their
own account (`ADWARP_HUBSPOT_TOKEN`, else `{ "accessToken" }` in `hubspot.json` in the Adwarp
folder). Read-only scopes: `crm.objects.contacts.read`, `crm.objects.companies.read`,
`crm.lists.read`. No OAuth, local only (never registered on the hosted endpoint).
`audienceFromHubSpot` turns a list's members or a CRM search into rows, runs them through
`cleanAudienceRows` (the CSV cleaner) and uploads with `uploadCleanedAudience` (the CSV upload),
so contacts leave only as SHA256 hashes. `previewHubSpotAudience` is the dry run: no LinkedIn
call, emails masked in the sample. Verified against HubSpot's docs on 2026-10-18, not yet
against a live portal:

- **Version is pinned** (`2026-09`, date-based, in `HUBSPOT_API_VERSION`). HubSpot moved from
v3 paths to `/crm/objects/2026-09/...` and `/crm/lists/2026-09/...` in March 2026; legacy v3
paths stop being supported in September 2027, and each date version gets 18 months.
- **Search** is `POST /crm/objects/2026-09/{contacts|companies}/search`: `filterGroups[].filters[]`
of `{ propertyName, operator, value | values | highValue }`, 200 per page, cursor in
`paging.next.after`, at most 10,000 results per query (paging past it is a 400, so Adwarp
refuses up front from `total`), 5 requests per second, up to 6 filters per group.
- **Lists:** `GET /crm/lists/2026-09/{listId}` gives `list.objectTypeId` (`0-1` contacts, `0-2`
companies); `GET .../{listId}/memberships` pages `results[].recordId`. Records then come from
`POST /crm/objects/2026-09/{type}/batch/read`, 100 ids per call, which answers 207 when some
ids no longer exist.
- **Limits:** private apps get 100 requests per 10 seconds on Free and Starter (190 above).
The client paces under both and retries 429 / 5xx with the shared policy.
- **Change journal:** audience creation goes through `createLinkedInAds()` like Salesforce, so
it is captured exactly as Salesforce audiences are. The journal only tracks campaign groups,
campaigns and creatives, so DMP segment creation is not journaled for either source.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pnpm -r typecheck

## Layout

- `packages/core`: LinkedIn REST client, OAuth, resource modules, audience hashing, Salesforce reader.
- `packages/core`: LinkedIn REST client, OAuth, resource modules, audience hashing, Salesforce and HubSpot readers.
- `packages/mcp`: MCP server. `src/tools.ts` registers tools; `src/index.ts` is the stdio entry.
- `packages/cli`: the `adwarp` CLI (also installed as `liam`, the legacy alias).
- `packages/adwarp`: the npm package `adwarp`, which bundles the CLI and the MCP server. Test
Expand Down
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ campaigns can only be switched on in the Google Ads UI.

> Unofficial and not affiliated with or endorsed by LinkedIn or Google.

> Covers creation, matched audiences (including building one straight from Salesforce),
> Covers creation, matched audiences (including building one straight from Salesforce or HubSpot),
> conversion selection, performance reporting/insights, competitor ad intelligence, and a
> change journal with before/after lift. See [ROADMAP.md](./ROADMAP.md) for what's shipped
> and what's planned next.
Expand All @@ -31,6 +31,8 @@ handles today, roughly in the order a campaign comes together:
company domains to URLs)
- "Build an audience from Salesforce: every contact on an account flagged as a target."
(SOQL straight to a matched audience)
- "Turn our HubSpot target accounts list into a LinkedIn audience." (a HubSpot list or search
straight to a matched audience of contacts or companies)
- "Launch a draft campaign for that audience, $100/day, tracking the demo-booked
conversion." (campaign group + campaign + draft ads in one call)
- "What ads is our biggest competitor running right now, and what offers are they pushing?" (reads the
Expand Down Expand Up @@ -157,7 +159,7 @@ steps, plus the `ADWARP_GOOGLE_*` environment variables.
## Packages

- `@adwarp/shared`: the platform-neutral layer: OAuth, credential stores, retry policy, the change journal, period and KPI math.
- `@adwarp/core`: LinkedIn REST client, resource modules, CSV + SHA256 hashing, Salesforce reader.
- `@adwarp/core`: LinkedIn REST client, resource modules, CSV + SHA256 hashing, Salesforce and HubSpot readers.
- `@adwarp/google`: Google Ads REST client, GAQL reads, atomic mutate builder, Search campaign creation.
- `@adwarp/mcp`: MCP server (stdio for local; reused by the hosted app). **Primary interface.**
- `@adwarp/cli`: the `adwarp` CLI over the same engines, for scripted batch runs.
Expand Down Expand Up @@ -318,6 +320,7 @@ so treat these credentials like an ad account password.

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,
`audience_from_hubspot` is not registered there (its HubSpot token stays on your machine),
competitor-ads scraping falls back to API-only metadata (no browser), and the change
journal / lift tools need the local `~/.adwarp/changelog.jsonl`. Run those through the
local MCP server or CLI.
Expand Down Expand Up @@ -461,7 +464,7 @@ Operate:

- **adwarp-launch**, "launch a campaign": brief to confirmed draft with house guardrails applied
- **adwarp-experiments**, "did that change help": hypothesis logging plus before/after lift
- **adwarp-audiences**, "audit my audiences": health, dry-run uploads, Salesforce refresh loop
- **adwarp-audiences**, "audit my audiences": health, dry-run uploads, Salesforce and HubSpot refresh loops
- **adwarp-account-audit**, "is my account set up right": hygiene scorecard and fix list

```bash
Expand All @@ -485,7 +488,8 @@ whether it works on the hosted endpoint.
estimates reach before building the campaign.
- **Audiences:** `upload_audience_csv` (auto-cleans the CSV: normalizes column names, drops
non-matcher columns, hashes emails, converts company domains to website URLs; supports both
contact and company lists), `audience_from_salesforce` (SOQL to matched audience), `get_audience_status`
contact and company lists), `audience_from_salesforce` (SOQL to matched audience),
`audience_from_hubspot` (HubSpot list or search to matched audience, local only), `get_audience_status`
- **Conversions:** `list_conversions` (select an existing insight-tag conversion to track).
`create_campaign` and `launch_from_brief` accept `conversionIds` or `conversionName`, and
fall back to `defaultConversionName` from config.
Expand Down Expand Up @@ -537,6 +541,8 @@ adwarp targeting estimate <facet> <urns…> # audience size for one facet's URNs
adwarp audience upload -n <name> -f <csv> # clean + upload a CSV as a matched audience
adwarp audience upload -n <name> -f <csv> --dry-run # preview the cleaned CSV, no upload
adwarp audience from-salesforce -n <name> -q "<SOQL>" # Salesforce query -> matched audience
adwarp audience from-hubspot -n <name> --list <id> [--dry-run] # HubSpot list -> matched audience
adwarp audience from-hubspot -n <name> --filter <prop>:<op>:<value> [-t company] # HubSpot search -> matched audience
adwarp audience status <segmentId> # matching status + resolved size
adwarp conversions list # account conversions (pick one to track)
adwarp campaigns list [--group <id>] [--all] # campaign groups + ad groups, drafts included
Expand Down Expand Up @@ -597,7 +603,9 @@ Local config lives in `~/.adwarp/config.json` (mode 0600, never in the repo):
| `mcpAuthToken` | Bearer secret for the hosted MCP endpoint |

Hosted equivalents are the `ADWARP_LINKEDIN_*` env vars plus `MCP_AUTH_TOKEN` (see [Configuration](https://adwarp.dev/docs/config)).
OAuth tokens are stored separately in `~/.adwarp/credentials.json`.
OAuth tokens are stored separately in `~/.adwarp/credentials.json`. A HubSpot private app
token, for [HubSpot audiences](https://adwarp.dev/docs/hubspot), goes in `~/.adwarp/hubspot.json` (mode 0600)
or `ADWARP_HUBSPOT_TOKEN`.

Coming from Liam? Every name from before the rename still works: the `~/.liads` folder, the
`LIADS_*` and `GADS_*` variables, and the `X-Liads-*` headers. See
Expand All @@ -616,6 +624,24 @@ adwarp audience from-salesforce -n "Q3 target accounts" \
-q "SELECT Email FROM Contact WHERE Account.Target_List__c = true AND Email != null"
```

## HubSpot integration

Adwarp reads HubSpot with a private app access token you create in your own HubSpot account,
with 3 read-only scopes: `crm.objects.contacts.read`, `crm.objects.companies.read` and
`crm.lists.read`. Put the token in `~/.adwarp/hubspot.json` as `{ "accessToken": "..." }`
(mode 0600) or in `ADWARP_HUBSPOT_TOKEN`. `audience_from_hubspot` (and
`adwarp audience from-hubspot`) take a HubSpot list id or CRM search filters, read every
page, and turn the contacts or companies into a matched audience through the same cleaning
and hashing as a CSV upload, so only email hashes leave your machine. It runs locally only.
Example:

```
adwarp audience from-hubspot -n "Q3 target accounts" --list 123 --dry-run
adwarp audience from-hubspot -n "Marketing qualified leads" --filter lifecyclestage:EQ:marketingqualifiedlead
```

See [HubSpot audiences](https://adwarp.dev/docs/hubspot) for creating the private app and the limits.

## Safety

- Every campaign/creative is created **DRAFT/PAUSED**. Activation is a separate, explicit step.
Expand Down
9 changes: 5 additions & 4 deletions apps/web/app/api/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ import { registerTools } from "@adwarp/mcp/tools";
const mcpHandler = createMcpHandler(
// The adapter's server is the same MCP SDK type; cast across the version boundary.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// Google tools are withheld here on purpose: this endpoint is multi-tenant
// over per-request LinkedIn credentials, and Google credentials would come
// from shared server env vars. See RegisterToolsOptions.
(server) => registerTools(server as any, { google: false }),
// Google tools and HubSpot audiences are withheld here on purpose: this
// endpoint is multi-tenant over per-request LinkedIn credentials, and Google
// and HubSpot credentials would come from shared server env vars. See
// RegisterToolsOptions.
(server) => registerTools(server as any, { google: false, hubspot: false }),
{ serverInfo: { name: "adwarp", version: VERSION } },
{ basePath: "/api" },
);
Expand Down
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ adwarp targeting estimate <facet> <urns...> # audience size for 1 facet's URNs
adwarp audience upload -n <name> -f <csv> [-t contact|company] # clean and upload a CSV as a matched audience
adwarp audience upload -n <name> -f <csv> --dry-run # preview the cleaned CSV, no upload
adwarp audience from-salesforce -n <name> -q "<SOQL>" [--email-field <column>] # Salesforce query to matched audience
adwarp audience from-hubspot -n <name> (--list <id> | --filter <prop>:<op>:<value> ...) [-t contact|company] [--dry-run] # HubSpot list or search to matched audience
adwarp audience status <segmentId> # matching status and resolved size
adwarp conversions list # account conversions (pick 1 to track)
adwarp campaigns list [--group <id>] [--all] # campaign groups and ad groups, drafts included
Expand Down
2 changes: 1 addition & 1 deletion docs/clients-claude-code.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Claude Code
description: "Add Adwarp to Claude Code as a local MCP server or through the hosted endpoint, then check that it works."
order: 13
order: 14
---

Claude Code runs Adwarp as a local MCP server with every tool, or connects to the hosted endpoint for the LinkedIn tools. Either way, [create a LinkedIn app](./linkedin-app.md) and [authenticate](./install.md#authenticate) first.
Expand Down
2 changes: 1 addition & 1 deletion docs/clients-claude-desktop.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Claude Desktop
description: "Add Adwarp to Claude Desktop's MCP config, restart the app, and check that it works."
order: 14
order: 15
---

Claude Desktop runs Adwarp as a local MCP server with every tool, including the Google Ads tools. [Create a LinkedIn app](./linkedin-app.md) and [authenticate](./install.md#authenticate) first.
Expand Down
2 changes: 1 addition & 1 deletion docs/clients-cli.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: CLI
description: "Run Adwarp from your terminal with no AI client: install the adwarp command, check that it works, and put it on a schedule."
order: 17
order: 18
---

Every capability of the MCP server is also an `adwarp` command, for your terminal, scripts and cron. It needs no AI client. [Create a LinkedIn app](./linkedin-app.md) first.
Expand Down
2 changes: 1 addition & 1 deletion docs/clients-codex.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Codex
description: "Add Adwarp to the Codex CLI with 1 command, then check that it works."
order: 15
order: 16
---

The Codex CLI runs Adwarp as a local MCP server with every tool, including the Google Ads tools. [Create a LinkedIn app](./linkedin-app.md) and [authenticate](./install.md#authenticate) first.
Expand Down
2 changes: 1 addition & 1 deletion docs/clients-cursor.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Cursor
description: "Add Adwarp to Cursor with 1 click or 1 config entry, then check that it works."
order: 16
order: 17
---

Cursor runs Adwarp as a local MCP server with every tool, including the Google Ads tools. [Create a LinkedIn app](./linkedin-app.md) and [authenticate](./install.md#authenticate) first.
Expand Down
3 changes: 3 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Local config lives in `~/.adwarp/config.json` (mode 0600, never in the repo). OA

Google Ads has its own files in the same folder: `~/.adwarp/google.json` and `~/.adwarp/google-credentials.json`. See [Google Ads](./google-ads.md).

HubSpot audiences read a private app token from `~/.adwarp/hubspot.json` (mode 0600), as `{ "accessToken": "..." }`. See [HubSpot audiences](./hubspot.md).

## Hosted equivalents

A hosted deploy reads environment variables instead of files:
Expand All @@ -34,6 +36,7 @@ A hosted deploy reads environment variables instead of files:

| Variable | Purpose |
| --- | --- |
| `ADWARP_HUBSPOT_TOKEN` | HubSpot private app token for [HubSpot audiences](./hubspot.md), used instead of `~/.adwarp/hubspot.json` |
| `ADWARP_CHANGELOG_PATH` | override the change journal path |
| `ADWARP_NO_CHANGELOG` | set to `1` to turn off change journal auto-capture |
| `ADWARP_CREATIVE_WORKER_URL`, `ADWARP_CREATIVE_WORKER_TOKEN` | the [remote creative worker](https://github.com/adwarp-dev/adwarp/tree/main/services/creative-worker) for competitor ad copy |
Expand Down
3 changes: 2 additions & 1 deletion docs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,12 @@ These are by design, because they need local resources. Run them through the [lo

- `upload_audience_csv` reads a CSV path on the server, not on your machine.
- `audience_from_salesforce` needs your local `sf` CLI.
- `audience_from_hubspot` is not registered on the hosted endpoint. Its HubSpot token would come from a server environment variable, so any caller could read that HubSpot account's contacts.
- `inspect_competitor_ads` returns Ad Library metadata only, unless the server is configured with the [remote creative worker](https://github.com/adwarp-dev/adwarp/tree/main/services/creative-worker). Nothing launches a browser.
- The change journal and lift tools (`log_ad_change`, `list_ad_changes`, `compute_lift`) need the local `~/.adwarp/changelog.jsonl`. Journaling is off on hosted deploys, which have a read-only filesystem.
- The Google Ads `gads_*` tools are not registered on the hosted endpoint at all. It serves many callers, each with their own LinkedIn credentials, while Google credentials would come from shared server environment variables. Exposing them would let any caller operate the server's Google Ads account.

[tools.json](./tools.json) says which tools work here. `hosted` is `true` for a tool that works on the hosted endpoint, and `hostedSupport` is `full`, `limited` or `none`. The endpoint registers every LinkedIn tool, but `upload_audience_csv`, `audience_from_salesforce` and the 3 journal tools need your machine, so they are `hosted: false`. `inspect_competitor_ads` is `limited`. `hostedNote` says why.
[tools.json](./tools.json) says which tools work here. `hosted` is `true` for a tool that works on the hosted endpoint, and `hostedSupport` is `full`, `limited` or `none`. The endpoint registers every LinkedIn tool except `audience_from_hubspot`. That tool, `upload_audience_csv`, `audience_from_salesforce` and the 3 journal tools need your machine, so all 6 are `hosted: false`. `inspect_competitor_ads` is `limited`. `hostedNote` says why.

## Put a skill on top

Expand Down
Loading
Loading