HOMA · OPERATOR HOW-TO

Step-by-step guide

Assumes no prior knowledge. Every command, what you should see, and what to do when it goes wrong.

This is the companion to the main handoff page. That one explains what the system is; this one is how to operate it. Contacts, ids and credential locations are in the vendor workbook — not on this page.
A. Cloudflare — the automations: access, checking, running, editing, deploying B. Speedeon — requesting, pulling and activating the mortgage-intent file C. OnSpot — building, checking and sending the open-house file D. Catalog — removing, adding and badging listings safely

A. Cloudflare — the automations

The automations ("Workers") are small programs that run on a schedule in Cloudflare. They keep audiences in sync, push conversion data back to the ad platforms, and send the daily digest. You will not touch them most weeks. You need access so that when one stops, you can look at it yourself.

A0. One-time setup

A0.1  —  Accept the Cloudflare invite

Check your email for an invite to the Homa Cloudflare account. Accept it and set up two-factor auth.

You should see: After logging in at dash.cloudflare.com, a Homa account appears in the account picker at top-left.
If that fails: Ask Shawn to re-send from Manage Account → Members. Your role should be Workers Admin.
A0.2  —  Install the command-line tool

You need Node.js installed. Then everything below uses npx wrangler, which downloads on demand — nothing to install globally.

node --version npx wrangler --version
You should see: Two version numbers. Node should be v18 or higher.
If that fails: Install Node from nodejs.org, then re-run.
A0.3  —  Log the tool into Cloudflare
npx wrangler login

A browser window opens. Approve the request.

You should see: "Successfully logged in" in the terminal.
If that fails: If you are on a headless machine, use an API token instead: Cloudflare dashboard → My Profile → API Tokens → Create, then export CLOUDFLARE_API_TOKEN=...
A0.4  —  Get the source

Download lab-fleet-handoff.zip from the handoff site and unzip it. Every worker is a folder inside workers/.

cd lab-fleet-handoff ls workers/
You should see: A list of folder names — these are the automations.

A1. Check whether an automation is alive

A1.1  —  Look at the monitor first

Before opening anything, check the fleet monitor. It watches every automation and reports which are healthy.

# open in a browser https://homa-fleet-monitor.homa-tryhoma.workers.dev/slos
You should see: JSON listing each check with a healthy/stale status.
If that fails: If a check says stale, that automation has not reported in. Continue to A1.2 for that one.
A1.2  —  Open the worker in the dashboard

Cloudflare dashboard → Workers & Pages → click the worker by name.

The Metrics tab shows recent invocations. The Logs tab (Begin log stream) shows live output.

You should see: Recent successful invocations on roughly its schedule.
If that fails: No invocations at all means the cron is not firing — check the schedule in A3.
A1.3  —  Watch the logs from your terminal
cd workers/<name> npx wrangler tail
You should see: A live stream. Leave it running and wait for the next scheduled run, or trigger one (A2).

A2. Run an automation by hand

A2.1  —  Trigger a scheduled worker

Most of these run on a timer. To force a run now:

cd workers/<name> npx wrangler dev --test-scheduled # then in a second terminal: curl "http://localhost:8787/__scheduled"
You should see: The worker executes once and logs appear in the first terminal.
This runs the code locally against live data and live secrets. It is real — it will write to the ad platforms.

A3. Change how often it runs

A3.1  —  Edit the schedule

The schedule lives in that worker wrangler.toml, in cron syntax.

cd workers/<name> open wrangler.toml # you are looking for: [triggers] crons = ["0 13 * * *"] # 13:00 UTC daily

Change the value, save, then deploy (A5).

Cron here is always UTC, never local time.

A4. Add or change a password / key

A4.1  —  See what is already set
cd workers/<name> npx wrangler secret list
You should see: A list of names — values are never shown, by design.
A4.2  —  Set one
npx wrangler secret put SECRET_NAME

It prompts for the value. Paste it and press enter.

You should see: "Successfully created secret".
Names must match exactly what the code expects. The required names per worker are in SECRETS.md in the bundle; the values are in the restricted sheet.
If that fails: Never put a secret in the source code or in wrangler.toml. Anything in those files is in the repo forever.

A5. Change what an automation does, and publish it

A5.1  —  Quick edit in the browser

Dashboard → the worker → Edit codeSave and Deploy. Fine for a one-line fix.

An edit made here is not in the source bundle. Mirror it back or the next proper deploy overwrites it.
A5.2  —  The proper way
cd workers/<name> npm i # edit src/worker.js npx wrangler deploy
You should see: "Published <name>" plus a version id. The cron schedule and bindings ship automatically from wrangler.toml.
If that fails: "Missing binding" means a KV namespace id in wrangler.toml does not exist in this account — see A6.
A5.3  —  Undo a bad deploy

Dashboard → the worker → Deployments → find the previous version → Rollback.

You should see: Traffic returns to the previous version within seconds.

A6. If you re-host into your own Cloudflare account

A6.1  —  Recreate the storage first

Workers store state in KV namespaces. The ids in wrangler.toml point at Homa account. In a different account you must create your own.

npx wrangler kv namespace create BINDING_NAME
You should see: It prints a new id.

Paste that id into wrangler.toml under the matching binding, then deploy.

If that fails: Deploying without this gives "KV namespace not found" or a silent no-op where the worker runs but stores nothing.

A7. The landing pages and this site

A7.1  —  Deploy a Pages site
npx wrangler pages deploy <folder> --project-name=<project> --branch=main
The branch must be main. Deploying with --branch=production succeeds, prints "Deployment complete", and gives you a working preview URL — but the live domain keeps serving the old version. Three deploys were silently invisible before this was spotted.
You should see: "Deployment complete" plus a preview URL. Then check the live domain, not the preview URL.
A7.2  —  Always verify the live domain

A 200 response is not proof. The site returns a small fallback page with a 200 status for any unknown path.

curl -sL -o /dev/null -w "%{http_code} %{content_type} %{size_download}\n" \ https://<your-live-domain>/<a-real-path>
You should see: 200, the right content type, and a size in the tens of thousands of bytes.
If that fails: A size around 256 bytes means you are looking at the fallback page — the deploy did not land where you think.
Do not re-enable the catalog schedule. catalog_sync.py ships in the bundle with its original launchd definition. Re-hosting it mechanically brings back the hourly full-feed refresh, which is what caused the crawler flood. See section D.

B. Speedeon — the mortgage-intent file

Direction: they send us a file. Nothing arrives unless someone asks for it.

B1. Request the weekly file

B1.1  —  Email the vendor

Contact details are in the vendor workbook. Send this, changing only the dates:

Hi Justin, Ready for this week’s file. Same selects as the last run: P1 — 2+ mortgage purchase-intent events in the last 60 days, FL + TX P2 — 2+ mortgage general-intent events in the last 60 days, FL + TX Please make it net-new only, suppressed against everything already delivered to us, and send the counts by state and tier before posting so we can approve. Thanks
Net-new only is the part that matters. Without it we pay twice for the same person and contact them twice.
B1.2  —  Approve the counts

They reply with record counts by state and intent tier. Check the split looks like previous runs before saying yes. For reference, the first delivery was 20,000 records — FL 10,472 and TX 9,528, with 17,055 carrying a usable email.

If that fails: If the total is under about half of what you expected, call rather than accepting. The shortfall rule is: fill P1 first, then top up from P2.

B2. Get the file

B2.1  —  Pull it from their server
cd ~/homa-engagement/speedeon python3 fetch.py
You should see: It connects, finds the new file in /homa/out, and downloads it into data/. Safe to re-run — it skips what it already has.
If that fails: Connection refused or auth failure means credentials changed. They are in ~/.homa-speedeon-sftp — read the file, never retype the value.
B2.2  —  Check what arrived
python3 intake.py
You should see: Row count, column names, the FL/TX split, and email coverage.
If that fails: If the row count does not match what you approved in B1.2, stop and query it before loading anything.

B3. Clean it

B3.1  —  Remove people we should not contact

Drop anyone who has already registered, is in a recovery sequence, or has unsubscribed. This is the suppression step and it is not optional.

On the first load this removed zero people — 11,918 registered emails were checked with no overlap. Do not assume that will always be true.

B4. Activate it — all three places

B4.1  —  Email

Load into the email tool as a new campaign.

Merge tags must be camelCase. snake_case renders as blank and the email goes out with a gap where the name should be.
If that fails: If you push these leads into a campaign and the count comes back lower than you loaded, both skip flags need to be false — otherwise anyone already in the workspace is silently dropped.
B4.2  —  Meta audience

Build a Custom Audience with all the identity fields, not just email — email, first name, last name, zip, state, country. Multi-key roughly doubles the match rate.

You should see: The upload response reports how many records it received and how many were invalid. Invalid must be zero.
If that fails: Any invalid count means a normalisation bug — every field must be lowercased and trimmed, zip cut to 5 digits, then hashed individually.
B4.3  —  Google audience

Create the list, then add members through Data Manager (not the regular Ads API).

cd ~/homa-engagement/customer-match-datamanager # ALWAYS dry-run first — validates without saving python3 ingest.py --list <LIST_ID> --emails-file <file> --validate-only # then for real python3 ingest.py --list <LIST_ID> --emails-file <file>
You should see: "VALIDATED" on the dry run, then "INGESTED" with the member count.
Attach the list to campaigns as Observation only. See C6 — getting this wrong restricts a live campaign.

C. OnSpot — the open-house geofence

Direction: we send them a file. They match devices that were physically at those open houses and push audiences into our ad account.

C1. Build the file (Monday)

C1.1  —  Pull the open houses

Dates are the weekend just gone, comma separated.

cd ~/homa-engagement/cold-pool-shell/onspot_weekends/scripts set -a && . ~/.homa-anyprop-creds && set +a python3 oh_fetch_raw.py "2026-08-15,2026-08-16,2026-08-17" oh_raw.json
You should see: A per-source match count for all 16 MLS feeds, then a total and "saved raw".
Always save raw first. A combined fetch-and-enrich run that dies halfway loses the fetch and you start over.
C1.2  —  Export our own property data
python3 wh_export.py wh_fl_properties.csv
You should see: Progress lines then a total row count.
If that fails: If it errors on pagination, note that the analytics API rejects OFFSET — the script pages by id instead. Do not "fix" it back to OFFSET.
C1.3  —  Fill the gaps
python3 oh_backfill.py oh_raw.json wh_fl_properties.csv merged.csv
You should see: It reports how many rows came from our warehouse and how many needed the MLS.
Our warehouse only covers a minority of these homes — roughly a fifth. The backfill is not optional.
C1.4  —  Split into weekend files
python3 split_weekends.py merged.csv ../
You should see: One CSV per weekend, each with a row count and file size. Sunday rows are grouped with their Saturday.

C2. THE CHECK — before anything goes to the vendor

Do not skip this. On 12 August the vendor QA rejected 287 of our records. That was our data, not theirs, and it cost a full delivery cycle.
C2.1  —  Run the five checks

For every row in every weekend file:

  1. End time is after start time. An inverted window is an unusable geofence.
  2. Start time is present.
  3. End time is present.
  4. Latitude and longitude are present and not zero.
  5. No duplicate row for the same listing, date and start time.
You should see: Zero failures on all five.
If that fails: Failures cluster in one feed. citrus is about 59% broken — it emits a start on one date and an end on the day before. Ten other feeds are clean.
C2.2  —  Drop the bad rows

Simplest and safest: remove any row that fails. It costs about 1% of volume.

Repairing the citrus date rollover is possible but only where the local times validate. A wrong time window is worse than a missing row — it geofences the wrong hours.

C3. Send it

C3.1  —  Email the files

Attach the weekend CSVs. Raw CSV is fine — each is roughly 2.6 to 3.0 MB, and three fit comfortably under the attachment limit.

State in the email: lat/long only, and one audience per weekend, kept separate.

Do not also send an address-match file. Sending both is what created the duplicate audiences that inflated the device count.

C4. Confirm what they built

C4.1  —  List the audiences by date, not by name

In the ad account, list all custom audiences sorted by creation time, newest first.

If that fails: Never filter by name. Their naming varies — hyphens versus underscores, singular versus plural. A name filter silently misses real deliveries and you conclude nothing arrived.
C4.2  —  Check the REAL size

Select all the new audiences together and read the platform delivery estimate for the union.

You should see: A deduplicated reach figure.
If that fails: Do not use the headline total. Nine audiences once summed to about 117,000 devices on paper but deduplicated to 23,700–27,900 — roughly a quarter. Price and plan against the union figure.

C5. Point an ad group at it

This is the step that gets missed. Nine audiences were delivered on 5 August and sat unused until the 12th because nothing referenced them. Audiences do nothing on their own.
C5.1  —  Build the ad group
  1. Include all the new weekend audiences (the platform deduplicates the overlap for you).
  2. Exclude registered users and anyone already closed or in escrow.
  3. Turn off audience expansion.
  4. Set the housing category — required for property advertising.
  5. Optimise for a light action such as a link click, not a signup.
  6. Build it paused, check it, then switch on ad first, then group, then campaign.

C6. Attaching lists to Google Search

C6.1  —  Observation, never Targeting

Attach the audience to each campaign one at a time, then explicitly set the audience mode to observation and verify it.

If that fails: Targeting restricts the campaign to only that list. A live campaign attached as Targeting to a 723-person list stops serving to everyone else. Campaigns often have no audience setting at all, and the default is ambiguous — so set it deliberately and read it back.
Attach one operation at a time. A batch is all-or-nothing, so one bad entry rolls back every attachment.

D. The listing catalog

The property ads pull from a catalog of about 16,000 live listings. It is edited by hand, on purpose.

Never re-upload the whole feed. Never re-enable the hourly schedule.
It is not an outside attack we are guarding against — it is the ad platform own crawler, and we trigger it. It fetches a listing page whenever it needs to read that item. A full refresh makes it re-read all ~16,000 pages at once, against our most expensive render. On the worst day that was roughly 10,500 of 12,500 apparent visitors from that platform.

D1. The cost model — the whole rule

ActionCostSafe?
Remove a listingNothing. No page fetch needed.Always
Add a listingOne page fetch each.In small batches
Change an item image URLRe-fetches the image only.Yes
Full re-upload / hourly scheduleEvery page, at once, repeatedly.NEVER

D2. Remove listings that are no longer for sale

D2.1  —  Find them

Compare the catalog contents against live listing status. Anything sold or under contract that is still in the catalog should go.

For scale: the last pass found 476 of 3,562 recently-served listings were off-market — about 13% — but they were drawing only ~1% of spend. This is hygiene, not an emergency.
D2.2  —  Delete them

Use the batch endpoint with method DELETE, in chunks of 200 or fewer.

POST /{catalog_id}/items_batch { "item_type": "HOME_LISTING", "requests": [ {"method": "DELETE", "data": {"home_listing_id": "<id>"}} ] }
You should see: It returns handles. The call is asynchronous.
If that fails: Do not mark items unavailable instead. Setting availability propagated inconsistently, and an update against an item that is already gone creates an empty stub that throws missing-image and missing-address warnings.

D3. Add new listings

D3.1  —  Pick which ones — demand first

Do not add everything. Start from what people are actually viewing: listings with at least two distinct viewers in the last 21 days, then join that set against the listings table.

Join from the viewed set, not the other way round. The full-table version times out. Also set an explicit row limit — the analytics API silently caps results at about 100 otherwise.
D3.2  —  Create them

Same batch endpoint, method CREATE, only genuinely new ids, a few hundred per call at most.

{ "method": "CREATE", "data": { "home_listing_id": "<id>", "name": "<street only>", "availability": "for_sale", "price": "<int> USD", "url": "https://app.tryhoma.com/property/<id>/details?utm_source=meta&utm_content=<id>", "image": [{"url": "<RAW MLS photo>"}], "address": {"addr1": "...", "city": "...", "region": "...", "postal_code": "...", "country": "US"}, "latitude": 0.0, "longitude": 0.0, "num_beds": 0, "num_baths": 0, "property_type": "..." } }
If that fails: Two traps that fail silently. (1) image must be an array — a plain string, or the bracket form image[0].url, both fail with a generic "invalid values" error and the item never displays. (2) Use the raw MLS photo here, not the badged URL — the badge has not been generated for a brand-new listing, so the badged address 404s and the ad shows a broken image.
D3.3  —  Add the price badge afterwards

Anything added by hand skips the nightly badging job, which reads a frozen feed. So do it explicitly, in this order:

  1. Create the item with the raw photo (D3.2).
  2. Generate the badged image.
  3. Upload it to storage.
  4. Update the item image to the badged URL.
You should see: That final image swap re-fetches only the image, so it does not trigger a page crawl.
D3.4  —  Verify they actually exist
GET /{catalog_id}/home_listings ?filter={"home_listing_id":{"is_any":["<id>","<id>"]}}
You should see: Your items come back.
If that fails: An empty result means the item is invalid and will not display in ads — almost always the image-array trap from D3.2.

D4. How often

CadenceJob
Monthly, or when spend shiftsRemove off-market listings.
As neededAdd in-demand new listings, badge them.
NeverFull re-upload. Re-enabling the schedule.
The permanent fix is an engineering change, not a process one. Once the property page is cached by user-agent for that crawler, a full refresh stops being expensive. Until then, this manual process is the protection.

Homa operator guide. Companion to the handoff page.