@volter/twin-googleads
v0.1.30
Published
Local Google Ads twin — a faithful, stateful local Google Ads v22 REST API (accounts, GAQL reporting, conversion actions, offline conversion upload) and Data Manager v1 ingestion API your integration talks to unmodified. Built on @volter/world-core.
Readme
@volter/twin-googleads
The Google Ads twin — a local, stateful, vendor-faithful replica of the
Google Ads API on the shared
@volter/world-core kernel.
It answers for two vendor hosts, because they are one product's one credential and one account graph:
| host | what it is |
|---|---|
| googleads.googleapis.com/v22/… | the Google Ads API — accounts, the GAQL search / searchStream reporting surface, conversionActions:mutate, ConversionUploadService |
| datamanager.googleapis.com/v1/… | the Data Manager API — the ingestion path Google now steers new conversion integrations to |
A Data Manager destination names a Google Ads customer and a Google Ads conversion action, so splitting them into two packs would leave each half unable to validate the other's ids.
State lives in the kernel's append-only log; reads fold the tree. No real Google is ever called
(the only network path is the connector, over an injected executor, behind a fail-closed rate
budget). Auth is faked locally, the way every twin fakes it: any non-empty bearer is
accepted and none is ever verified — so the ya29.… token a world's
googleoauth twin mints works unchanged, and Google sign-in is not re-modeled
here. What this twin does model is everything a client branches on: the absence of a
credential (a bare 401), the developer-token requirement, the GoogleAdsFailure envelope,
and login-customer-id reachability.
Usage
bun run src/cli.ts serve # both API surfaces (writable)
bun run src/cli.ts serve --read-only
bun run src/cli.ts mirror # the React mirror (accounts, conversion actions, conversions)
bun run src/cli.ts conformance # offline property check over projected stateThe twin is born holding the account graph a freshly-connected advertiser already has — a Google Ads account is created by signing up in a browser, not through the API:
| customer | kind | reachable |
|---|---|---|
| 1234567890 Twin Manager Account | manager (MCC) | directly |
| 2345678901 Twin Advertiser | advertiser | directly |
| 3456789012 Twin Managed Client | advertiser | only with login-customer-id: 1234567890 |
Everything else is created through the vendor's own write paths (conversionActions:mutate,
customers/{id}:createCustomerClient, customerClientLinks:mutate).
Coverage
The capability manifest (src/googleads-capabilities.ts) is the real Google Ads surface as the
denominator, compiled top-down from the vendor's own protobuf definitions — every rpc in
google/ads/googleads/v22/services and google/ads/datamanager/v1 carries a google.api.http
annotation that IS its REST binding, and all 182 of them are in the manifest (158 across the
108 Google Ads service protos, 24 across the six Data Manager ones). Coverage is therefore honest
and low. Run bun scripts/manifest-baseline-one.ts googleads for the live count. As of writing:
done=62 / total=256 (regressions=0) — 182 compiled REST operations plus the auth, transport,
connector, mirror and GAQL-semantics capabilities the bindings do not name.
Modeled (proven done, each with a failable offline verify):
- Accounts —
customers:listAccessibleCustomers,customers/{id}:createCustomerClient,customerClientLinks:mutate(create →PENDING, update →ACTIVE). A client created under a manager is not directly accessible, exactly as at Google. - Auth and reachability — a missing bearer is the API front end's bare
401(noGoogleAdsFailuredetail); a missingdeveloper-tokenis401 requestError: DEVELOPER_TOKEN_PARAMETER_MISSING; a hyphenatedlogin-customer-idis400 INVALID_CUSTOMER_ID; and an account you cannot reach is403 authorizationError: USER_PERMISSION_DENIED— never a 404, because Google does not disclose the existence of accounts you were not invited to. - GAQL — a real (small, bounded) query engine over
customer,customer_client,customer_client_linkandconversion_action:SELECTwith a proto3-JSONfieldMask,WHERE(=,!=,</>,IN,LIKE,REGEXP_MATCH,IS NULL),ORDER BY,LIMIT,searchpaging (pageSize/pageToken/returnTotalResultsCount) andsearchStream's array-of-chunks wire.int64renders as a string, enums as their constant,doubleas a number. - Conversion actions —
conversionActions:mutatecreate / update / remove, withvalidateOnly(results: [], nothing written),partialFailure(good operations applied, failed slots left as{},partialFailureErrorlocated atoperations[i]),responseContentType: MUTABLE_RESOURCE, and all-or-nothing semantics whenpartialFailureis absent. - Offline conversions, both lanes —
customers/{id}:uploadClickConversions(which really does requirepartialFailure, as Google does) and Data Managerevents:ingest/requestStatus:retrieve, both validating the destination against the Google Ads account graph. - Connector — pull the reachable account hierarchy and each advertiser's conversion actions
(idempotent re-pull), push a pending entry as the mutate or ingest it represents. A
200carryingpartialFailureErroris read as a refusal, and a searchStream error arriving as a200-shaped array throws rather than folding an empty account over real state. - UI mirror — accounts, conversion actions and offline conversions, data-coupled through the store door (seed through the vendor's own write path → read the projection the screen reads → render the mirror's own component over it → assert the seeded value survives).
Todo (real surface, not yet modeled). The bulk of Google Ads is campaign construction: campaigns,
budgets, ad groups, ads, criteria, assets, asset groups, audiences, bidding strategies, labels,
experiments, drafts, recommendations, the whole planning family (keyword plans, reach plans,
geo/keyword-theme constants, audience and creator insights), billing, batch jobs, account links,
Local Services, GoogleAdsService.Mutate, googleAdsFields, and the Data Manager audience-member,
ad-event, partner-link and user-list-licence services. Also filed: GAQL metrics.* and segments.*,
the resources this twin does not store, the DURING/BETWEEN/CONTAINS operators, the
PARAMETERS clause, summary rows, multi-chunk streaming, and the gRPC wire (see below).
Never silently empty, and never a lie about the vendor. A GAQL query is answered against the
name universe compiled from the vendor's own v22 resource protos (src/googleads-gaql-universe.gen.ts),
so three cases stay apart: a name this twin serves is answered; a name Google Ads has and
this twin does not refuses with queryError: QUERY_ERROR and a [twin gap] message naming the
gap; only a name Google Ads does not have at all gets the vendor's own
UNRECOGNIZED_FIELD / BAD_RESOURCE_TYPE_IN_FROM_CLAUSE. Collapsing the middle case into the
last would have the twin asserting that Google has no keyword_plan resource. An empty results
page is never an answer — it is indistinguishable from "the account has none". The same rule holds
at the routing layer: an unmodeled real endpoint is a 404 that says so, and a malformed
pageToken or pageSize is refused rather than quietly defaulted.
The gRPC gap
This twin serves the REST binding. google-ads-api (the Node client of record) uses REST for
its reporting path — customer.query() / report() / reportStream() go to
googleads.googleapis.com/v<N>/customers/<id>/googleAds:searchStream over axios, which is exactly
the surface here — and gRPC via google-ads-node/google-gax for its service and mutate calls;
google-ads-python defaults to gRPC throughout. That half is filed as googleads.transport.grpc,
a real todo, not an exclusion. pack.adoption claims both npm clients and the Python one anyway:
a repo depending on them plainly talks to this vendor, and a claimed vendor with a recorded gap is
a better answer than mapping it to no vendor at all.
Why there is a UI mirror
Google Ads' core job is done in a browser — advertisers build campaigns, choose conversion actions
and switch between manager and client accounts at ads.google.com — so needsUi is true. The
mirror ships the screens this twin can data-couple today (accounts, conversion actions, offline
conversions); the campaign, ad-group, reporting and account-switcher screens are recorded debt
(googleads.ui.* todos), not faked.
It is an archetype D (store door) mirror rather than API-passthrough, and that is forced rather
than chosen: Google Ads has no GET read at all — every read is a POST carrying a GAQL string.
The named projections (accounts, conversion_actions, conversions) re-enter the pack's own
served API, so even the aggregate is composed out of real API responses, and sabotaging the handler
reddens every UI verify.
Files
src/googleads-twin.ts— the request handler for both hosts (routes → kernel writes/reads).src/googleads-gaql.ts— the GAQL tokenizer, parser, field registry and evaluator.src/googleads-errors.ts— theGoogleAdsFailure/google.rpc.Statusenvelopes.src/googleads-server.ts—createGoogleAdsTwinFetch/createGoogleAdsTwinServer+ the store doors.src/googleads-mirror-ui.ts+client/googleads-mirror.tsx— the React mirror.src/googleads-connector.ts— pull/push over an injected executor (liveGoogleAdsExecute).src/googleads-budget.ts— the vendor's published quota as a fail-closed declaration.src/googleads-conformance.ts— offline property checks (dev-only; lazy-imported by the CLI).src/googleads-capabilities.ts— the capability manifest (the real vendor surface).
Provenance
Google publishes no OpenAPI document for the Google Ads API, and the rendered REST reference for
v22 has already rotated off the docs nav. The first-party machine-readable source is the protobuf
definitions in googleapis/googleapis,
read 2026-09-13: the denominator is compiled from their google.api.http bindings, and every error
constant this twin serves is quoted verbatim from errors/*.proto. v22 is the version the
motivating application pins; the handler accepts any /v<N>/ segment and reports the one it was
addressed under, because clients pin different majors.
