Research

Digital Surface Labs

"Claims Lifecycle Architecture: From Outreach to Recovery"

Claims Lifecycle Architecture: From Outreach to Recovery

1. The Problem

Dollar Hound has a strong forward pipeline: fetch records from 50 state databases, resolve identity via the people graph, enrich contacts, generate outreach emails, send. The pipeline ends at send.js marking a record status = 'sent'. After that, nothing.

If someone replies to an outreach email, the reply lands in a Gmail inbox. There is no system connecting that reply to the claim record it relates to. Today the process is:

  1. Check inbox manually
  2. Read the reply, figure out who it is
  3. Look up the record in the dashboard
  4. Figure out the state-specific claiming process
  5. Write a personalized reply with instructions
  6. Track the back-and-forth until the claim is filed
  7. Follow up if they go quiet

This is fine for 5 replies. It does not work for 500. The gap between "email sent" and "money recovered" is where the actual value gets created, and right now it is entirely manual.

The records table already has columns that hint at a lifecycle (claim_started_at, claim_submitted_at, paid_at, paid_amount, fee_amount) but nothing populates them. There is no conversation threading, no stage tracking, and no way for an agent to monitor the inbox and route replies.


2. Architecture: Dollar Hound + OpenClaw

The right split is clean: Dollar Hound owns data, OpenClaw owns orchestration.

  Dollar Hound (data + APIs)              OpenClaw (orchestration)
  ==============================          ==============================

  SQLite Database                         Cron Jobs
  ├── records                             ├── inbox-check (every 5 min)
  ├── conversations                       └── stale-detection (daily)
  ├── conversation_messages
  ├── outreach_events                     Skill: claims-lifecycle
  └── finder_regulations (ref data)       ├── match inbound email
                                          ├── advance conversation stage
  Express API (server.js :3200)           ├── notify Joe via WhatsApp
  ├── GET  /api/conversations             ├── compose replies
  ├── POST /api/conversations             └── auto-acknowledge
  ├── POST /api/conversations/:id/message
  ├── POST /api/conversations/:id/stage   Channels
  ├── POST /api/conversations/:id/reply   ├── Email (inbox monitoring)
  ├── POST /api/conversations/match-email ├── WhatsApp (notifications)
  └── GET  /api/conversations/stats       └── iMessage (fallback)

  Dashboard (views/)                      Memory
  ├── dashboard.html (badge + stage col)  └── Learned patterns, templates
  └── conversation.html (thread view)        state-specific guidance

Data flows one direction at the API boundary: OpenClaw calls Dollar Hound's APIs. Dollar Hound never calls OpenClaw. This means:

  • Dollar Hound has zero new dependencies (no imapflow, no cron library, no WhatsApp SDK)
  • OpenClaw uses capabilities it already has: inbox channel integration, cron scheduling, multi-channel messaging, memory, approval workflows
  • The skill is markdown + a few API calls, not hundreds of lines of application code

3. Database Design

3.1 New Table: conversations

Tracks the lifecycle of a single claim conversation from first outbound email through resolution.

CREATE TABLE IF NOT EXISTS conversations (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  record_id       INTEGER NOT NULL REFERENCES records(id),
  stage           TEXT NOT NULL DEFAULT 'awaiting_response',
  thread_id       TEXT,           -- Message-ID from original sent email
  last_message_at DATETIME,
  last_direction  TEXT,           -- 'inbound' or 'outbound'
  next_action     TEXT,           -- free text: what needs to happen next
  next_action_due DATETIME,
  state_process_url TEXT,         -- state-specific claim portal URL
  created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_conversations_record
  ON conversations(record_id);
CREATE INDEX IF NOT EXISTS idx_conversations_stage
  ON conversations(stage);
CREATE INDEX IF NOT EXISTS idx_conversations_next_action_due
  ON conversations(next_action_due)
  WHERE next_action_due IS NOT NULL;
Column Type Purpose
record_id INTEGER FK Links to the original unclaimed property record
stage TEXT Current lifecycle stage (see Section 5)
thread_id TEXT SMTP Message-ID from the original outbound email, used for In-Reply-To matching
last_message_at DATETIME Timestamp of most recent message in either direction
last_direction TEXT Whether the last message was inbound or outbound
next_action TEXT Human-readable description of what needs to happen next
next_action_due DATETIME Deadline for the next action (drives stale detection)
state_process_url TEXT Direct link to the state's claim portal for this record

3.2 New Table: conversation_messages

Every message in the thread, inbound or outbound.

CREATE TABLE IF NOT EXISTS conversation_messages (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  conversation_id INTEGER NOT NULL REFERENCES conversations(id),
  record_id       INTEGER NOT NULL REFERENCES records(id),
  direction       TEXT NOT NULL,  -- 'inbound' or 'outbound'
  from_address    TEXT,
  to_address      TEXT,
  subject         TEXT,
  body            TEXT,
  message_id      TEXT,           -- SMTP Message-ID header
  in_reply_to     TEXT,           -- SMTP In-Reply-To header
  matched_by      TEXT,           -- how this message was linked: 'message_id', 'subject', 'email', 'manual'
  created_at      DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_conv_messages_conversation
  ON conversation_messages(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_conv_messages_message_id
  ON conversation_messages(message_id)
  WHERE message_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_messages_in_reply_to
  ON conversation_messages(in_reply_to)
  WHERE in_reply_to IS NOT NULL;
Column Type Purpose
direction TEXT 'inbound' (recipient replied) or 'outbound' (we sent)
message_id TEXT The SMTP Message-ID header value
in_reply_to TEXT The SMTP In-Reply-To header value (present on replies)
matched_by TEXT How this message was linked to the conversation

3.3 Column Additions to records

Two new columns on the existing records table:

ALTER TABLE records ADD COLUMN message_id TEXT;
ALTER TABLE records ADD COLUMN conversation_stage TEXT;
Column Purpose
message_id Captured from nodemailer response (info.messageId) when sending. This is the anchor for threading.
conversation_stage Denormalized from conversations.stage for dashboard filtering without JOINs. Updated via trigger or API.

These follow the existing pattern in server.js where new columns are added via safe ALTER TABLE wrapped in try/catch (lines 172-202).


4. Email Threading / Matching

When an inbound email arrives, it needs to be connected back to a claim record. This is done via a priority cascade -- try the highest-confidence method first, fall through to lower confidence.

Priority Cascade

Priority Method Confidence How It Works
1 Message-ID match Highest Check the inbound email's In-Reply-To or References headers against records.message_id. This works when the recipient hits "Reply" in their email client, which automatically sets In-Reply-To to the Message-ID of the email they are replying to.
2 Subject line match High Parse the subject for state-specific patterns like "Re: You may have unclaimed property in CA". Cross-reference with records where status = 'sent' and state = 'CA'. If there is exactly one match, assign it. If multiple, narrow by sender email.
3 Email address match Medium Look up the From address against records.email where status = 'sent'. This catches cases where someone starts a new email thread instead of replying to the original. If there are multiple sent records for the same email, flag for manual review.
4 Manual assignment Fallback Unmatched messages get logged to a pending_messages queue for operator triage in the dashboard.

Capturing Message-ID on Send

Currently, both send.js and triage.js call transporter.sendMail() but discard the return value. Nodemailer returns an info object with info.messageId -- the SMTP Message-ID assigned to the sent email. This is the single most important piece of data for threading.

In send.js (line 285), change:

// Before:
await transporter.sendMail({ ... });

// After:
const info = await transporter.sendMail({ ... });
db.prepare("UPDATE records SET message_id = ? WHERE id = ?")
  .run(info.messageId, record.id);

In triage.js (line 337), same pattern -- capture info.messageId and POST it to the API alongside the other send metadata:

const info = await transporter.sendMail({ ... });
await apiPost(`/api/triage/mark-sent/${record.id}`, {
  sender_id: sender.id,
  email: fields.email,
  subject: draft.subject,
  job_id: record.job_id,
  message_id: info.messageId,  // NEW
});

Match-Email Algorithm (Pseudocode)

function matchEmail(headers, body):
  // 1. Message-ID match
  if headers.in_reply_to:
    record = db.get("SELECT * FROM records WHERE message_id = ?", headers.in_reply_to)
    if record: return { record, method: 'message_id' }

  if headers.references:
    for ref in parseReferences(headers.references):
      record = db.get("SELECT * FROM records WHERE message_id = ?", ref)
      if record: return { record, method: 'message_id' }

  // 2. Subject line match
  stateMatch = headers.subject.match(/unclaimed property in ([A-Z]{2})/)
  if stateMatch:
    candidates = db.all(
      "SELECT * FROM records WHERE state = ? AND status = 'sent'",
      stateMatch[1]
    )
    if candidates.length == 1: return { record: candidates[0], method: 'subject' }
    // Narrow by sender email
    narrowed = candidates.filter(r => r.email == headers.from)
    if narrowed.length == 1: return { record: narrowed[0], method: 'subject' }

  // 3. Email address match
  records = db.all(
    "SELECT * FROM records WHERE lower(email) = ? AND status = 'sent'",
    headers.from.toLowerCase()
  )
  if records.length == 1: return { record: records[0], method: 'email' }

  // 4. No match
  return { record: null, method: 'manual' }

5. Claims Process State Machine

Stages

Stage Description Entry Trigger
awaiting_response Email sent, waiting for recipient to reply Conversation created after send
responded Recipient replied, operator notified Inbound email matched
gathering_info Collecting information needed for claim (SSN last 4, ID copy, etc.) Operator begins info collection
forms_ready Claim forms/instructions prepared for this state Operator prepares state-specific guidance
forms_sent Instructions sent to recipient Outbound message with forms
claim_in_progress Recipient is working on filing Recipient acknowledges receipt
claim_submitted Claim filed with state Recipient confirms submission
claim_approved State approved the claim Recipient reports approval
paid Money received Recipient confirms payment
stale No response after N days Stale detection cron
closed Conversation ended (unsubscribed, not interested, wrong person) Operator or auto-close

State Machine Transitions

                                    ┌──────────────┐
                              ┌────>│    stale      │
                              │     └──────┬───────┘
                              │            │ (recipient re-engages)
                              │            v
┌───────────────────┐    ┌────┴────┐    ┌──────────────┐
│ awaiting_response │───>│responded│───>│gathering_info│
└───────────────────┘    └────┬────┘    └──────┬───────┘
                              │                │
                              │                v
                              │         ┌─────────────┐
                              │         │ forms_ready  │
                              │         └──────┬──────┘
                              │                │
                              │                v
                              │         ┌─────────────┐
                              │         │ forms_sent   │
                              │         └──────┬──────┘
                              │                │
                              │                v
                              │     ┌──────────────────┐
                              │     │claim_in_progress │
                              │     └────────┬─────────┘
                              │              │
                              │              v
                              │     ┌──────────────────┐
                              │     │ claim_submitted   │
                              │     └────────┬─────────┘
                              │              │
                              │              v
                              │     ┌──────────────────┐
                              │     │ claim_approved    │
                              │     └────────┬─────────┘
                              │              │
                              │              v
                              │     ┌──────────────────┐
                              │     │      paid        │
                              │     └──────────────────┘
                              │
                              v
                       ┌─────────────┐
                       │   closed    │
                       └─────────────┘

Valid transitions:
  awaiting_response -> responded, stale, closed
  responded         -> gathering_info, forms_ready, forms_sent, stale, closed
  gathering_info    -> forms_ready, stale, closed
  forms_ready       -> forms_sent, stale, closed
  forms_sent        -> claim_in_progress, stale, closed
  claim_in_progress -> claim_submitted, stale, closed
  claim_submitted   -> claim_approved, stale, closed
  claim_approved    -> paid, closed
  stale             -> responded, gathering_info, closed  (re-engagement)
  paid              -> (terminal)
  closed            -> (terminal)

Note: responded can skip directly to forms_ready or forms_sent for simple cases where no additional info is needed (e.g., small claims in states with straightforward online portals).

Syncing Stage to Records Table

When a conversation stage changes, the denormalized records.conversation_stage column should be updated:

UPDATE records SET conversation_stage = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;

This keeps the dashboard fast -- filtering by stage does not require a JOIN.


6. API Design

All endpoints live in server.js alongside existing routes. They follow the same patterns: Express routes, better-sqlite3 queries, JSON responses.

Endpoints

GET /api/conversations

List active conversations, paginated and filterable.

Query params:
  stage     — filter by stage (e.g., ?stage=responded)
  state     — filter by record state (e.g., ?state=CA)
  page      — page number (default: 1)
  limit     — results per page (default: 50)
  sort      — 'recent' (default), 'oldest', 'amount'

Response:
{
  conversations: [
    {
      id, record_id, stage, last_message_at, last_direction,
      next_action, next_action_due,
      record: { owner_name, email, state, amount_dollars, property_type }
    }
  ],
  total: 142,
  page: 1,
  pages: 3
}

GET /api/conversations/:id

Full conversation detail with all messages.

Response:
{
  conversation: { id, record_id, stage, thread_id, ... },
  record: { id, owner_name, email, state, amount_dollars, ... },
  messages: [
    { id, direction, from_address, subject, body, created_at, matched_by }
  ]
}

GET /api/conversations/pending

Conversations needing operator attention. Returns conversations where: - stage = 'responded' (new replies) - next_action_due <= now (overdue actions) - last_direction = 'inbound' and no outbound reply yet

Response:
{
  pending: [ ... ],   // same shape as /api/conversations items
  counts: { responded: 3, overdue: 1, awaiting_reply: 2 }
}

POST /api/conversations

Create a new conversation. Called by OpenClaw when a matched inbound email arrives, or by send.js at send time.

Body:
{
  record_id: 12345,
  thread_id: "<abc123@mail.gmail.com>",       // Message-ID from sent email
  stage: "awaiting_response",                  // default
  state_process_url: "https://ucpi.sco.ca.gov/UCP/Default.aspx"
}

Response:
{ id: 1, record_id: 12345, stage: "awaiting_response", ... }

POST /api/conversations/:id/message

Log a message (inbound or outbound).

Body:
{
  direction: "inbound",
  from_address: "jane@example.com",
  to_address: "joe@digitalsurfacelabs.com",
  subject: "Re: You may have unclaimed property in CA",
  body: "Hi Joe, yes I'd like to claim this! What do I need to do?",
  message_id: "<reply123@mail.gmail.com>",
  in_reply_to: "<original456@mail.gmail.com>",
  matched_by: "message_id"
}

Response:
{ id: 1, conversation_id: 42, direction: "inbound", ... }

Side effects: Updates conversations.last_message_at, last_direction, updated_at.

POST /api/conversations/:id/stage

Advance the conversation stage.

Body:
{
  stage: "gathering_info",
  next_action: "Ask for last 4 of SSN and photo ID",
  next_action_due: "2026-03-27T00:00:00Z"
}

Response:
{ id: 42, stage: "gathering_info", ... }

Side effects: Updates records.conversation_stage, creates an outreach_events entry with event_type = 'stage_change'.

POST /api/conversations/:id/reply

Compose and send a reply via the sender pool.

Body:
{
  body: "Hi Jane, great news! Here's how to claim...",
  subject: "Re: You may have unclaimed property in CA"  // optional, defaults to thread subject
}

Response:
{ sent: true, message_id: "<reply789@digitalsurfacelabs.com>" }

Side effects: Sends email via sender pool, logs outbound message, updates last_message_at, last_direction.

GET /api/conversations/stats

Counts by stage for dashboard badges.

Response:
{
  awaiting_response: 412,
  responded: 7,
  gathering_info: 3,
  forms_ready: 1,
  forms_sent: 4,
  claim_in_progress: 2,
  claim_submitted: 1,
  claim_approved: 0,
  paid: 0,
  stale: 28,
  closed: 15
}

POST /api/conversations/match-email

Given email headers, find matching record. This is the primary endpoint OpenClaw calls when processing inbound email.

Body:
{
  from: "jane@example.com",
  subject: "Re: You may have unclaimed property in CA",
  in_reply_to: "<original456@mail.gmail.com>",
  references: "<original456@mail.gmail.com>",
  body: "Hi, I got your email..."
}

Response (matched):
{
  matched: true,
  method: "message_id",
  record: { id: 12345, owner_name: "DOE JANE", state: "CA", ... },
  conversation: { id: 42, stage: "awaiting_response" }  // null if no existing conversation
}

Response (unmatched):
{
  matched: false,
  method: "manual",
  record: null,
  conversation: null
}

7. OpenClaw Skill Design

The claims lifecycle is implemented as a single OpenClaw skill. The skill file at skills/claims-lifecycle/SKILL.md defines everything OpenClaw needs.

Skill Structure

skills/claims-lifecycle/
├── SKILL.md          -- Skill definition (cron schedule, capabilities, instructions)
└── templates/        -- State-specific response templates (optional, can also live in memory)

SKILL.md Content

# Claims Lifecycle

## Triggers
- cron: */5 * * * *     (inbox check every 5 minutes)
- cron: 0 8 * * *       (stale detection daily at 8am)
- message: /claims      (manual trigger from WhatsApp)

## API Base
- Dollar Hound: http://epimetheus:3200

## Capabilities

### 1. Inbox Check (every 5 min)
For each new email in the claims inbox:
1. Call POST /api/conversations/match-email with headers + body
2. If matched and no conversation exists: POST /api/conversations to create one
3. POST /api/conversations/:id/message to log the inbound message
4. POST /api/conversations/:id/stage to advance to 'responded'
5. Send WhatsApp to Joe: "[Name] replied about $[amount] in [state]. [first 100 chars of reply]"

### 2. Response Handling
When Joe replies via WhatsApp with guidance:
1. Load state-specific template from memory (or use generic)
2. Fill template variables from record context
3. Optionally personalize via LLM
4. Call POST /api/conversations/:id/reply to send
5. Call POST /api/conversations/:id/stage to advance

### 3. Stale Detection (daily at 8am)
1. GET /api/conversations?stage=awaiting_response,gathering_info,forms_sent,claim_in_progress
2. Filter for last_message_at > 7 days ago (configurable)
3. For each stale conversation:
   - If stage is awaiting_response and age > 14 days: auto-close
   - If stage is gathering_info and age > 7 days: send follow-up
   - Otherwise: notify Joe via WhatsApp with summary

### 4. Auto-Acknowledge (Phase 3)
When a new inbound message is matched:
1. Send immediate acknowledgment: "Thanks for responding! I'll review your message and
   get back to you with next steps within 24 hours."
2. Set next_action_due to 24 hours from now

## Stop and Ask Pattern
When uncertain about a reply (ambiguous intent, unusual request, potential legal issue):
1. Do NOT auto-respond
2. Send Joe the full message via WhatsApp
3. Wait for Joe's reply
4. Use Joe's guidance to compose the response
5. Log the pattern in memory for future reference

The "Stop and Ask" Pattern

This is critical and already proven in Joe's existing OpenClaw usage. The cron fires in the main session, processes conversations in a loop, and pauses when it hits something that needs human judgment:

Cron fires (every 5 min)
  └─> Check inbox
       └─> New reply from Jane Doe ($2,400, CA)
            └─> Match found via Message-ID
            └─> Log message
            └─> Advance to 'responded'
            └─> WhatsApp to Joe:
                 "Jane Doe replied about $2,400 in CA:
                  'Hi, I'd like to claim this but my name has
                  changed since then. What do I do?'

                  Reply with guidance or 'skip' to handle later."
            └─> Joe replies: "Tell her she'll need her marriage
                 certificate or court order showing the name
                 change, plus current photo ID"
            └─> Compose reply using CA template + Joe's guidance
            └─> POST /api/conversations/:id/reply
            └─> Advance to 'gathering_info'

8. State-Specific Guidance Templates

Templates are keyed by (state, stage) and stored in OpenClaw's memory system. They use Mustache-style variables filled from record context.

Template Variables

Variable Source
{{firstName}} Parsed from records.owner_name via firstNameFrom() in outreach.js
{{state}} records.state
{{stateName}} Looked up from finder-regulations.js state_name field
{{amount}} Formatted from records.amount_dollars
{{claimUrl}} records.claim_url or finder-regulations.js portal_url
{{agency}} From finder-regulations.js agency field
{{agencyUrl}} From finder-regulations.js agency_url field
{{propertyType}} records.property_type
{{feeCap}} From finder-regulations.js fee_cap_pct or fee_cap_alt

Example Templates

CA / responded

Hi {{firstName}},

Thanks for getting back to me. Here's how to claim your property through the
California State Controller's Office:

1. Go to {{claimUrl}}
2. Search for your name to find the specific claim
3. Click "Claim" next to the matching entry
4. Fill out the online claim form -- you'll need:
   - Your current address
   - Last 4 digits of your SSN
   - A copy of your photo ID (driver's license or passport)

The whole process is online and usually takes about 10 minutes. The State
Controller's Office processes claims within 90-180 days.

There is no fee for claiming directly through the state. If you run into any
issues, let me know and I can help walk you through it.

Best,
Joe

CA / forms_ready

Hi {{firstName}},

I've put together the steps for your California claim. Here's what you'll need:

Required documents:
- Government-issued photo ID
- Proof of current address (utility bill, bank statement)
- Social Security number (last 4 digits)

File your claim here: {{claimUrl}}

The Controller's Office typically processes claims in 90-180 days. You'll
receive a check by mail once approved.

Let me know if you have any questions or need help with any of the steps.

Best,
Joe

TX / responded

Hi {{firstName}},

Thanks for your reply. Here's how to claim your property through the Texas
Comptroller of Public Accounts:

1. Visit {{claimUrl}}
2. Search using your name as it appears on the record
3. Select the matching claim and click "Claim Property"
4. You'll need to verify your identity -- the Comptroller may request:
   - A notarized claim form
   - Copy of your driver's license or state ID
   - Proof of address

Texas claims can be filed online for amounts under $250. For larger amounts,
you may need to mail additional documentation. I can help you figure out
exactly what's needed for your specific claim.

Best,
Joe

TX / forms_ready

Hi {{firstName}},

Here are the details for filing your Texas unclaimed property claim:

Your claim amount: {{amount}}
Claim portal: {{claimUrl}}

For claims over $250 in Texas, you'll typically need:
- Completed claim form (I can send you the link)
- Notarized affidavit of identity
- Copy of photo ID (front and back)
- Proof of ownership or entitlement

The Comptroller's office processes most claims within 90 days of receiving
complete documentation.

Would you like me to send the specific form links? I can also walk you through
the notarization step if needed.

Best,
Joe

Generic / responded (default for states without specific templates)

Hi {{firstName}},

Thanks for getting back to me. Here's how to claim your {{propertyType}} from
{{stateName}}:

1. Visit the state's claim portal: {{claimUrl}}
2. Search for your name and locate the matching record
3. Follow the instructions to file a claim

Most states require some form of identity verification (photo ID, last 4 of
SSN). I can look up the specific requirements for {{stateName}} if you'd like.

The claim process is free when you go directly through the state. Let me know
if you have any questions or need help.

Best,
Joe

Regulation Data Integration

The finder-regulations.js file (at /Users/Joseph.Newbry@alaskaair.com/dev/dollar-hound/lib/finder-regulations.js) already contains structured data for all 50 states + DC. Key fields for template generation:

  • portal_url -- direct link to the state's search/claim portal
  • agency / agency_url -- the state office that handles claims
  • fee_cap_pct / fee_cap_alt -- fee limits (useful for reassuring recipients)
  • contract_required / contract_notes -- determines whether a written agreement is needed
  • waiting_period_months -- waiting periods that affect claim timing
  • notes -- freeform state-specific details

OpenClaw can query these via a new API endpoint or read them directly from memory after an initial seed.


9. Dashboard Integration

Three changes to the existing dashboard.

9.1 Notification Badge

Add a badge to the dashboard header showing the count of conversations needing attention (stage = responded or next_action_due overdue).

In views/dashboard.html, add next to the header title:

<span id="conv-badge" class="conv-badge" style="display:none"></span>

Populated by calling GET /api/conversations/stats on page load:

fetch('/api/conversations/stats')
  .then(r => r.json())
  .then(stats => {
    const urgent = (stats.responded || 0);
    if (urgent > 0) {
      document.getElementById('conv-badge').textContent = urgent;
      document.getElementById('conv-badge').style.display = 'inline-block';
    }
  });

9.2 Conversation Stage Column

Add a conversation_stage column to the records table in the dashboard. Uses the denormalized records.conversation_stage field. Render as a colored badge matching the existing status badge pattern (.status-badge classes in dashboard.html, lines 77-80).

Suggested badge colors:

Stage Background Text Color
responded #3a2a1a #ff9800 (orange -- attention needed)
gathering_info #2a2a3a #7986cb
forms_sent #1a2a3a #42a5f5
claim_in_progress #1a3a2a #66bb6a
paid #1a3a1a #4caf50 (green -- success)
stale #1a1a1a #555 (muted)

9.3 Conversation Detail View

A new view at views/conversation.html, following the two-pane layout pattern from views/review.html:

Left pane (320px, same as review.html): - Record info: name, amount, state, property type, email - Conversation metadata: stage badge, created date, last activity - Stage advancement buttons - Link to state claim portal

Right pane (flexible width): - Message thread (newest at bottom, chat-style) - Inbound messages: left-aligned, dark background - Outbound messages: right-aligned, green-tinted background - Each message shows: from, timestamp, body - Reply composer at bottom: textarea + send button - Template selector dropdown (populated from state-specific templates)

Route: GET /conversation/:id serves the HTML, data loaded via GET /api/conversations/:id.


10. Implementation Phases

Phase 1: Manual MVP

Goal: Schema exists, Message-IDs are captured, conversations can be created and queried via API. Manual inbox checking, but at least the data model is in place.

Task File(s) Effort
Add conversations + conversation_messages tables server.js (schema section, ~line 75) 30 min
Add message_id + conversation_stage columns to records server.js (ALTER TABLE section, ~line 172) 10 min
Capture info.messageId from nodemailer on send outbound/send.js (line 285), outbound/triage.js (line 337) 20 min
Add conversation API routes (CRUD + match-email) server.js (new route section) 2-3 hours
Add /api/conversations/stats endpoint server.js 30 min
Create conversation on send (optional) outbound/send.js, outbound/triage.js 30 min
Manually check inbox and create conversations via API/curl n/a ongoing

Phase 1 deliverable: You can send an email, its Message-ID is captured, and when a reply comes in you can manually curl POST /api/conversations/match-email to find the record, then POST /api/conversations to start tracking it.

Phase 2: OpenClaw Integration

Goal: Inbox monitoring is automated. Joe gets WhatsApp notifications when someone replies. Replies can be sent through the system.

Task File(s) Effort
Write claims-lifecycle OpenClaw skill skills/claims-lifecycle/SKILL.md 1-2 hours
Set up inbox monitoring cron (every 5 min) OpenClaw cron config 30 min
Add WhatsApp notification flow OpenClaw channel config 30 min
Add reply endpoint (POST /api/conversations/:id/reply) server.js 1 hour
Build conversation detail view views/conversation.html 2-3 hours
Add notification badge to dashboard views/dashboard.html 30 min
Add conversation_stage column to dashboard table views/dashboard.html 30 min
Seed state-specific templates into OpenClaw memory OpenClaw memory 1-2 hours

Phase 2 deliverable: OpenClaw checks the inbox every 5 minutes, matches replies to records, notifies Joe via WhatsApp, and Joe can reply through the system. Dashboard shows conversation status.

Phase 3: Automation Growth

Goal: Reduce operator involvement for routine interactions. LLM-personalized responses. Proactive follow-up.

Task Effort
Auto-acknowledgment replies ("Thanks for responding! Here's what happens next...") 1 hour
Stale conversation detection + auto-follow-up 1-2 hours
LLM-personalized responses using DGX Spark inference (same pattern as outreach.js chatCompletion()) 2-3 hours
Deeper research passes on responded claims (build person dossier, pre-fill forms) 3-5 hours
Auto-generate state-specific templates from finder-regulations.js data 2 hours
Conversation analytics (response rate, time-to-claim, revenue tracking) 2-3 hours

Phase 3 deliverable: Most routine interactions are handled automatically. Joe only gets involved for edge cases, name changes, complex multi-party claims, or when the agent is uncertain (stop-and-ask pattern).


11. Why This Architecture

Why not build everything natively in Dollar Hound?

Building inbox monitoring, cron scheduling, WhatsApp integration, and conversation AI directly into Dollar Hound would require:

  • imapflow or similar for IMAP inbox access (new dependency, connection management, error handling)
  • node-cron or systemd timers for scheduling (already managing dollar-hound.service in systemd, but cron-within-cron gets messy)
  • WhatsApp Business API integration (auth, webhooks, message formatting)
  • LLM orchestration for composing replies (already exists in outreach.js via chatCompletion(), but conversation context management is different from one-shot email generation)
  • Approval workflows (pause, notify human, wait for response, continue)

That is 500+ lines of new infrastructure code in Dollar Hound for capabilities that OpenClaw already has.

What OpenClaw already provides

Capability OpenClaw Status Dollar Hound Alternative
Email inbox monitoring Built-in channel integration Would need imapflow + connection pool
Cron scheduling Built-in, proven Would need node-cron or systemd timer
WhatsApp messaging Built-in channel Would need Business API integration
iMessage fallback Built-in channel Would need applescript bridge
LLM conversation management Built-in with memory Would need context window management
Approval workflows (stop-and-ask) Built-in pattern Would need custom state machine
Pattern learning over time Memory system Would need separate learning system

The skill is small

The OpenClaw skill is approximately 50 lines of markdown that describes what to do. OpenClaw handles how. Dollar Hound's contribution is the API surface -- well-defined endpoints that return JSON. Clean separation.

Dollar Hound stays focused

Dollar Hound is good at: - Fetching and importing unclaimed property records from state databases - Identity resolution via the people graph - Contact enrichment - Email composition and sending via sender pool - Serving a dashboard for operator review

It should continue to do those things. The claims lifecycle is an orchestration problem, not a data problem. The data lives in Dollar Hound (conversations, messages, stages), but the orchestration -- checking the inbox, routing messages, notifying humans, composing responses -- belongs in OpenClaw.


12. Key Files in Dollar Hound

File Role in Claims Lifecycle
server.js Schema definitions (line 75+), API routes. All new tables and endpoints go here. Currently has records, jobs, outreach_events, settlements tables.
outbound/send.js Email sending via sender pool. Line 285: transporter.sendMail() -- needs to capture info.messageId return value and store it on the record.
outbound/triage.js Interactive send triage. Line 337: same sendMail() change needed. Also needs to POST message_id to the API via mark-sent endpoint.
outbound/outreach.js Email composition patterns (composeNotificationBody, composeOutreachBody, buildComplianceHeaders). Reply templates should follow the same structure: greeting, body, signature, compliance footer.
lib/finder-regulations.js 50-state + DC regulation data. Contains portal_url, agency, agency_url, fee_cap_pct, contract_required, waiting_period_months for every state. This data drives template generation.
views/review.html Two-pane layout pattern (320px left pane + flexible right pane) that the conversation detail view should follow. Uses the same dark theme, status badges, and action button patterns.
views/dashboard.html Main dashboard. Needs notification badge in header, conversation_stage column in records table, and link to conversation detail view.
lib/source-metadata.js getClaimUrl() function and STATE_CATALOG -- provides claim portal URLs per state, complementing finder-regulations.js.
outbound/sender-pool.js Sender pool management (getNextSender, createTransporter, recordSend). The reply endpoint uses this same pool to send outbound conversation replies.