@nytka/plugin-ads
v0.1.1
Published
Google Ads connector for nytka projects. Collects monthly campaign performance into datasets/ and registers it with provenance. Read-only, and no dependencies.
Readme
@nytka/plugin-ads
Google Ads connector for nytka projects. Collects monthly campaign performance into
datasets/ and registers it with provenance. Read-only, and no dependencies.
npm i @nytka/plugin-ads
nytka-ads auth # mint the refresh token — start here
nytka-ads discover # which accounts can this credential reach
nytka-ads collect # the last 13 months, per campaign per monthShould you use this, or Google's own MCP?
Both, for different jobs. Google publishes an official Ads MCP
server — self-hosted, free, strictly
read-only, with a GAQL search tool. If you want an agent to look at an Ads account and
answer a question in conversation, use that. It is better at it than this is, and there is
nothing to maintain.
What it cannot do is leave anything behind. Every answer lands in a context window and is
gone. There is no collectedAt, no payload on disk, no registry entry, and no way to ask
what the number was last month rather than what it is now.
This connector exists for exactly that gap, and in Google Ads the gap is unusually wide. See "Conversions are never final" below — an Ads number you read today can legitimately be a different number tomorrow for the same month, so "what did we see, and when did we see it" is not bookkeeping here. It is the data.
No dependencies, and why that is worth mentioning
This was planned as the heaviest connector in the line. google-ads-api depends on
google-ads-node at 121 MB — about 126 MB installed — and unlike Search Console or Tag
Manager there is no @googleapis/* per-API split to escape into.
That was true and it was also beside the point, because the library is not required. The
Ads API transcodes to REST and publishes a discovery document. Two fetch calls do the
whole job: one form POST to exchange the refresh token, one JSON POST to run the query.
So this package installs @nytka/core and nothing else. Measured 2026-07-29 with
npm pack:
| | unpacked |
|---|---|
| @nytka/plugin-ads | 57.3 kB |
| @nytka/core, its only dependency | 12.5 kB |
| total installed | ~70 kB |
| the google-ads-api path this replaces | ~126 MB |
That is roughly 1,800× smaller, and it also sidesteps google-gax and with it the
rimraf → glob → minimatch → brace-expansion advisory chain, rather than accepting it.
The cost is that OAuth refresh, pagination and error shaping are hand-rolled rather than inherited. That is a fair trade at one query shape and a bad one at ten — if this connector ever grows a broad surface, the arithmetic changes.
Read-only, and the honest caveat
Google Ads publishes exactly one OAuth scope, and it is read-write. There is no
adwords.readonly. This is the only connector in this line that cannot promise read-only
from the credential itself — @nytka/plugin-gtm holds tagmanager.readonly and could not
write if its code tried to.
Read-only is held three ways instead, none of them the scope:
| | |
|---|---|
| The account role | The refresh token can do whatever the consenting account can do in the Ads UI. Restricting that account to the Read only role in Google Ads → Admin → Access and security is the only place Ads can enforce read-only, and it is outside this code — read the caveat below before you try |
| The endpoint allowlist | Every mutating call in the Ads API shares one URL suffix. This package builds URLs from a fixed list of two read endpoints and never from a caller-supplied path. A test greps src/ for that suffix and fails if it ever appears |
| The query gate | Only SELECT is accepted, checked before any socket is opened |
Take the first one seriously. A user who believes the credential is read-only will grant it more freely than they should, and here that belief would be wrong.
Two things about that first row, because it is written above as though it were a step and it is not one:
It is containment, not a requirement. This connector works under any role and only ever issues reads. The role decides what the token could do if it leaked — not what this code does with it.
You often cannot apply it to yourself. Google Ads requires an account to keep at least
one admin, so the sole admin of an account cannot demote themselves, and would not want to:
it costs them UI management of their own campaigns. If the token must be genuinely incapable
of writes, invite a second Google account, give it the Read only role, and mint the
refresh token signed in as that one. Otherwise accept that the token carries your access and
treat GOOGLE_ADS_REFRESH_TOKEN as the admin credential it is.
Setup
Four values. All free. They come from two different Google consoles, and that is the part that trips people up:
| | comes from |
|---|---|
| GOOGLE_ADS_DEVELOPER_TOKEN | ads.google.com — the Ads UI you already use |
| GOOGLE_ADS_CLIENT_ID, GOOGLE_ADS_CLIENT_SECRET | console.cloud.google.com — a different product |
| GOOGLE_ADS_REFRESH_TOKEN | your terminal, via nytka-ads auth |
| the customer id | your terminal, via nytka-ads discover |
Nothing links the two consoles to each other. You are not associating a Cloud project with
an Ads account anywhere — the only connection is that all four values land in one .env.
1. A developer token — in the Ads UI
Google Ads → Tools → API Center (ads.google.com/aw/apicenter), inside a manager
(MCK) account.
A manager account is a separate account type, not a setting on the account you already
have. If you sign into your normal Ads account and there is no API Center, that is why —
it is not a mistake at your end. Creating one is free, at
ads.google.com/home/tools/manager-accounts. Link your existing account to it and API
Center appears.
Check which tier you were granted before going further. The signup default is Test Account access, which cannot query production accounts at all. Explorer can, and is often granted automatically, but it is not guaranteed and it is capped at 2,880 operations/day against production. Basic lifts that to 15,000 and takes about five business days to review; Standard is about ten.
A 13-month backfill across several accounts can reach the Explorer cap. If collect starts
failing on quota rather than on credentials, that is where to look.
Tiers and limits verified 2026-07-29 against Google's access-levels page. Google moves these — re-check if it is much past 2027-01.
2. An OAuth client — in Cloud console
Reuse the Cloud project you already have. If GA4, GSC or GTM is installed on this project, one exists with APIs already enabled on it. On that project:
- Enable the Google Ads API — APIs & Services → Library. Creating the client does not
do this. Skipping it fails at
discoverand never atauth, so it presents as a credential problem when it is not one. - Credentials → Create credentials → OAuth client ID → Desktop app.
Put the id and secret in .env. The secret is shown once.
Do not reuse the service account. Every other Google connector in this line runs on
GOOGLE_SERVICE_ACCOUNT_KEY, so it is the first thing to reach for, and it cannot work here. Google Ads has no way to admit a service account — you cannot add one under Access and security, because that flow sends an invitation a service account has no way to accept. The only service-account route into Ads is Workspace domain-wide delegation, which is a different setup and not one this connector asks for.The OAuth client sits alongside the service account on the same Cloud project. They do not conflict.
You do not need to configure a redirect URI. Desktop-app clients accept any loopback
port and auth binds a throwaway one. People go hunting for that setting; it does not exist
for this client type.
3. Publish the consent screen — in Cloud console
Google Auth Platform → Audience → Publish app, before running auth.
While the app is in Testing, consent is refused outright for any account not on the test-user list — you are blocked long before the seven-day token expiry can reach you. Publishing fixes both at once.
On a personal @gmail.com the user type is External, and External + unverified is a
working configuration for a connector with one user:
- You will see a "Google hasn't verified this app" screen at consent. Click Advanced → Go to (unsafe). Expected, and once.
- Unverified apps are capped at 100 users. You are one.
- Verification — privacy policy, domain ownership, weeks of review — is only needed to remove that screen or to exceed 100 users. Neither applies.
Workspace accounts can choose Internal and skip the warning screen. That is the only thing Internal buys here; it is not a reason to acquire Workspace.
The alternative is staying in Testing, adding yourself under Audience → Test users, and re-minting the token every seven days. Publishing is the better trade.
4. A refresh token — back in your terminal
npx nytka-ads authIt prints a URL, waits on a loopback port, exchanges the code and prints the token. Open the
URL, sign in as an account with access to the Ads account, approve. Paste the result into
.env as GOOGLE_ADS_REFRESH_TOKEN.
It does not expire, so this is a one-time step — and re-running is always safe: consent is forced every time, so you get a fresh token rather than the silent empty response Google returns on a repeat approval.
authis the one command in this line that prints a credential, because a refresh token that isn't shown to the person who just consented exists nowhere else. It lands in your terminal scrollback; the command says so when it runs.
5. The customer id — in your terminal
nytka-ads discover lists every account the token reaches. A manager account holds no
campaigns of its own, so collect the client accounts under it and set
GOOGLE_ADS_LOGIN_CUSTOMER_ID to the manager.
Every variable, with the same prose, is in .env.example:
cat node_modules/@nytka/plugin-ads/.env.example >> .envUnset, every command names what is missing and exits 0. An unconfigured project is not a broken install (decision 0008).
collect
nytka-ads collect # last 13 whole months
nytka-ads collect --customer 123-456-7890
nytka-ads collect --from 2025-01-01 --to 2025-12-31
nytka-ads collect --no-registerOne row per campaign per month — impressions, clicks, cost, conversions, conversion value — plus a per-month total. Cost is kept in micros exactly as the API returns it and also converted, because micros is the only lossless form.
The dataset id is keyed to the customer and the period at month granularity, not the exact window. Running this daily for a month replaces one entry rather than leaving thirty near-identical ones.
Conversions are never final
Search Console and GA4 finalise: wait two or three days and the number stops moving. Ads does not work that way. Impressions, clicks and cost settle within about a day, but conversions keep accruing for as long as the account's conversion window, which can be 90 days. No lag makes a recent month final.
So this connector does not pretend otherwise. The default window trims one day to drop the
partial tail, collectedAt is written onto the payload as well as the registry entry, and
validUntil is null — one expiry cannot honestly describe a window whose older months
are settled and whose newest month can move tomorrow.
Re-collect, and the entry is replaced. The difference between the two readings is a real finding about attribution, not a bug.
Importing it
import { run, client, collectCampaigns, authorize } from '@nytka/plugin-ads'
const res = await run({ customerId: '1234567890' })run returns counts, totals and paths — never the campaign rows. A year of per-campaign
spend belongs in a payload to be queried from a file, not loaded into an agent's context.
Every network-touching function takes an injectable client or fetchImpl, which is how the
85 tests in this package run with no credential and no network.
Licence
MIT
