·
GTMgtmeAI

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.

Trevor — Elegant Atomics

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 whole shape at speed: pipeline.yaml, the plan gate, a run receipt, the cache paying you back, and the gate ladder.

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.

Clone, build, init, and the zero-key demo — including the record that honestly fails the delivery floor.

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: email lowercases and validates (JSONPlaceholder returns Sincere@april.biz; the ledger stores sincere@april.biz — and since email is an identity field, that normalization is what makes dedupe work). transform: domain reduces website to 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.username is 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), and idempotency for deliver bindings. See the shipped Apollo binding for all of them in ~150 lines.
From a raw API response to a working adapter — paths, transforms, and why registry rules are the only 'logic' allowed.

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.

The tutorial pipeline climbing every rung — ending with the re-run that delivers nothing, because idempotency is structural.

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 auth and pagination blocks and you've integrated a real API. APIs with published OpenAPI specs are the easiest targets.
  • gtme freeze --bundle snapshots 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.