Getting Started with gtme: GTM as Code
gtme is a CLI for GTM pipelines — campaigns as YAML, an append-only ledger, AI steps behind contracts, and receipts for every dollar. Here's the mental model that makes it click, plus a full tutorial: author a brand-new vendor adapter in YAML and run it in a pipeline, end to end.
gtme is a CLI for GTM data pipelines, built for engineers who do GTM — not the other way around. Campaigns are YAML files. Everything your pipelines learn lands in an append-only SQLite ledger you can query. AI judgment sits behind the same schema contracts as every other step. And every run ends in a receipt: what moved, what it cost, and what the cache saved you.
This post does two things. First, it gives you the mental model — the five ideas that make everything else in gtme predictable. Second, it walks a real tutorial: you'll write a brand-new vendor adapter as a single YAML file, install it, and run it in a pipeline with an AI step and a delivery — including the part where you validate the whole thing offline before spending a cent. Every command and every output in this post is real.
The mental model: five things to know
You can be productive with gtme knowing exactly five things.
1. The ledger is the bus
Steps never pass records to each other. Every step reads a projection of what the ledger already knows about a record and writes new facts back — each with provenance (which adapter, which run), confidence, and a timestamp. Two consequences follow immediately: knowledge accumulates across runs instead of living inside one pipeline execution, and re-running anything is cheap, because a step whose answer is already fresh in the ledger gets skipped — and the receipt tells you what that skip saved.
2. Pipelines are YAML with a deliberately small grammar
One file describes a campaign: a source and ordered steps — and
delivery is just a step, so a pipeline can send to zero, one, or several
targets, at any point in the chain. There is no expression language and no
DSL — a small, enumerable set of keys, which is also what makes pipelines
safe for coding agents to write. gtme help --agent emits the entire
surface as one machine-readable document for exactly that reason.
3. Contracts make everything checkable before it runs
Every step declares what it needs and what it provides against a
canonical field registry — shared names like email, full_name,
company_domain, with normalization rules attached. gtme plan walks the
whole pipeline and proves it coherent — every need met, every credential
resolvable, costs estimated — before a single record moves. AI steps
declare their needs too (uses:), so even a free-text prompt is
plan-checkable.
4. The gate ladder: simulate → plan → dry-run → armed
Delivery — the one action you can't take back — sits behind a ladder.
--simulate executes the entire pipeline offline from fixtures: no
network, no keys, no spend. plan proves contracts. --dry-run runs
everything except delivery and renders, per record, exactly what would
send — that's the artifact you review. Arming is the same command without
the flag. Re-running an armed pipeline delivers nothing twice; idempotency
is structural, not a convention.
5. Adapters are data (until they can't be)
Most vendor APIs are CRUD over HTTP, so most gtme adapters are bindings: declarative YAML interpreted by one generic engine — auth, pagination, field extraction, error handling, all frozen at authoring time. A binding can't execute code, which is what makes third-party adapters reviewable by reading them. Only when an integration needs real logic does it graduate to a process adapter (any language, NDJSON over stdin/stdout). The tutorial below is you writing a binding.
Setup, and a campaign with zero API keys
git clone https://github.com/elegant-atomics/gtme && cd gtme
./install.sh # builds gtme, installs it to ~/.local/bin, runs gtme init
install.sh is deliberately boring — it compiles from the checkout and
copies one static binary into place, then warns you if ~/.local/bin
isn't on your PATH (add it to your shell profile, or use
PREFIX=/usr/local ./install.sh). Prefer not to install? make build
gives you gtme and every command below works with that path
instead.
Before touching a single credential, run a whole campaign offline:
gtme run examples/demo.yaml --simulate
The Apollo source serves its conformance fixtures, the AI steps answer synthetically (marked as synthetic in provenance), and delivery is held with its merge variables resolved into the receipt. One record even fails the delivery floor on purpose — its email is Apollo's locked-email placeholder, and gtme refuses to key an identity on garbage. The receipts are honest; that's the point of them.
Tutorial: bring in a new adapter and run it
Here's the claim that matters most: adding a vendor to gtme is writing a
YAML file. Let's prove it with a real API. We'll use
JSONPlaceholder — a free fake API
with a /users endpoint — because you can run every step of this without
signing up for anything. The shape of the work is identical for Apollo,
Attio, Instantly, or the vendor you actually care about.
Step 1 — Look at what the API returns
curl -s https://jsonplaceholder.typicode.com/users | head -20
[
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": { "city": "Gwenborough", ... },
"website": "hildegard.org",
"company": { "name": "Romaguera-Crona", ... }
},
...
]
An adapter's whole job is mapping this vendor dialect onto gtme's canonical fields, at the boundary, once — so nothing downstream ever thinks about it again.
Step 2 — Write the binding
A binding lives in a directory under ~/.gtme/adapters/:
mkdir -p ~/.gtme/adapters/jsonplaceholder-users/fixtures
Then ~/.gtme/adapters/jsonplaceholder-users/binding.yaml:
id: jsonplaceholder/users
version: 1
role: source # source | enrich | deliver
entity_type: person
provides: # the contract: what this adapter emits
type: object
additionalProperties: false
properties:
full_name: { type: string }
email: { type: string }
city: { type: string }
company_name: { type: string }
company_domain: { type: string }
jsonplaceholder.username: { type: string }
config_schema:
type: object
additionalProperties: false
properties:
limit:
type: integer
minimum: 1
description: Stop after this many people
base_url:
type: string
default: "https://jsonplaceholder.typicode.com"
request:
method: GET
url: "{{config.base_url}}/users"
extract:
records: "." # the response root IS the record array
fields:
full_name: name
email: { path: email, transform: email }
city: address.city
company_name: company.name
company_domain: { path: website, transform: domain }
jsonplaceholder.username: username
Read the extract block closely, because it's doing the real work:
- Dotted paths walk the response (
address.city,company.name). - Transforms are registry rules, not code.
transform: emaillowercases and validates (JSONPlaceholder returnsSincere@april.biz; the ledger storessincere@april.biz— and sinceemailis an identity field, that normalization is what makes dedupe work).transform: domainreduceswebsiteto a registrable domain. You cannot write arbitrary logic here — that's a feature. The moment a binding needs logic, it graduates to a process adapter. jsonplaceholder.usernameis vendor-namespaced. It's not a canonical field, so it keeps a vendor prefix — stored, queryable, and visibly vendor-coupled if a pipeline ever depends on it.- Real APIs add three more blocks you'd declare the same way:
auth(header/bearer + which env var),pagination(page, cursor, or offset, plus termination), andidempotencyfor deliver bindings. See the shipped Apollo binding for all of them in ~150 lines.
Step 3 — Give it fixtures
Fixtures are a saved real response, and they do double duty: they're the
adapter's conformance test and what --simulate serves. Mint them from
the live API:
curl -s https://jsonplaceholder.typicode.com/users | \
python3 -c '
import json,sys
users = json.load(sys.stdin)
print(json.dumps({"responses":[{"match":"GET /users","status":200,"body":users}]}, indent=2))
' > ~/.gtme/adapters/jsonplaceholder-users/fixtures/conformance.json
A binding without fixtures still runs live — but simulation will surface it as a visible gap rather than silently passing. gtme never pretends.
Step 4 — Use it in a pipeline
The adapter now resolves by id like any built-in. Wire it into a pipeline with an AI judgment step and a CSV delivery (the "universal out" — reviewable by humans, importable by anything):
# tutorial.yaml
name: tutorial
source:
use: jsonplaceholder/users
with: { limit: 5 }
steps:
- id: fit
use: ai/filter
uses: [full_name, company_name] # the AI step's declared needs
with:
prompt: Keep people whose company name sounds like a real business.
- id: deliver # delivery is an ordinary step —
use: csv/deliver # put it anywhere, use several
with: { path: reviewed.csv }
variables: # egress mapping: column ← ledger field
name: full_name
company: company_name
idempotency: email
Step 5 — Climb the ladder
Simulate — the whole pipeline, offline, zero keys. Your fixtures serve the source; the AI answers synthetically; delivery holds:
gtme run tutorial.yaml --simulate
run 01M06... — done (SIMULATED — fixtures only; nothing sent, nothing persisted)
step adapter in out cached cost avoided
source jsonplaceholder/users 0 5 0 $0 -
fit ai/filter 5 5 0 $0 -
deliver csv/deliver 0 0 0 $0 -
deliver: resolved variables for 5 record(s) — review, then arm:
sincere@april.biz
company: "Romaguera-Crona"
name: "Leanne Graham"
...
Plan — contracts and credentials, still nothing spent:
gtme plan tutorial.yaml
# ...
# send surface: 1 deliver step(s) (ADR-031)
# deliver → csv/deliver (touch scope: tutorial)
#
# available fields after the last step: city, company_domain, company_name,
# email, full_name, jsonplaceholder.username
# plan ok — nothing has been spent
The send surface block is the plan calling out every deliver step —
target and touch scope — in one place, so a pipeline's send points are
reviewable at a glance no matter where its deliver steps sit.
Try breaking it — change uses: [full_name] to uses: [headline] and
plan again. The error names the step, the field, and what is available.
Errors are prompts here, not stack traces.
Arm — the live API, a real model judging, a real receipt:
gtme secret set ANTHROPIC_API_KEY # prompts, no echo
gtme run tutorial.yaml
step adapter in out cached cost avoided
source jsonplaceholder/users 0 5 0 $0 -
fit ai/filter 5 5 0 $0.0050 -
deliver csv/deliver 5 5 0 $0 -
total: $0.0050 spent
cat reviewed.csv
identity_key,company,name
lucio_hettinger@annie.ca,Keebler LLC,Chelsey Dietrich
sincere@april.biz,Romaguera-Crona,Leanne Graham
shanna@melissa.tv,Deckow-Crist,Ervin Howell
...
Half a cent, and notice sincere@april.biz — the mixed-case email your
binding normalized at the boundary, now serving as the delivery
idempotency key. Run it again: the deliver line reads 0 in, 5 cached,
the CSV gains nothing, and nobody is ever delivered twice.
Step 6 — Interrogate what you now know
The ledger is yours. No log spelunking:
gtme show sincere@april.biz --provenance # every fact, who wrote it, when
gtme runs last # the receipt, reconstructed
gtme query "SELECT field, value FROM current_fields LIMIT 10"
Every value carries its source: jsonplaceholder/users@1 for the fields
your binding wrote, ai/filter @ claude-... for the judgment — the model
identifier is part of provenance, so you always know who decided.
Where to go next
- Point this at your vendor. The binding you just wrote is the whole
pattern; add
authandpaginationblocks and you've integrated a real API. APIs with published OpenAPI specs are the easiest targets. gtme freeze --bundlesnapshots any run into a portable folder — pipeline, bindings, fixtures, hash manifest — that simulates and runs anywhere. Campaigns become artifacts you can review and share.- Groups turn AI verdicts into recorded decisions: a qualify pipeline fills a group, a send pipeline consumes it, suppression windows enforce contact policy — and nothing gets re-judged run to run.
- The docs are unusual and worth it: SPEC.md is the canon the binary is built from, DECISIONS.md records why every choice was made, and VALIDATION.md is the receipts-first log of real campaigns run during development — real API drift, real dollar amounts, findings included.
gtme is open source under Apache-2.0. If you're an engineer who does GTM, it was built for you — and if you wire it into something interesting, we'd genuinely like to hear about it.