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.
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.
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.
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.
| Group | Tool | What it does |
|---|---|---|
| READ | market_read | Tonight's authored billboard read + the 5 tiles (and whether authored or live-fallback, and how old) |
| market_health | Freshness/liveness — last sweep, last read, active count, stale flags. "Is the agent alive?" | |
| list_listings | Current for-sale roster (address, price, beds/baths, status, briefed) | |
| read_listing(zpid) | One listing's full stored record from the market's Turso | |
| OPERATE | trigger_sweep | Refresh listings from Zillow now (async; a full sweep takes minutes) |
| trigger_drain | Author the briefings buyers are waiting on (async) | |
| trigger_metrics | Re-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).
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.
@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.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
/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
https://slack.cells.md/events → https://advisor.homezero.md/channels/slack/events.url_verification challenge internally). The subscribed bot events already include app_mention and message.im.
@cells-mention it in a channel, or DM it directly.@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.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.
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.
flue/src/channels/slack.ts — the @flue/slack channel (exported as channel; flue auto-discovers + mounts it at /channels/slack/* — app.ts needs no Slack wiring) + the thread-scoped reply tool + parseSlackKeyflue/src/agents/staff.ts — the staff-desk agent (Opus@medium, AUTH_PASSWORD route gate, no rhythm)flue/src/staff/tools.ts · flue/src/staff/prompts.ts — the read+operate toolset + the operator-register instructionsflue/src/market/identity.ts — LIVE_MARKET_DO_ID / MARKET_SLUG in one place (also now used by the sweep cron)flue/src/app.ts — unchanged Slack-wise (flue() auto-mounts the channel); now references LIVE_MARKET_DO_ID for the sweep cronflue/wrangler.jsonc — migration v3 adds FlueStaffAgentflue/test/staff/*.test.ts (factory shape, channel/key round-trip, prompts, tools + store reader)