HomeZero · market agent

Staff desk on Slack

2026-06-24 · flue worker homezero-flue · branch feature/market-staff-slack

Staff talk to the market agent in Slack and get a candid operator — it answers from the market's live brain and can pull its levers (run a sweep, drain briefings, re-author tonight's read) without ever touching the live market agent's autonomous session.

The shape in one breath

Slack delivers a verified event → the @flue/slack channel on homezero-flue dispatches it to a staff-desk agent (one DO per Slack thread) → the agent reads/operates the market and posts its reply back into the thread.

Slack workspace │ @mention / DM the bot ▼ advisor.homezero.md/channels/slack/events (@flue/slack — verifies the signature) │ dispatch(agent:'staff', id: slack:v1:<team>:<channel>:<thread>) ▼ FlueStaffAgent (one DO per thread — Opus 4.8 @ medium, operator register) │ ╲ reply_in_slack_thread → chat.postMessage │ READ: market_read · market_health · list_listings · read_listing (Convex + the market's Turso) │ OPERATE: trigger_sweep · trigger_drain · trigger_metrics ▼ dispatch(agent:'market', id: north-fork-valley-v9, {kind}) ← the LIVE market agent runs the beat

Why a staff desk, not the live market DO

Flue beta.5's inbound ingress (HTTP + dispatch) can only feed an agent instance's one default session — there's no session selector on the wire. The live market agent (north-fork-valley-v9) runs its autonomous beats in that default session, and we just spent a v8→v9 DO recreate clearing a session that got poisoned by foreign failures. So staff chat gets its own agent: a separate DO per Slack thread that shares the market's brain (the same Turso store + Convex billboard/health) and pulls the same levers (dispatches real beats at the live market DO) — but never runs anything in the market DO's session. The market's autonomy stays isolated; staff get a full operator surface.

The staff agent is purely reactive — no rhythm, no scheduled beats. It answers a message and stops. The cells-era "two-hats" model (one cell, named buyer/staff sessions) isn't reproducible on beta.5 ingress; this gets ~all of it with documented APIs and zero new fragility.

The persona — operator register

Lifted from the cells-era advisor's SOUL.md two-hats discipline: the person here is HomeZero staff, not a buyer. The agent drops the warm advisor voice and talks like a candid technical peer — confidence, data quality, staleness, what the last beat actually did, where the gaps are. The load-bearing rule (and the test that guards it): it never answers staff in the buyer-facing voice, and it always ends a turn by posting through reply_in_slack_thread.

The tools

GroupToolWhat it does
READmarket_readTonight's authored billboard read + the 5 tiles (and whether authored or live-fallback, and how old)
market_healthFreshness/liveness — last sweep, last read, active count, stale flags. "Is the agent alive?"
list_listingsCurrent for-sale roster (address, price, beds/baths, status, briefed)
read_listing(zpid)One listing's full stored record from the market's Turso
OPERATEtrigger_sweepRefresh listings from Zillow now (async; a full sweep takes minutes)
trigger_drainAuthor the briefings buyers are waiting on (async)
trigger_metricsRe-author tonight's read + re-snapshot the tiles (async)

Operate tools dispatch a beat into the live market DO and return immediately — the agent says what it kicked off and that the result lands shortly. Each market binds its live DO id in flue/src/market/identity.ts (one place; it carries the version suffix that bumps on a recreate).

Wiring it to our Slack

Cells already talks to our workspace through a Slack app named cells (bot U0B231DT0D6), behind a router worker cells-front-slack at slack.cells.md that fans the app's single Events URL out to cells via a KV channel→{cell,session} binding. The signing secret + bot token are already minted in ~/.cells/secrets.json.

The constraint: a Slack app has exactly one Events Request URL, and @flue/slack verifies the request signature itself — so the cells router can't forward to it (the router reshapes the body, dropping the signature). Reusing the cells app therefore means repointing its Events URL off cells (slack.cells.md/events) and onto advisor.homezero.md/channels/slack/events. The homezero-flue worker becomes the direct receiver. Cells loses Slack inbound — which is the natural Slack half of the cells→Flue retirement.

Setup runbook (reuse the cells app, repoint)

  1. Reuse the creds. Set the two secrets on homezero-flue from ~/.cells/secrets.json (same signing secret + bot token, same bot identity):
    cd flue
    # values piped from the cells secrets file — never printed
    python3 -c "import json;print(json.load(open('$HOME/.cells/secrets.json'))['SLACK_SIGNING_SECRET'])" \
      | npx wrangler secret put SLACK_SIGNING_SECRET
    python3 -c "import json;print(json.load(open('$HOME/.cells/secrets.json'))['SLACK_BOT_TOKEN'])" \
      | npx wrangler secret put SLACK_BOT_TOKEN
  2. Deploy the worker. flue auto-mounts the channel at /channels/slack/events; until SLACK_SIGNING_SECRET is set it 401s every request (signature check fails), and until the Events URL is repointed nothing reaches it — so this is safe to deploy ahead of the cutover.
    cd flue && npx flue build --target cloudflare && npx wrangler deploy
  3. Repoint the Events URL. In the cells Slack app config → Event Subscriptions → Request URL, change
    https://slack.cells.md/events → https://advisor.homezero.md/channels/slack/events.
    Slack re-verifies the URL live (the channel answers the url_verification challenge internally). The subscribed bot events already include app_mention and message.im.
  4. Make the bot reachable. Invite the cells bot to the staff channel (or DM it). Staff @cells-mention it in a channel, or DM it directly.
  5. Smoke test. In Slack: @cells is the market alive? → expect a thread reply with sweep/read freshness. Then @cells run a sweep → expect "triggered a sweep…". Watch the live run via the SSE stream on the market DO if needed.

After repoint — scope note

With the homezero-flue worker as the app's sole receiver, the staff desk answers any @mention or DM of the bot, workspace-wide (it ignores everything that isn't a human @mention or DM, and its own messages). For a single market that's fine. Multi-market routing (a Slack channel → a specific market's staff desk, the cells KV-binding idea) is a later add when more markets launch.

Alternative — keep cells' Slack alive

If cells still needs Slack inbound, stand up a second Slack app (e.g. "homezero") in the same workspace with its own Events URL → advisor.homezero.md/channels/slack/events and its own signing secret + bot token (set as the two Worker secrets above). ~5 minutes via a manifest; both apps coexist. Costs a second bot identity in the workspace.

Files