Add a vendor with a binding
Turn one keyed, paginated vendor endpoint into a verified binding kept in your project, then plan, simulate, and run a pipeline through it
What you'll learn
You've read Add a vendor, your vendor isn't in the registry, and you want to write its binding by hand with auth, pagination, a list field, and cost.
- map a vendor response onto a binding's manifest, request, pagination, and extract blocks
- turn a list in the response into a string array with each:
- record fixtures that walk every page, and read what verify refuses
- keep a binding in your project folder on GTME_ADAPTER_PATH
You end with a verified binding for a keyed, paginated vendor endpoint and a pipeline that runs through it.
Before you start
- Read Add a vendor first. It writes a keyless, single-page binding. This guide adds auth, pagination, a list field, errors, and cost. Two adapter tiers has the test for when a vendor needs a process adapter instead.
- You need gtme,
curl,jq, your vendor's API docs, and its key exported in your shell. - Spend: four real requests to the vendor, one to look and one per page of fixtures. Verify, plan, and simulate call nothing. The optional armed run at the end spends what the vendor charges.
- Sends: nothing. The pipeline writes a local CSV.
Stacklens is an invented vendor that lists companies using a given technology. The outputs on this page came from a local stand-in for its API, at the address in STACKLENS_URL. Set that variable to your vendor's base URL; your paths and fields will differ too.
export STACKLENS_URL=http://127.0.0.1:8787
export STACKLENS_API_KEY=sk_test_standin
Write the binding
-
Make one call to the endpoint and read what comes back:
curl -fsS -H "Authorization: Bearer $STACKLENS_API_KEY" \ "$STACKLENS_URL/v1/companies?technology=hubspot&page=1&per_page=2" | jq .The output is the following:
{ "data": [ { "id": "co_101", "name": "Acme Corp", "domain": "acme.com", "employees": 120, "industry": "Computer Software", "technologies": [ { "name": "HubSpot", "category": "Marketing automation" }, { "name": "Salesforce", "category": "CRM" }, { "name": "Segment", "category": "Analytics" } ] }, ... ], "meta": { "page": 1, "per_page": 2, "total_pages": 3 } }Three things in this response shape the binding. The records sit under
data,meta.total_pagessays when to stop paging, andtechnologiesis a list of objects. -
Make the binding's folder inside your project, and put its parent on the adapter search path:
mkdir -p adapters/stacklens-companies/fixtures export GTME_ADAPTER_PATH=$PWD/adaptersgtme searches
GTME_ADAPTER_PATHbefore~/.gtme/adapters, so the binding can live in version control next to the pipelines that use it. Set the variable in every shell you run gtme from. -
Write
adapters/stacklens-companies/binding.yaml:# stacklens/companies: companies that use a given technology, from # Stacklens, an invented vendor. Served by a local stand-in for this guide. # Endpoint: GET /v1/companies?technology=...&page=...&per_page=... # Auth: Authorization: Bearer <key> id: stacklens/companies version: 1 role: source entity_type: company provides: type: object additionalProperties: false properties: company_domain: { type: string } company_name: { type: string } company_employees: { type: integer } company_industry: { type: string } stacklens.id: { type: string } stacklens.technologies: { type: array, items: { type: string } } config_schema: type: object additionalProperties: false required: [technology] properties: technology: type: string description: The technology to search for, such as hubspot per_page: type: integer minimum: 1 maximum: 100 default: 50 tech_limit: type: integer minimum: 1 default: 5 description: The most technologies to keep per company base_url: type: string default: "https://api.stacklens.example" credentials: [STACKLENS_API_KEY] freshness_days: 30 auth: type: bearer env: STACKLENS_API_KEY request: method: GET url: "{{config.base_url}}/v1/companies" query: technology: "{{config.technology}}" pagination: strategy: page param: page size_param: per_page page_size: "{{config.per_page}}" termination: total_pages_path: meta.total_pages extract: records: data fields: company_domain: { path: domain, transform: domain } company_name: name company_employees: { path: employees, absent: [0] } company_industry: industry stacklens.id: id stacklens.technologies: each: technologies template: "{{ item.name | strip }} ({{ item.category }})" limit: "{{ config.tech_limit }}" errors: "404": { verdict: skip, reason: no company uses this technology } retry: max_attempts: 3 cost: per: record amount_usd: 0.02Here's what each block does:
Block What it does here idthroughentity_typeThe manifest, the surface gtme planreads. A source reads nothing from earlier steps, so it declares noneeds.providesNames every field the step writes. Each is a canonical field for a company or starts with stacklens., andadditionalProperties: falsemeans the step can't write a field you didn't list.config_schemaThe settings a pipeline passes under with:.technologyis required, and the rest have defaults.credentials,authPlan demands the key, and gtme sends it as a bearer token. freshness_daysHow long a fetched value stays fresh. Plan prints it as cache: 30d.paginationgtme adds pageandper_page, and stops atmeta.total_pagesor at the step'slimit, whichever comes first.extractrecords: datais the list inside the response.absent: [0]reads a headcount of 0 as unknown.each:Renders the template once per item in technologies, keeps at mosttech_limit, and provides a list of strings (the decision record, ADR-059).errors,retryA 404 ends the source with zero records and a warning. Statuses you don't list keep gtme's defaults: a 401 stops the run, and a 429 or 5xx is retried up to retry.max_attemptstimes.costCharges $0.02 per record emitted. Use per: requestwhen the vendor bills per page.The
each:template is written in the same template language as a request (ADR-057).{{ }}inserts a value,itemis one entry of the list, and| striptrims spaces. A render that comes out empty as a whole is dropped, but an empty piece inside it is kept, so an item with a blank category renders asSegment (). To leave the parentheses out, test for the empty string, in single quotes because the test uses double ones:template: '{{ item.name | strip }}{% if item.category != "" %} ({{ item.category }}){% endif %}'A vendor that answers two different problems with one status needs a list of rules. Each rule may carry
match, a piece of text the response body must contain, and the first rule that applies wins (ADR-065). On an enrich or deliver step,fail_runstops the step, so the rest of the records wait for a resume instead of each paying for the same refusal:errors: "403": - match: "Plan limit reached" verdict: fail_run reason: the plan is full; upgrade it, then resume - verdict: fail_run class: auth reason: the API key was rejectedA rule that applies because of its
matchexits 1 unless it names aclass, andclass: authexits 3.Each block is one of the binding primitives in SPEC §10a. Run
gtme help --bindingsfor every key each block accepts, such as the other pagination strategies and auth types (ADR-041). -
Verify the binding before it has fixtures, to see what verify checks and where it stops:
gtme adapters verify stacklens/companiesThe output is the following:
stacklens/companies v1 — source (company) calls: api.stacklens.example demands: STACKLENS_API_KEY needs: none provides: company_domain, company_employees, company_industry, company_name, stacklens.id, stacklens.technologies gtme: adapters: stacklens/companies ships no conformance fixtures (fixtures/conformance.json) — fixtures are mandatory; record one real response per request (`gtme help --bindings`)The schema passed, and verify printed the surface you'd review in a stranger's binding: the host, the key, and the fields. Then it stopped, because nothing installs without fixtures (ADR-042). If a provided field is neither canonical nor prefixed, verify stops before printing anything:
gtme: adapters: stacklens/companies: provides: "technologies" is not a canonical company field — use a canonical name (see spec/fields/company.json) or namespace it as <vendor>.technologies -
Record every page as a fixture, a saved response that verify and simulate serve instead of calling the vendor:
for page in 1 2 3; do curl -fsS -H "Authorization: Bearer $STACKLENS_API_KEY" \ "$STACKLENS_URL/v1/companies?technology=hubspot&page=$page&per_page=2" done | jq -s '{config: {technology: "hubspot", per_page: 2}, responses: [to_entries[] | {match: "?page=\(.key + 1)&", status: 200, body: .value}]}' \ > adapters/stacklens-companies/fixtures/conformance.jsonThe loop saves each page, and
jqwraps them into one file with one entry per page, each matched on its page number.configis the step configuration verify runs with. It needstechnologybecause the schema requires it, andper_page: 2makes verify walk all three pages. Eachmatchis a piece of the request URL, and the host doesn't matter. Amatchmade of query parameters compares each one exactly, sopage=2never matchesper_page=2, and verify can't pass with one page served twice. -
Verify again:
gtme adapters verify stacklens/companiesThe output is the following:
stacklens/companies v1 — source (company) calls: api.stacklens.example demands: STACKLENS_API_KEY needs: none provides: company_domain, company_employees, company_industry, company_name, stacklens.id, stacklens.technologies fixtures: ok — 3 response(s) on file, 5 record(s) extractedThe stand-in holds five companies across three pages. If verify counts more than your vendor has, one
matchanswered two pages.
Run a pipeline through it
-
In the project folder, save
hubspot-users.yaml:name: hubspot-users version: 1 source: use: stacklens/companies with: technology: hubspot limit: 4 steps: - id: out use: csv/deliver with: path: hubspot-users.csv entity_type: company variables: company: company_name employees: company_employees stack: stacklens.technologies idempotency: company_domainPipeline hubspot-users: source stacklens/companies, then delivers with out (csv/deliver), once per company_domain. Every step writes to the ledger, and cached, AI, and deliver steps read it first. entity_type: companytellscsv/deliverit's writing companies. Without it, plan fails withcsv/deliver is a person adapter, but the records here are company.idempotency: company_domainwrites each company to this file once, however many times the pipeline runs. -
Plan the pipeline:
gtme plan hubspot-users.yamlThe output is the following:
pipeline hubspot-users (version 1) 1. source [source] — stacklens/companies@1 entity: company provides: company_domain, company_employees, company_industry, company_name, stacklens.id, stacklens.technologies cache: 30d creds: STACKLENS_API_KEY (resolved) est/record: $0.0200 ... plan ok — nothing has been spentPlan found the key in your shell. In a shell without it, plan stops and names the fix:
gtme: step "source": missing credential STACKLENS_API_KEY (set it in the environment or run `gtme secret set STACKLENS_API_KEY`) -
Run it under simulate, which serves your fixtures in place of the vendor:
gtme run hubspot-users.yaml --simulateThe output is similar to the following:
simulate: recorded responses only — no network, no spend, nothing sends, nothing persists run 01M3MJW7WPBH8N0CZRK74DNRJY (hubspot-users) source [info]: stacklens/companies: 4 records source: sourced 4 records out: 4 in, 0 out, 0 cached, 0 filtered, 0 failed, 1 skipped, 3 held (dry run) run 01M3MJW7WPBH8N0CZRK74DNRJY — done (SIMULATED — recorded responses only; nothing sent, nothing persisted) step adapter in out empty cached filtered failed cost avoided source stacklens/companies 0 4 - 0 - - $0.0800 - out csv/deliver 4 0 - 0 - - $0 - out: 1 record(s) held back by on_missing: umbrellahealth.com: missing company_employees out: resolved variables for 3 record(s) — review, then run again without --dry-run to arm: acme.com company: "Acme Corp" employees: "120" stack: "[\"HubSpot (Marketing automation)\",\"Salesforce (CRM)\",\"Segment (Analytics)\"]" globex.io company: "Globex" employees: "45" stack: "[\"HubSpot (CRM)\",\"Mixpanel (Analytics)\"]" initech.com company: "Initech" employees: "300" stack: "[\"HubSpot (Marketing automation)\",\"Zendesk (Support)\",\"Stripe (Payments)\",\"Looker (BI)\",\"Okta (Identity)\"]" total: $0.0800 (estimated) spentlimit: 4stopped the source at 4 of the 5 companies. Umbrella Health reported 0 employees, whichabsent: [0]turned into no value.Every field under
variables:needs a value before a record delivers. The default,on_missing: skip, skipped Umbrella Health at theoutstep, which is the1 skippedin the counts. Initech lists six technologies, andtech_limitkept five. The$0.0800is the binding's declared cost for 4 records, estimated; simulate called nothing. Simulate prints each variable as text, so the list shows as JSON there; the ledger keeps it a list. -
Optional: run armed. If your vendor's host differs from the binding's
base_urldefault, set it under the source'swith:block. The stand-in needed this line:base_url: "http://127.0.0.1:8787"Then run with no flag, which is armed. Against a real vendor this run spends the declared $0.02 per record,
$0.0800for these four. The stand-in charges nothing:gtme run hubspot-users.yamlThe output is similar to the following:
run 01M3MJWM1GH52VEXRF4FRJJXDG (hubspot-users) source [info]: stacklens/companies: 4 records source: sourced 4 records out [info]: csv/deliver: wrote 1 row(s) to hubspot-users.csv out [info]: csv/deliver: wrote 1 row(s) to hubspot-users.csv out [info]: csv/deliver: wrote 1 row(s) to hubspot-users.csv out: 4 in, 3 out, 0 cached, 0 filtered, 0 failed, 1 skipped run 01M3MJWM1GH52VEXRF4FRJJXDG — done step adapter in out empty cached filtered failed cost avoided source stacklens/companies 0 4 - 0 - - $0.0800 - out csv/deliver 4 3 - 0 - - $0 - out: 1 record(s) held back by on_missing: umbrellahealth.com: missing company_employees total: $0.0800 (estimated) spentThe stand-in logged one request, because
per_pagedefaulted to 50 and one page held all four. The receipt still shows$0.0800, the binding's declared cost. With a wrong key, the same run fails withstacklens: credentials were rejected (HTTP 401); check the value inSTACKLENS_API_KEY, or store it withgtme secret set STACKLENS_API_KEY. Withtechnology: nosuchtool, the source warnsskip: no company uses this technologyand the run finishes with zero records, which is theerrorsblock at work.
What you have now
You have a binding in your project that verifies offline, with a fixture for every page. gtme adapters verify stacklens/companies is the check, and it runs without a key or the network.
If you ran armed, the ledger holds the companies with the vendor's list kept as a list. Check one:
gtme show acme.com
The output is similar to the following:
{
"deliveries": [
{
"created_at": "2026-09-28T18:02:01.663Z",
"run_id": "01M3MJWM1GH52VEXRF4FRJJXDG",
"scope": "hubspot-users.csv",
"status": "accepted",
"target": "csv/deliver"
}
],
"entity_type": "company",
"fields": {
"company_domain": "acme.com",
"company_employees": 120,
"company_industry": "Computer Software",
"company_name": "Acme Corp",
"stacklens.id": "co_101",
"stacklens.technologies": [
"HubSpot (Marketing automation)",
"Salesforce (CRM)",
"Segment (Analytics)"
]
},
"identity_key": "acme.com",
"identity_key_tier": "company_domain"
}
identity_key is how the ledger recognizes the same company on every run.
Keep the binding in the project when it belongs with these pipelines, because it's versioned beside them. Copy it to ~/.gtme/adapters/stacklens-companies when every folder on this machine should see it; verify passes there the same way.
Hand it to Claude Code
Paste this line into Claude Code for your own vendor:
Write a gtme binding for VENDOR's ENDPOINT from DOCS_URL in ./adapters, with its key in KEY_VAR, record a fixture for every page, and stop when gtme adapters verify passes.
Replace the following:
VENDOR: the vendor's nameENDPOINT: the call you want, such as its company searchDOCS_URL: the page that documents itKEY_VAR: the environment variable that holds the key
The agent writes the folder, records the fixtures with your key, and stops at a passing verify for you to read.
Next
- Types and traverse shows the binding shape that follows a relation, such as a company to its people.
- A campaign is a folder shows how a bundle carries this binding with its fixtures.
gtme adapterslists the verbs that search, add, verify, and update bindings.