Package and share a campaign
Freeze a finished run into a bundle folder, hand it to a teammate or an agent, run it on their ledger, and freeze a new version after a change
What you'll learn
You have a campaign that ran, and you want a teammate, an agent, or you next quarter to run exactly that campaign again.
- how to freeze a run into a bundle and what to put beside it
- what the receiving side runs, and what it pays for again
- what happens when someone edits a frozen file
- how to ship a change as a new bundle version
Goal: a campaign frozen into a folder that anyone with gtme can run, plus a second version after you change it. A bundle is that folder: the campaign's files, and a manifest that records a fingerprint, a hash, of each one, which changes if a single byte does.
Before you start
You need gtme on both sides. Install covers it. Built-in adapters ship inside the binary, not the bundle, so both sides should run the same release; gtme version prints it.
This guide needs no keys and sends nothing past your own disk. The pipeline's only priced step is demo/enrich, a pretend $0.01 per record that calls no vendor, and its output goes to a local out.csv. The first armed run of three people reports $0.03.
Steps
-
Make a campaign folder with its own ledger, and fetch a practice CSV:
mkdir -p renewals && cd renewals export GTME_LEDGER=$PWD/ledger.db curl -fsSLO https://raw.githubusercontent.com/gtme-run/gtme/main/examples/contacts.csvSave the opening line as
opener.txt:{{ record.full_name }}, your renewal with {{ record.company_domain }} is coming up.Save the pipeline as
renewal-openers.yaml:name: renewal-openers version: 1 source: use: csv/source with: path: contacts.csv columns: { full_name: Full Name, email: Email, title: Title, company_domain: Company Website } steps: - id: score use: demo/enrich with: cost_per_record_usd: 0.01 - id: keep use: sql/filter with: query: > SELECT identity_id FROM current_values WHERE field = 'demo.score' AND CAST(value AS INTEGER) >= 70 - id: opener use: text/compose uses: [full_name, company_domain] provides: [opener] with: template: { file: opener.txt } - id: out use: csv/deliver with: path: out.csv variables: opener: renewal-openers.opener score: demo.score idempotency: emailPipeline renewal-openers: source csv/source, then score (demo/enrich), then keep (sql/filter), then opener (text/compose), then delivers with out (csv/deliver), once per email. Every step writes to the ledger, and cached, AI, and deliver steps read it first. scorerates each person,keeppasses scores of 70 or more,openerfills the template, andoutwrites a row per person. -
Run it. A bundle is frozen from a run, so there has to be one:
gtme run renewal-openers.yamlThe output is similar to the following:
run 01M3MGMBM85JE40V4K9S9DNDFH (renewal-openers) ... run 01M3MGMBM85JE40V4K9S9DNDFH — done step adapter in out empty cached filtered failed cost avoided source csv/source 0 3 - 0 - - $0 - score demo/enrich 3 3 - 0 - - $0.0300 - keep sql/filter 3 1 - 0 2 - $0 - opener text/compose 1 1 - 0 - - $0 - out csv/deliver 1 1 - 0 - - $0 - total: $0.0300 (estimated) spent -
Freeze that run into a new folder.
lastnames your most recent run:gtme freeze last --bundle renewal-openers-v1The output is similar to the following:
froze run 01M3MGMBM85JE40V4K9S9DNDFH into bundle renewal-openers-v1 (5 steps) — self-contained except credentials and input files -
Read what landed:
find renewal-openers-v1 -type f | sortrenewal-openers-v1/manifest.json renewal-openers-v1/pipeline.yaml renewal-openers-v1/registry/company.json renewal-openers-v1/registry/person.json renewal-openers-v1/registry/post.json renewal-openers-v1/templates/opener.txtmanifest.jsonlists every other file with its hash, the run it was frozen from, and the release that froze it:grep -E "source_run_id|gtm_version" renewal-openers-v1/manifest.json"source_run_id": "01M3MGMBM85JE40V4K9S9DNDFH", "gtm_version": "0.0.0-dev",0.0.0-devis a local build; a released binary records its version number. Your template traveled astemplates/opener.txt, and the frozenopenerstep now points there:grep -A4 "id: opener" renewal-openers-v1/pipeline.yaml- id: opener use: text/compose with: template: file: templates/opener.txtsource_run_idis the run from step 2. A pipeline that calls a vendor through a binding also gets anadapters/folder, one per binding, with its fixtures, the recorded vendor responses simulate replays. A binding you installed withgtme adapters addalso carries.source.json, the commit it's pinned at (ADR-042). -
Save the run's receipt beside the frozen files, so the person you hand it to knows what a good run looks like.
gtmeprints the receipt to a separate output stream, so2>saves it to a file:gtme runs last 2> renewal-openers-v1/receipt.txthead -3 renewal-openers-v1/receipt.txtrun 01M3MGMBM85JE40V4K9S9DNDFH pipeline: renewal-openers status: doneThe manifest lists only frozen files, so
receipt.txtdoesn't change any hash. The shipped bundles keep a README there too. -
Add the input and zip the folder:
cp contacts.csv renewal-openers-v1/ zip -r renewal-openers-v1.zip renewal-openers-v1Leave the CSV out when the teammate brings their own list. Tell them what their side brings:
- The same gtme release, which the manifest's
gtm_versionrecords. - Their list, saved in the bundle folder as
contacts.csvwith the headers the pipeline maps:Full Name,Email,Title, andCompany Website. Renaming their columns is fine; editingpipeline.yamlbreaks its hash. - Any keys the bundle's vendors need, which simulate lists.
- Any group the bundle reads, filled by running the campaign that writes it on their ledger first.
We recommend committing bundles to a git repository, one folder per version, so every change to a campaign is a diff a teammate can read. A zip is fine for a one-off handoff.
- The same gtme release, which the manifest's
-
On the receiving side, unzip it next to a fresh ledger and simulate it from inside the folder. To play the teammate on one machine, use a sibling folder:
mkdir -p ../teammate && cd ../teammate export GTME_LEDGER=$PWD/ledger.db unzip -q ../renewals/renewal-openers-v1.zip && cd renewal-openers-v1 gtme run . --simulateThe output is similar to the following:
bundle renewal-openers (frozen from run 01M3MGMBM85JE40V4K9S9DNDFH) — hashes verified simulate: recorded responses only — no network, no spend, nothing sends, nothing persists ... out: resolved variables for 1 record(s) — review, then run again without --dry-run to arm: jane.doe@acme.com opener: "Jane Doe, your renewal with acme.com is coming up." score: "100" total: $0.0300 (estimated) spentOn a bundle that needs keys, simulate lists each missing one, and the receiving side stores them with
gtme secret set, as Your stack shows. A bundle that reads a group stops here withgroup "NAME" does not existuntil the campaign that fills it has run on this ledger.gtme runchecked every hash in the manifest before it read a record (SPEC §8), and simulate is the first check becausegtme plandoesn't take a folder yet. Its total is what an armed run would cost, and nothing was charged; the--dry-runin its hint is dry-run's wording. -
Arm it. On the teammate's fresh ledger, this spends the pretend $0.03 again and writes Jane to
out.csvagain. With a vendor or a live target, rungtme run . --dry-runfirst and read its resolved variables; this bundle only writes a local file:gtme run .The output is similar to the following:
bundle renewal-openers (frozen from run 01M3MGMBM85JE40V4K9S9DNDFH) — hashes verified ... step adapter in out empty cached filtered failed cost avoided source csv/source 0 3 - 0 - - $0 - score demo/enrich 3 3 - 0 - - $0.0300 - keep sql/filter 3 1 - 0 2 - $0 - opener text/compose 1 1 - 0 - - $0 - out csv/deliver 1 1 - 0 - - $0 - total: $0.0300 (estimated) spentThe rows match step 2's receipt exactly.
scoreshowscached 0because the cache lives in each ledger, and the teammate's is new.outdelivered Jane again, because idempotency is checked against the teammate's deliveries, and there are none. Run on your own ledger inrenewals/, the same bundle skips all three scores and writes nothing. So a bundle carries the campaign, and each side's contact history stays with that side. Hand over a new list, or agree on who sends. -
Edit a frozen file and run it again:
perl -pi -e 's/is coming up/renews next month/' templates/opener.txt gtme run . --simulateThe output is the following:
gtme: bundle: templates/opener.txt does not match its manifest hash — the bundle has been modified since it was frozenThe run stops before it reads a record and exits
2. Unzip the original again to undo the edit. To change a campaign, change its source files and freeze again, as the next step does. -
Make the change on purpose, back in your own folder. Edit the source files, not the bundle, and run them:
cd ../../renewals export GTME_LEDGER=$PWD/ledger.db perl -pi -e 's/is coming up/renews next month/' opener.txt gtme run renewal-openers.yamlThe output is similar to the following:
... step adapter in out empty cached filtered failed cost avoided source csv/source 0 3 - 0 - - $0 - score demo/enrich 3 0 - 3 - - $0 $0.0300 keep sql/filter 3 1 - 0 2 - $0 - opener text/compose 1 1 - 0 - - $0 - out csv/deliver 1 0 - 0 - - $0 - out: 1 already delivered total: $0 spent, $0.0300 avoided via cache (3 records skipped)openerwrote Jane a new line,scorecame from the cache, andoutwrote nothing:out: 1 already deliveredis Jane, who already has a delivery on this ledger. That's the safety working: v2 reaches people v1 hasn't, which is the normal case for a new list. -
Freeze the new run into its own folder, and compare the two versions:
gtme freeze last --bundle renewal-openers-v2 diff -r renewal-openers-v1 renewal-openers-v2The output is similar to the following:
froze run 01M3MGQ75E6V3X11F5KZSWHTAE into bundle renewal-openers-v2 (5 steps) — self-contained except credentials and input files Only in renewal-openers-v1: contacts.csv diff -r renewal-openers-v1/manifest.json renewal-openers-v2/manifest.json 4,5c4,5 < "source_run_id": "01M3MGMBM85JE40V4K9S9DNDFH", < "created_at": "2026-09-28T17:22:37.243Z", --- > "source_run_id": "01M3MGQ75E6V3X11F5KZSWHTAE", > "created_at": "2026-09-28T17:24:11.471Z", 12c12 < "templates/opener.txt": "b3e9401b17ad3e1624b26237ae221cf8fa6e9591d51c82552f59848e3eed5680" --- > "templates/opener.txt": "648e7dfe176402cb9a1128f8b362c894b1ce28bbcbf33c3e10f7eee8b22a915d" Only in renewal-openers-v1: receipt.txt diff -r renewal-openers-v1/templates/opener.txt renewal-openers-v2/templates/opener.txt 1c1 < {{ record.full_name }}, your renewal with {{ record.company_domain }} is coming up. --- > {{ record.full_name }}, your renewal with {{ record.company_domain }} renews next month.The change is one template line and its hash.
gtme freeze --bundlewants an empty folder and refusesrenewal-openers-v1asis not empty, so every version gets its own.
What you have now
Two bundle versions of one campaign, each tied to the run it came from. To check one, put contacts.csv in its folder and run gtme run . --simulate there. The first line should end in hashes verified.
Your ledger stays with you, and so do the cache, the delivery history, and group membership. Keys and any process adapter, a vendor adapter that runs as its own program, stay with each machine. The hashes catch an accidental edit. Anyone can recompute one after a deliberate edit, so read a bundle's diff before you arm it, as you'd read a change to code.
To run exactly this campaign next quarter, run the bundle on the same ledger. People already delivered are skipped, and scores inside their cache window are reused, so only new people cost anything. On a fresh ledger, it pays for everyone again and sends to everyone again.
To have Claude Code check a bundle someone handed you, paste this line:
Check the bundle in BUNDLE_DIR: run gtme run . --simulate from inside it on a fresh GTME_LEDGER, show me receipt.txt if there is one, and list any missing keys or groups. Don't arm it.
Replace BUNDLE_DIR with the path to the bundle folder.
Next
gtme freezelists every form and flag of the command that writes a bundle.gtme runlists--simulateand--dry-run, which both work on a bundle folder.gtme secretstores the keys a vendor bundle needs on the receiving machine.