Traverse a relation
Write a traverse step that turns a run's people into their companies, plan it, run it keyless, and check the relations and identities the ledger holds
What you'll learn
You have people and want their companies, or companies and want the people at them, and you're about to write the step that crosses from one type to the other.
- how to pick the relation and which end of it is the parent
- how to read a traverse in the plan, and which fields a step after it can read
- what the traverse line on the receipt counts
- where to check the relation rows and the identities in the ledger
Goal: a pipeline that turns a CSV of people into their companies. The companies you have two contacts at end in a group. Then you cross the same relation back, from a company to the people who work there.
Before you start
You need gtme (Install), and Types and traverse explains the words this guide uses. That page defines a type, a relation, a leg, and what it means to mint or coalesce.
This guide needs no API keys, spends nothing, and sends nothing. Its traverse is sql/traverse, which follows a relation the ledger already holds. The relation comes from your CSV: a person row with a company_domain makes the source write works_at to that company, and mint the company if it's new. sql/traverse mints nothing.
Steps
Build the pipeline, then check what it left behind:
-
Make a folder with its own ledger, and save four people as
people.csv:mkdir -p traverse && cd traverse export GTME_LEDGER=./ledger.db cat > people.csv <<'EOF' Full Name,Email,Title,Company,Company Website Jane Doe,jane.doe@acme.com,VP Marketing,Acme Inc,acme.com Sam Lee,sam@acme.com,Head of Growth,Acme Inc,acme.com Bob Stone,bob@globex.io,RevOps Lead,Globex,globex.io Priya Rao,priya@gmail.com,Founder,, EOFJane and Sam both work at Acme. Priya has no company.
-
Save
accounts.yaml:name: accounts version: 1 source: use: csv/source with: path: people.csv columns: { full_name: Full Name, email: Email, title: Title, company_name: Company, company_domain: Company Website } steps: - id: to-company use: sql/traverse with: entity_type: company query: > SELECT r.to_id AS identity_id, r.from_id AS parent_id FROM relations r WHERE r.relation = 'works_at' - id: two-contacts use: sql/filter with: query: > SELECT r.to_id AS identity_id FROM relations r WHERE r.relation = 'works_at' GROUP BY r.to_id HAVING COUNT(*) >= 2 - id: account-line use: text/compose uses: [company_name, company_domain] provides: [account_line] with: template: "{{ record.company_name }} ({{ record.company_domain }})" group: target-accountsPipeline accounts: source csv/source, then to-company (sql/traverse), then two-contacts (sql/filter), then account-line (text/compose). Every step writes to the ledger, and cached, AI, and deliver steps read it first. to-companyis the traverse. Here the person is the parent and the company is the child.entity_type: companyis the type it emits, and its query returns the child asidentity_idand the parent, the record in the run it came from, asparent_id.A relation runs one way.
works_atgoes from a person (from_id) to a company (to_id). Companies are matched by domain, so two people atacme.comreach one company, and a person with no domain gets noworks_atrow.two-contactsis a filter that counts people per company across the whole ledger and keeps companies with 2 or more; to keep every company, delete it.account-lineis a compose step that fills a template and calls no model. -
Plan it:
gtme plan accounts.yamlThe output is the following, trimmed:
pipeline accounts (version 1) 1. source [source] — csv/source@1 entity: person writes: works_at → company (from company_domain) ... 2. to-company [traverse] — sql/traverse traverse: person → company via sql/traverse (follows an existing relation; mints nothing) ... 3. two-contacts [filter] — sql/filter entity: company ... 4. account-line [compose] — text/compose@1 entity: company reads: company_name, company_domain requires: company_name, company_domain provides: accounts.account_line est/record: ? ends in group "target-accounts" as company (records that complete the run are added) available fields after the last step: accounts.account_line note: a step provides open-ended fields, so per-record needs are re-checked at run time plan ok — nothing has been spentRead the
entity:lines top to bottom. The source works on people, and every step afterto-companyworks on companies, so the run has two legs.writes:is where the relation comes from. The group takes the last leg's type.After a traverse, a step reads the new type's fields.
account-linereadscompany_nameandcompany_domain, which the source wrote to each company when it minted it. The available-fields line lists only what this pipeline's steps provide. The note means plan checked the field names against the company type, and the run checks each company has the values. -
Add
titletoaccount-line, and plan again:uses: [company_name, company_domain, title]gtme plan accounts.yamlIt prints:
gtme: step "account-line": uses: "title" is not a canonical company field — use a canonical name (see spec/fields/company.json) or namespace it as <vendor>.titletitlebelongs to a person, and afterto-companythe records are companies, so plan stops before anything runs. The company fields page, linked at the end, lists what the company leg can read. A step that reads person fields goes before the traverse. Taketitleback out. -
Gate on a person filter, and plan again. Add a
seniorfilter beforeto-company, and putwhen: senior.passedonaccount-line:- id: senior use: sql/filter with: query: > SELECT identity_id FROM current_values WHERE field = 'title' AND (value LIKE 'VP%' OR value LIKE 'Head of%')gtme plan accounts.yamlIt prints:
gtme: step "account-line": when: senior.passed names a step before the traverse "to-company" — a verdict is a fact about the parent, not the child; gate at the traverse instead (when: senior.passed on "to-company" mints only children of passing parents)Move
when: senior.passedtoto-company, as the message says, or take both edits back out. The rest of this guide uses the file without them. -
Run it:
gtme run accounts.yamlThe receipt is similar to the following:
... run 01M3ME25E2Z1JG6XXYZ0Y72ZEN — done step adapter in out empty cached filtered failed cost avoided source csv/source 0 4 - 0 - - $0 - to-company sql/traverse 4 3 1 0 - - $0 - two-contacts sql/filter 2 1 - 0 1 - $0 - account-line text/compose 1 1 - 0 - - $0 - group "target-accounts": 1 record(s) added to-company: 4 parent(s) in, 3 out, 1 empty — 2 traversed (company), 1 already in this run total: $0 spentto-companytook 4 people in, and 3 of them led to a company. Priya is the 1empty: she has noworks_atrow.2 traversedcounts the children, Acme and Globex.Sam's Acme reached a company Jane had already brought into the run, so it coalesced into that record and counts as
already in this run. So 3 rows came out, and 2 companies move forward, which is whytwo-contactsshows 2 in. It kept Acme, and Acme is the group's one member. -
Check the relation rows with
gtme query:gtme query "SELECT p.identity_key AS person, r.relation, c.identity_key AS company FROM relations r JOIN identities p ON p.id = r.from_id JOIN identities c ON c.id = r.to_id"It prints:
{"company":"acme.com","person":"jane.doe@acme.com","relation":"works_at"} {"company":"acme.com","person":"sam@acme.com","relation":"works_at"} {"company":"globex.io","person":"bob@globex.io","relation":"works_at"} 3 rowsThe source wrote these rows, and
to-companyfollowed them without writing any. Priya has none, which is why she came outempty. -
Check what the traverse reached:
gtme query "SELECT e.event, i.identity_key FROM step_events e JOIN identities i ON i.id = e.identity_id WHERE e.step_id = 'to-company' AND i.entity_type = 'company'"It prints:
{"event":"traversed","identity_key":"acme.com"} {"event":"coalesced","identity_key":"acme.com"} {"event":"traversed","identity_key":"globex.io"} 3 rowsAcme is one identity with two events. The ledger's word for
already in this runiscoalesced. -
Go the other way. Save
companies.csvandpeople-at.yaml:printf 'Company,Company Website\nAcme Inc,acme.com\n' > companies.csvname: people-at version: 1 source: use: csv/source with: path: companies.csv entity_type: company columns: { company_name: Company, company_domain: Company Website } steps: - id: to-people use: sql/traverse with: entity_type: person query: > SELECT r.from_id AS identity_id, r.to_id AS parent_id FROM relations r WHERE r.relation = 'works_at' group: acme-peoplePipeline people-at: source csv/source, then to-people (sql/traverse). Every step writes to the ledger, and cached, AI, and deliver steps read it first. The relation is the same, and the columns swap: the company is now the parent, so
to_idisparent_id. -
Run it:
gtme run people-at.yamlThe output includes:
to-people: 1 result row(s) ignored (parent outside the run) ... group "acme-people": 2 record(s) added to-people: 1 parent(s) in, 1 out, 0 empty — 2 traversed (person), 0 already in this runThe query read every
works_atrow in the ledger, and gtme kept the ones whose parent is in this run. Bob's row points at Globex, which isn't, so it's ignored.
What you have now
You have two pipelines that cross the same relation in opposite directions, and a typed group from each.
gtme groups
group type members added removed touched created
acme-people person 2 2 0 0 2026-09-28
target-accounts company 1 1 0 0 2026-09-28
target-accounts holds companies and acme-people holds people, because each group took its run's last leg. Run gtme show acme.com (gtme show) and it prints the company with its two CSV fields and accounts.account_line.
To get the companies out as a file, add a csv/deliver step with entity_type: company under its with:, or query the group. Without that key, csv/deliver expects people and plan refuses it on a company leg. The query needs no change to the pipeline:
gtme query --format csv \
"SELECT i.identity_key AS domain, v.value AS account_line
FROM group_members m
JOIN groups g ON g.id = m.group_id
JOIN identities i ON i.id = m.identity_id
JOIN current_values v ON v.identity_id = m.identity_id
AND v.field = 'accounts.account_line'
WHERE g.name = 'target-accounts'" > target-accounts.csv
target-accounts.csv then holds one row per company:
domain,account_line
acme.com,Acme Inc (acme.com)
To make it yours, start from the relations your ledger holds:
gtme query --format table \
"SELECT relation, count(*) AS n FROM relations GROUP BY relation"
relation n
works_at 3
1 rows
Change the relation name and the entity_type, and put each column where the relation's direction says it goes. If the relation you need isn't in the ledger yet, a vendor traverse mints it: the shipped posts-to-engagers bundle has two, harvest/profile-posts and harvest/post-reactions. Both are bindings that need HARVEST_API_KEY and spend per call, and gtme run . --simulate runs them offline from recorded responses. To run this with your agent, paste this into Claude Code:
Map the CSV at PATH with company_domain from its website column, write a pipeline that crosses works_at from each person to their company with sql/traverse, and end it in a group. Run gtme plan and read me each traverse line and the entity of every step after it. If plan is ok, run it, explain which people were empty and which companies were already in this run, and export the group as one CSV row per company.
Replace PATH with your CSV file.
Next
- Company fields lists every field a step on the company leg can read.
- The
relationstable shows the columns your traverse query selects from. gtme groupscovers every verb for the typed group a run ends in.