Multi-stage campaigns with groups
Split a campaign into a qualify pipeline you re-run freely and a send pipeline you arm by hand, with a reviewed group between them
What you'll learn
You have one pipeline that finds people and contacts them, and you want to review who qualified before anything sends, work the list a few at a time, and never contact anyone twice across campaigns.
- how a group/deliver step hands records to a group, and how its dry-run becomes the review
- what plan checks across the boundary between two pipelines
- how once: and limit: work a group a batch at a time
- how a suppression group stops a second campaign from contacting the same people
Goal: two pipelines with a reviewed group between them. The first decides who qualifies, and re-running it never sends anything. The second works that list two people a run, and you arm it by hand. A contact history both campaigns share keeps anyone from being reached twice.
flowchart LR
q[qualify.yaml] -- hands off --> qualified[group: qualified]
qualified -- sources --> s[send.yaml]
s -- records a touch --> contacted[group: contacted]
contacted -- suppresses --> s
Before you start
You need gtme (Install), and it helps to have read Groups and segments.
This guide needs no API keys and sends nothing past your own disk. The only priced step is demo/enrich, a pretend $0.01 per record that calls no vendor. Both deliveries write local files. The CSV comes from the shipped qualify-group-send bundle, which is this split with a model judge and an Instantly send, and which needs keys to arm.
Steps
Build both stages and run them in order:
-
Make a folder with its own ledger, and fetch an eight-person CSV:
mkdir -p multi-stage && cd multi-stage export GTME_LEDGER=./ledger.db curl -fsSLO https://raw.githubusercontent.com/gtme-run/gtme/main/bundles/qualify-group-send/1-qualify/people.csv -
Save stage 1 as
qualify.yaml:name: qualify 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: score use: demo/enrich with: cost_per_record_usd: 0.01 - id: worth-it use: sql/filter with: query: > SELECT identity_id FROM current_values WHERE field = 'demo.score' AND CAST(value AS INTEGER) >= 50 - id: handoff use: group/deliver with: { group: qualified } variables: name: full_name title: title score: demo.scorePipeline qualify: source csv/source, then score (demo/enrich), then worth-it (sql/filter), then delivers with handoff (group/deliver). Every step writes to the ledger, and cached, AI, and deliver steps read it first. scorerates each person, andworth-itkeeps anyone at 50 or more.handoffis a deliver step whose target is thequalifiedgroup, and itsvariables:are what you'll review. -
Save stage 2 as
send.yaml:name: send version: 1 source: group: qualified limit: 2 once: true steps: - id: opener use: text/compose uses: [full_name, company_name] provides: [opener] with: template: "{{ record.full_name }}, a quick question about outbound at {{ record.company_name }}." - id: out use: csv/deliver with: path: outbox.csv variables: name: full_name opener: send.opener idempotency: email record: contacted suppress: { group: contacted, within: 30d }openerreads the fields inuses:and writesopener, which the file stores assend.opener. The source serves at most 2 members a run, oldest-added first, andonce: trueskips members this pipeline already finished.record: contactedwrites a touch per delivery, andsuppress:holds back a send to anyone touched incontactedin the last 30 days. -
Plan stage 2 before stage 1 has run:
gtme plan send.yamlIt prints:
gtme: 2 plan problems: - group "contacted" does not exist — create it with `gtme groups add contacted <identity-key>...` or snapshot a segment with `gtme groups add contacted --from-segment <name>` - group "qualified" does not exist — create it with `gtme groups add qualified <identity-key>...` or snapshot a segment with `gtme groups add qualified --from-segment <name>`Plan catches a missing group before anything runs, and here both are missing. Stage 1 will create
qualified. A group thatrecord:writes is created at run time, butsuppress:needs it to exist first, so createcontactedempty, typed for people:gtme groups add contacted --type personIt prints:
group contacted: 0 added, 0 unchanged -
Dry-run stage 1:
gtme run qualify.yaml --dry-runThe output is similar to the following:
... handoff: 5 record(s) would be handed off to group "qualified" (held back — dry run) handoff: resolved variables for 5 record(s) — review, then run again without --dry-run to arm: jane.doe@acme.com name: "Jane Doe" score: "100" title: "VP Marketing" ... erin@umbrella.example name: "Erin Park" score: "76" title: "Software Engineer" ... total: $0.0800 (estimated) spentA dry-run runs every step except deliveries, so
scorespends its pretend $0.08 here and the armed run reads it from cache. This list is the review of the handoff. Erin scored well and isn't a buyer. Stage 2 reads the group, not this list, so you arm the handoff and take her out of the group before stage 2 runs. -
Arm stage 1, the same command without the flag:
gtme run qualify.yamlThe last lines are:
handoff: 5 record(s) handed off to group "qualified" total: $0 spent, $0.0800 avoided via cache (8 records skipped) -
Review the group, and take Erin out with a reason:
gtme groups show qualified gtme groups remove qualified erin@umbrella.example \ --note "engineer, not a buyer"The output is similar to the following:
group qualified (person) — 5 member(s) person:bob@globex.io person:carol@initech.dev person:erin@umbrella.example person:jane.doe@acme.com person:lee@vandelay.example written by: qualify sourced by: (none) ... group qualified: 1 removed, 0 unchangedgtme groups showlists who's waiting in the group and which pipelines write and read it. -
Run stage 1 again:
gtme run qualify.yamlThe receipt is the following, trimmed:
... step adapter in out empty cached filtered failed cost avoided source csv/source 0 8 - 0 - - $0 - score demo/enrich 8 0 - 8 - - $0 $0.0800 worth-it sql/filter 8 5 - 0 3 - $0 - handoff group/deliver 5 0 - 0 - - $0 - handoff: 5 already delivered ...All 5 handoffs count as already delivered, because gtme already recorded each one as a delivery and won't make it twice (idempotency), so Erin stays out. Stage 1 has no send in it, so re-running it can't contact anyone.
-
See why the send lives in its own file. Copy
qualify.yamltocombined.yaml, change itsname:tocombined, append anoutstep like the one insend.yaml, and plan it:gtme plan combined.yamlThe output includes:
warning: one commit point: this pipeline both hands off — handoff (→ group "qualified") — and sends — out (→ csv/deliver). Arming approves every deliver step at once, so approving the handoff approves the send; keep the handoff in its own pipeline and let the send consume the group.In one file, arming the handoff also arms the send, so there's no moment to review the group in between. Delete
combined.yaml; the rest of the guide doesn't use it. -
Dry-run stage 2:
gtme run send.yaml --dry-runThe output is similar to the following:
run 01M3MDA6GA2HHCPCX3GWTF5RCJ (send) source: sourced 2 members of group "qualified" (4 of 4 not yet worked; limit 2, oldest first) ... out: resolved variables for 2 record(s) — review, then run again without --dry-run to arm: jane.doe@acme.com name: "Jane Doe" opener: "Jane Doe, a quick question about outbound at Acme Inc." bob@globex.io name: "Bob Stone" opener: "Bob Stone, a quick question about outbound at Globex." total: $0 spentStage 2 has its own dry-run and its own receipt, and this is where you read the lines before they send.
-
Arm stage 2. Run this three times:
gtme run send.yamlThe source line from each run is:
source: sourced 2 members of group "qualified" (4 of 4 not yet worked; limit 2, oldest first) source: sourced 2 members of group "qualified" (2 of 4 not yet worked; limit 2, oldest first) source: sourced 0 members of group "qualified" (0 of 4 not yet worked; limit 2, oldest first)The dry-run finished nobody, so the first armed run still saw 4 to work. Each armed run advanced by 2, and the third found nothing left.
-
Add a person to the CSV, and run both stages:
cat >> people.csv <<'EOF' Dana Park,dana@contoso.com,Head of Growth,Contoso,contoso.com EOF gtme run qualify.yaml gtme run send.yamlThe output includes:
handoff: 5 already delivered handoff: 1 record(s) handed off to group "qualified" total: $0.0100 (estimated) spent, $0.0800 avoided via cache (8 records skipped) ... source: sourced 1 members of group "qualified" (1 of 5 not yet worked; limit 2, oldest first)Stage 1 paid to score Dana only and handed her off.
8 records skippedis the eight scores already in the ledger, and the five people already inqualifiedcount as already delivered. Stage 2 served Dana and nobody else. If stage 1 runs every morning without you, stage 2's dry-run is your review: read it, remove anyone you reject fromqualified, then arm stage 2.gtme plan send.yamlprints the same count before a run, as5 member(s), 1 not yet worked, sourcing 1. -
Save a second campaign as
webinar.yaml, and run it:name: webinar version: 1 source: group: qualified steps: - id: invite use: csv/deliver with: path: webinar.csv variables: name: full_name idempotency: email record: contacted suppress: { group: contacted, within: 30d }gtme run webinar.yamlThe output is similar to the following:
... invite: 5 record(s) suppressed: jane.doe@acme.com: touched in "contacted" 16s ago bob@globex.io: touched in "contacted" 16s ago carol@initech.dev: touched in "contacted" 16s ago lee@vandelay.example: touched in "contacted" 16s ago dana@contoso.com: touched in "contacted" 6s ago total: $0 spentwebinar.csvis a new target, so idempotency alone would let all 5 through. After 30 days the rule lets them through again; set a longerwithin:for a longer one. Three things stop repeats on this page:once:stops stage 2 serving a member it already finished.- Idempotency stops the same delivery to the same target.
suppress:stops a second campaign within the window.
What you have now
You have two stages with a reviewed group between them, and a contact history every campaign checks.
gtme groups
group type members added removed touched created
contacted person 0 0 0 5 2026-09-28
qualified person 5 6 1 0 2026-09-28
qualify person 0 0 0 6 2026-09-28
qualified holds the 4 you reviewed plus Dana, with Erin's removal and its note in its history. contacted has the 5 touches from the send stage. qualify holds stage 1's handoff touches: a deliver step without record: records under its pipeline's name. outbox.csv has 5 rows.
We recommend this split for any campaign that spends or sends. Re-run stage 1 whenever the list grows, and arm stage 2 on your own schedule. For a real send, swap csv/deliver for your sender's step, and idempotency then counts per campaign. To run it with your agent, paste this into Claude Code:
In the multi-stage folder, with GTME_LEDGER=./ledger.db, run gtme run qualify.yaml --dry-run and show me the handoff list. When I say go, arm it, then remove each email I reject with gtme groups remove qualified EMAIL --note. Then run gtme run send.yaml --dry-run, show me the resolved lines, and arm it only after I say so.
Next
gtme groupscovers every verb, includingadd, which fills a group without running a pipeline.- Participants shows how a person or an agent can judge records in stage 1 before the handoff.
gtme planlists every line plan prints, including theonce:count and the missing-group error.