# How Reply-Watch Classifies Employer Email Replies and Updates the Tracker in Career-Ops

> Learn how Reply-Watch in santifer/career-ops uses a keyword state machine to classify employer email replies and suggest tracker updates for your approval.

- Repository: [Santiago Fernández de Valderrama/career-ops](https://github.com/santifer/career-ops)
- Tags: how-to-guide
- Published: 2026-08-20

---

**Reply-watch mode uses a deterministic keyword-based state machine in `reply-matcher.mjs` to classify employer replies into categories like Interview, Offer, Rejected, or Noise, then suggests tracker updates that require human approval before applying.**

The `reply-watch` mode in santifer/career-ops transforms raw mailbox data into actionable tracker updates through a three-phase pipeline. This article breaks down exactly how the classification logic works, what signals it prioritizes, and how the human-in-the-loop approval mechanism prevents accidental data corruption.

## Architecture Overview

The reply-watch system processes employer emails through three distinct phases orchestrated by `reply-watch.mjs` with core logic residing in `reply-matcher.mjs`.

### Phase 1: Load and Match

The pipeline begins with candidate loading and fuzzy matching against existing tracker entries.

- **Input**: [`data/reply-candidates.json`](https://github.com/santifer/career-ops/blob/main/data/reply-candidates.json) (or user-supplied path)
- **Tracker sources**: [`data/applications.md`](https://github.com/santifer/career-ops/blob/main/data/applications.md) and [`data/follow-ups.md`](https://github.com/santifer/career-ops/blob/main/data/follow-ups.md)
- **Matching function**: `matchCandidates` builds text context from `from` + `subject` + `body_snippet`

The matcher scores candidates against tracker rows using three weighted signals:

| Signal Combination | Confidence Level |
|---|---|
| Company name + Role title | High |
| Sender domain + Role title | High |
| Single signal only | Medium/Low |

Domain safety measures exclude `SHARED_DOMAINS` (LinkedIn, Greenhouse, Gmail, etc.) to prevent false matches. Short company names (≤3 characters) require word-boundary matching to avoid substring collisions like "HP" inside "PHP".

### Phase 2: Classification via Deterministic State Machine

For matched candidates, `classifyReply` executes a strict precedence-ordered keyword check in `reply-matcher.mjs`:

1. **Noise** — Marketing-alert keywords match first; no tracker action suggested
2. **Rejected** — Rejection keywords or `signal: 'rejection'` override all; suggests `Rejected` status
3. **Offer** — Offer phrases ("offer letter", "congratulations on the offer"); suggests `Offer` status
4. **Auto-confirmation** — Generic "application received" strings; no tracker action
5. **Need Action** — Scheduling or task requests; suggests `Interview` if scheduling detected, else `Responded`
6. **Interview** — Interview invite keywords including Chinese "邀您面试" or `signal: 'interview_invite'`; suggests `Interview` status
7. **Responded** — Follow-up style phrases; suggests `Responded` status
8. **Unknown** — Recruiting-related terms present but unclear; flags "Needs Review"
9. **Fallback** — Everything else defaults to "Needs Review"

**Critical precedence rule**: Rejection beats offer, noise beats everything. No LLM inference occurs—all decisions are rule-based.

### Phase 3: Human-in-the-Loop Update

Classification outputs aggregate into a digest presented to the user. The `updateTrackerStatuses` function in `reply-watch.mjs` only executes after explicit "y"/"yes" confirmation:

- Acquires file lock on [`data/applications.md`](https://github.com/santifer/career-ops/blob/main/data/applications.md)
- Rewrites only rows where current status ≠ recommended status
- Runs `node tracker.mjs sync` to refresh SQLite index

No mutations occur without user approval regardless of classification confidence.

## Internationalization Support

The classifier handles non-English content through `normalizeChinese` in `reply-matcher.mjs`:

- Strips corporate suffixes: "有限公司", "公司"
- Enables substring matching on Chinese characters (space-agnostic)
- Recognizes interview invitation patterns like "邀您面试"

## Running Reply-Watch Mode

Basic invocation with default candidates file:

```bash
node reply-watch.mjs

```

Custom candidates file path:

```bash
node reply-watch.mjs /path/to/my-replies.json

```

## Example Candidate Input and Output

Sample [`reply-candidates.json`](https://github.com/santifer/career-ops/blob/main/reply-candidates.json) structure:

```json
[
  {
    "message_id": "msg1",
    "from": "hr@awesome-tech.com",
    "subject": "Interview invitation – Senior Engineer",
    "body_snippet": "We'd like to schedule a video interview next week.",
    "signal": "interview_invite"
  },
  {
    "message_id": "msg2",
    "from": "noreply@job-alerts.com",
    "subject": "New opportunities for you",
    "body_snippet": "Check out these openings…",
    "signal": null
  }
]

```

Generated digest:

```

Today: 2 application updates need review

1. Awesome Tech — Senior Engineer
   Type: Interview
   Signal: interview_invite
   Evidence: interview invitation; schedule an interview
   Suggested tracker update: Interview

2. job-alerts.com
   Type: Noise
   Evidence: new opportunities for you
   Suggested tracker update: none

```

Confirming "y" rewrites the Awesome Tech entry from `Applied` to `Interview` in the tracker.

## Key Source Files

| File | Purpose |
|---|---|
| `reply-watch.mjs` | Orchestrates loading, matching, classification, and conditional tracker updates |
| `reply-matcher.mjs` | Implements `matchCandidates`, domain/company matching, and `classifyReply` state machine |
| [`modes/reply-watch.md`](https://github.com/santifer/career-ops/blob/main/modes/reply-watch.md) | User documentation for inputs, invocation, and workflow |

## Summary

- **Deterministic classification** via precedence-ordered keyword matching in `reply-matcher.mjs`—no LLM dependency
- **Multi-signal matching** using company name, sender domain, and role title with safety guards for shared domains and short names
- **Strict precedence hierarchy**: Noise → Rejected → Offer → Auto-confirmation → Need Action → Interview → Responded → Unknown
- **Mandatory human approval** before any [`data/applications.md`](https://github.com/santifer/career-ops/blob/main/data/applications.md) mutation via `updateTrackerStatuses`
- **Chinese language support** through normalized substring matching

## Frequently Asked Questions

### Does reply-watch use AI or machine learning to classify emails?

No. The classification relies entirely on deterministic keyword matching and explicit signal flags. The `classifyReply` function in `reply-matcher.mjs` uses static keyword lists with hardcoded precedence rules, making behavior predictable and debuggable without external API dependencies.

### What prevents reply-watch from matching the wrong application entry?

Three safeguards operate in `matchCandidates`: excluded `SHARED_DOMAINS` prevent generic email providers from skewing results; company names of three characters or fewer require word-boundary matching; and scoring requires multiple signal alignment for high-confidence matches. Low-confidence matches surface for user review rather than auto-association.

### Can reply-watch automatically update the tracker without asking me?

No. Even with perfect signal alignment (e.g., explicit `interview_invite` flag), `updateTrackerStatuses` in `reply-watch.mjs` requires explicit "y" or "yes" input. The file-lock mechanism only activates after confirmation, and the sync command runs post-rewrite to maintain index consistency.

### How does the system handle non-English employer replies?

The `normalizeChinese` function in `reply-matcher.mjs` preprocesses Chinese text by removing common corporate suffixes and enabling character-level substring matching. Specific interview invitation patterns like "邀您面试" are directly encoded in the classification rules alongside English equivalents.