Agent Rework — Architecture

One spine: a jobs table. Subsystems do domain work and enqueue a job when they need the PM. A general conductor reads the queue, paces the chat, gets the answer, and writes the resolution back. The owning subsystem finishes. The chat agent stops doing everyone's work.

7 min
Today one iMessage agent does everything: interprets the PM, picks vendors (LLM guess → "24/7 electric"), calls every tool, drafts every message. The rework splits that into specific subsystems (PMS intake, memory, clock) that own their work and tools, and one general conductor that owns the conversation. They talk through a durable jobs table — the only thing the conductor and the subsystems share. Build it additively alongside the live loop; cut over one flow at a time.

The loop

PMS intakeclassify → find_policy memoryask-to-promote (N=2) clockstale WO · quiet vendor jobs tablepending · askingawaiting · resolved enqueue conductorreads queue · pacesconducts · interprets claim PM (chat) ask resolution finish

Green = the answer flowing back. A subsystem enqueues a job, the conductor asks the PM and writes the structured resolution, and the owning subsystem reads it and finishes the work (dispatch the vendor, write the policy). The conductor never dispatches; the subsystems never touch chat.

The jobs table build first

Supabase, durable, multi-process. The seam between "who needs the PM" and "when/how the PM is asked." One pending job per dedupe_key (idempotent enqueues).

ColumnPurpose
id, workspace_iduuid pk · tenant partition
sourcepms_intake | memory | clock — who owns the resolution
kindconfirm_dispatch | ask_promotion | clarify_wo | stale_wo_nudge | open_question
statuspending → asking → awaiting_reply → resolved (or cancelled / failed)
priorityurgent / normal / low — drives pacing order
issue_id, chat_guidcorrelation + where to ask
askjsonb: { question, draft } — what the conductor says (or a hint it rewrites)
resumejsonb: everything the subsystem needs to finish (vendor_id, proposal spec, …)
resolutionjsonb: the PM's structured answer, written by the conductor
not_beforework-hours hold / snooze — earliest surface time
dedupe_keyidempotency: one pending job per (kind, issue/scope)
attempts, last_error, *_atretry + lifecycle timestamps

This is the drafts.json staging queue generalized: hold_untilnot_before, approved_atstatus, plus the two things drafts lack — source (route the resolution home) and resume (reconstruct the work).

The conductor

One general agent. It owns the conversation and the pacing — nothing domain-specific.

Reads the queue, paces.Surfaces at most one asking job per chat at a time (batches related asks), respects not_before + priority. This is the "don't dump every job into chat at once" rule.
Asks.Drafts the question from job.ask (warm, in-voice), sends via the existing draft/send pipeline, sets awaiting_reply.
Interprets the reply.When the PM answers, matches it to the open job and turns free text into a structured resolution ({confirmed, vendor?, freeform?}). The conductor's only LLM job.
Hands back.Writes resolution, sets resolved. A per-source handler consumes it. The conductor is done.

Subsystems own the work

PMS intake (the "24/7 electric" fix)

// new-work-order-message.mjs — replaces decideMessage's LLM vendor guess
const trade = await classifyTrade(issue)         // description → trade (the keystone)
const r = await findPolicy({ trigger:{ kind:'new_work_order', trade, property_id } })
// tier 1 → "Sending Osalpa for the electrical?"   tier 2 → derived suggest + ask
// tier 3 → "Who should handle this?"   (never a guessed vendor)
enqueueJob({ source:'pms_intake', kind:'confirm_dispatch', issue_id,
  ask:{ question, draft }, resume:{ vendor_id: r.policy?.args.vendor_id, trade } })
// on resolution {confirmed, vendor}: PMS calls dispatch_vendor + log_event(the decision)

Memory

find_policy already emits a proposal at N=2. When intake (or a sweep) sees one, it enqueues an ask_promotion job with the proposal in resume. On a yes, the memory handler calls log_event(create_policy) — authority gets a name. This is the F half, now with a delivery channel.

The tool split

OwnerTools
conductorsend_text, wait, done — conversational + flow only
PMSdispatch_vendor, text_tenant, update_issue — invoked by the intake/resolution handler, not the chat agent
memoryfind_policy, log_event — invoked by intake + the promotion handler

Today all 10 live on the chat agent (and find_policy/log_event aren't even registered). After the split the conductor can't accidentally dispatch a vendor — it can only talk.

The clock

A heartbeat that turns time into jobs. Cheapest host: a tick() at the end of the issue-poller's existing 5s sweep. Each tick scans for and enqueues (idempotently): stale WO (awaiting PM too long), quiet vendor (dispatched, no reply in TTL), unanswered question (clarification sent, PM silent), follow-up due. The conductor handles them like any other job — so escalations pace through the same channel, never a separate firehose.

Build plan

Additive, flag-gated, one flow at a time. The live loop keeps running on the old path. Each new piece ships dark, gets verified in the Test workspace, then the cutover is a deliberate flip — never a big-bang replace while a real customer is on the line.
in progress1 · Jobs table + core/jobs.mjsSchema + enqueue/claim/resolve/list, idempotent, with a smoke test. The spine; nothing else lands without it.
2 · Trade classifierclassifyTrade(issue) — description → trade. The keystone; find_policy is blind without it. Eval against the seeded WOs.
3 · PMS intake → find_policy → jobBehind a flag: classify → find_policy → enqueue confirm_dispatch. Old decideMessage stays as fallback.
4 · Conductor loopReads jobs, paces, asks, interprets, resolves. Reuses the draft/send + work-hours machinery.
5 · Resolution handlers + clockPer-source finish (dispatch / create_policy); clock tick enqueues heartbeat jobs.
6 · Tool split + cutoverNarrow the conductor's registry; flip intake to policy-first; retire the LLM vendor guess.

Memory subsystem is done (E+F shipped, committed). Steps 1–2 are safe pure-infra wins; 3–6 are the cutover, gated for your review.