Skip to main content
Glama
README.md
# Follow-Through

**The MCP agent that remembers what everyone promised — so nothing quietly dies.**

Follow-Through is a TypeScript Model Context Protocol (MCP) server built on
[NitroStack](https://nitrostack.ai). It listens to meeting transcripts, extracts
every spoken commitment (who promised what, to whom, by when, and with what
confidence), persists it durably, and then keeps working autonomously: it polls
Slack, email, and Linear for real evidence of completion, sends escalating
nudges when deadlines slip, and escalates to a manager's inbox when a
commitment goes silent — all without a human having to re-prompt it.

For a demo, a compressed virtual clock (`simulate_days_passing`) makes a full
week of follow-up observable in seconds, and a companion widget renders the
live commitment board.

---

## Why this exists

Most action items from meetings never make it into a tracker. They live in a
transcript nobody re-reads, get half-remembered, and quietly expire. Follow-Through
closes that loop in three moves:

1. **Capture** — the moment a promise is spoken, it becomes a durable record
   with an auto-created Linear ticket.
2. **Verify** — instead of nagging blindly, the agent checks Slack and email for
   *evidence* that the work actually got done.
3. **Escalate gracefully** — if there's no evidence and no response, nudges get
   more specific, and a human manager is looped in with full context.

---

## Key features

| Feature | What it does |
|---|---|
| **Commitment extraction** | Parses transcripts into structured commitments via a pluggable LLM (Anthropic, OpenRouter) **or** a fully offline deterministic parser — same output shape either way. |
| **Confidence triage** | Each commitment is graded `committed` / `hedged` / `aspirational`. Aspirational wishes are never auto-chased. |
| **Durable store** | Pure-JS JSON-file persistence (atomic writes, serverless-safe) with a virtual clock, so the whole lifecycle can be simulated deterministically. |
| **Evidence-based "done"** | Completion is decided by real signal — a Slack message or email from the owner with matching keywords and a completion signal — not by assumption. |
| **Escalation ladder** | `open → nudged_1 → nudged_2 → escalated` with gentle-then-specific reminders, tuned per confidence tier. |
| **Linear integration** | Every commitment gets a ticket (LIN-48x); escalated tickets gain a manager watcher and a contextual comment. |
| **Live dashboard widget** | A Next.js widget auto-attached to `query_commitments` renders the commitment board with status, evidence, and nudge history. |
| **Demo controls** | `simulate_days_passing` and `reset_demo` compress a multi-day chase into seconds for a deterministic demo. |
| **Zero-cost default** | Runs fully offline with no API key; free OpenRouter models are optional. |

---

## How it works

```
                 ┌────────────────────────────────────────────────────────┐
   transcript    │                        App                             │
  ─────────────► │  ingestion ──► store ──► evidence ──► nudge ──► linear  │
                 │                     ▲                                 │
                 │                     │  scheduler (poll loop / clock)   │
                 │      widget ◄───────┴── query_commitments              │
                 └────────────────────────────────────────────────────────┘
```

### Modules

| Module | Responsibility |
|---|---|
| `ingestion` | Extract structured commitments from transcripts (LLM or offline). |
| `store` | JSON-file persistence: commitments, evidence/nudge logs, escalation state, virtual clock. |
| `evidence` | Search Slack/email for completion signal (real providers, with fixture fallback in demo mode) and score it against the commitment. |
| `nudge` | Compose and send reminders across Slack/email with tone control (real delivery, fixture/log in demo mode). |
| `linear` | Ticket lifecycle: create, status, escalate (with manager watcher + comment). |
| `scheduler` | The autonomous loop. Polls due commitments, checks evidence, advances the state machine. |

### The decision state machine

For each due, non-aspirational commitment, the scheduler runs every poll:

```
        due (+ grace)            +3d (committed)           +6d (committed)
 open ───────────────────► nudged_1 ─────────────► nudged_2 ─────────────► escalated
     gentle nudge                specific nudge             manager + watcher
```

At every step it first checks **evidence** (Slack/email) and **ticket status**
(Linear `Done`) — proof beats nagging. Nudges only happen when there is
neither evidence nor a completed ticket.

### Confidence tiers and cadence

| Tier | Example | Grace for nudge 1 | nudge 2 | escalate |
|---|---|---|---|---|
| `committed` | "I will ship the report by Friday" | at due date (0d) | +3d | +6d |
| `hedged` | "I'll *try* to get it done by Friday" | +2d | +5d | +10d |
| `aspirational` | "We should probably track error budgets" | never chased | — | — |

### Evidence scoring

`scoreEvidence()` in `src/common/matching.ts` combines three signals:

- **Recall** — what fraction of the commitment's key terms appear in the message
- **Completion signals** — words like *sent, published, shipped, delivered, merged*
- **Author match** — the message came from the commitment owner

A score ≥ `0.6` marks the commitment **done**. Keyword-only mentions cap at
`0.55` — deliberately below the bar, because a false "done" is worse than a
false nudge.

---

## Technology stack

- **Runtime**: Node.js ≥ 20 (ESM, TypeScript strict mode)
- **Framework**: NitroStack (`@nitrostack/core`) — decorator-based modules, DI, MCP server
- **Persistence**: Pure-JS JSON-file store (`data/follow-through.json`, atomic writes) — zero native dependencies, deploy-safe on any base image
- **Validation**: Zod
- **LLM**: `fetch`-based calls (no heavy SDK) — Anthropic Messages API or OpenRouter chat-completions
- **Widgets**: Next.js 14 + React 18 (`@nitrostack/widgets`)

---

## Project structure

```
src/
  app.module.ts                 # root module — wires all six modules
  common/
    types.ts                    # Commitment, Person, Ticket, evidence/nudge types
    dates.ts                    # date math + virtual-today helpers
    matching.ts                 # evidence scoring + thresholds
  modules/
    ingestion/                  # extract_commitments + sample transcript fixture
    store/                      # JSON-file store + virtual clock + query/upsert/promote
    evidence/                   # search_slack_evidence / search_email_evidence
    nudge/                      # send_nudge
    linear/                     # linear_create_ticket / get_status / update_status / escalate
    scheduler/                  # simulate_days_passing / reset_demo + poll loop
  providers/
    slack.ts                    # real Slack evidence search + DM nudges (Slack Web API)
    email.ts                    # real SMTP send + IMAP evidence search
    linear.ts                   # real Linear GraphQL client
  widgets/app/commitment-dashboard/  # the live dashboard widget (Next.js)
scripts/
  e2e-smoke.mts                 # full-lifecycle regression test (30+ asserts)
  extract-sample.mts            # one-liner demo: sample transcript → extract_commitments
  check-state.mts               # dump live server truth (commitments + ticket statuses)
  providers-check.mts           # which real providers/LLM keys are configured
  live-check.mts                # live create/read/update against real Linear + Slack/email search
```

---

## Getting started

### Prerequisites

- Node.js ≥ 20 (developed on 24.15.0)
- npm

### Install

```bash
npm install
npm run build
```

The build compiles TypeScript to `dist/` and bundles the widget to
`src/widgets/out/`.

### Configuration

Copy `.env.example` to `.env`. Everything has sane defaults; no key is required.

| Variable | Default | Purpose |
|---|---|---|
| `LLM_PROVIDER` | `auto` | `auto` \| `anthropic` \| `openrouter` \| `none` |
| `ANTHROPIC_API_KEY` | — | Enables Claude-based extraction (paid) |
| `ANTHROPIC_MODEL` | `claude-sonnet-4-6` | Claude model |
| `OPENROUTER_API_KEY` | — | Enables free-tier OpenRouter extraction |
| `OPENROUTER_MODEL` | `meta-llama/llama-3.1-8b-instruct:free` | Free OpenRouter model |
| `SCHEDULER_INTERVAL_MS` | `3600000` | Real-world poll cadence (production) |
| `DATA_DIR` | `<cwd>/data` | Writable volume for the JSON store (falls back to temp, then in-memory) |
| `SLACK_BOT_TOKEN` | — | Enables real Slack evidence search + DM nudges |
| `SLACK_EVIDENCE_CHANNELS` | — | Comma-separated channels to search for evidence |
| `EMAIL_SMTP_HOST/USER/PASS` | — | Enables real outbound nudges by email |
| `EMAIL_IMAP_HOST/USER/PASS` | — | Enables real IMAP evidence search |
| `LINEAR_API_KEY` | — | Enables real Linear ticket lifecycle |
| `LINEAR_TEAM_ID` | — | Linear team for ticket creation (auto-detected if omitted) |

**Provider resolution (`auto`):** uses Anthropic if its key is set, else
OpenRouter if its key is set, else the **offline deterministic extractor**.
Any LLM failure falls back to the offline extractor — the demo never breaks.

**Demo vs. real mode:** when none of `SLACK_BOT_TOKEN`, `EMAIL_IMAP_*`, or
`LINEAR_API_KEY` are set, the server runs in demo mode and all Slack/email/
Linear traffic is fixture-based. Configure any subset and only those providers
go real — the rest keep their fixtures. Run `npx tsx scripts/providers-check.mts`
to see which mode you're in.

> **No key? No problem.** The deterministic extractor produces the same
> structured output from the same tool schema with zero network and zero cost.

---

## Running

### Development (recommended for demoing)

```bash
npm run dev
```

- MCP server runs over **stdio** (wait for a client, see [Connect a client](#connect-a-client))
- Widget dev server runs on **http://localhost:3001** with hot reload
  (route: `http://localhost:3001/commitment-dashboard`)

### Production

```bash
npm run build
npm start
```

Boots in **dual mode** — stdio **and** HTTP at `http://localhost:3000/mcp`.

---

## Demo walkthrough

The sample transcript (`mtg_ops_standup`) contains four commitments designed
to exercise every path: a completion, a slacker, a hedged promise, and an
aspirational wish.

```powershell
# 0. Clean slate (virtual "today" = real today, e.g. 2026-07-31)
reset_demo

# 1. Extract commitments from the sample transcript → 4 commitments + 4 Linear tickets
get_sample_transcript
extract_commitments  { transcript_text, participants, meeting_date }

# 2. Fast-forward 3 days (→ Aug 3, the first due date)
simulate_days_passing  { days: 3 }
#    Priya  → done_evidence      (Slack + email show the report was sent)
#    Marcus → nudge_1            (gentle nudge, no evidence yet)

# 3. Fast-forward 3 days (→ Aug 6)
simulate_days_passing  { days: 3 }
#    Marcus → nudge_2            (more specific nudge)

# 4. Fast-forward 3 days (→ Aug 9)
simulate_days_passing  { days: 3 }
#    Marcus → escalated          (ticket watcher: raj.patel@company.com + context comment)
#    Aisha  → nudge_1            (hedged — grace period meant she wasn't chased early)
#    Tom    → open, untouched    (aspirational — never auto-chased)

# 5. Inspect the final board
query_commitments
# 6. Prove the escalation stuck to the ticket
linear_get_status  { ticket_id: "LIN-482" }   # → Escalated, watchers: [...]
```

For a browser visual, open the dashboard widget (see below) — it reflects the
same board.

---

## Tools

All 14 tools registered on the `follow-through` MCP server:

| Tool | Module | Purpose |
|---|---|---|
| `get_sample_transcript` | ingestion | Ready-to-use demo transcript + roster |
| `extract_commitments` | ingestion | Parse transcript → commitments, records, tickets |
| `upsert_commitment` | store | Insert/update a commitment record |
| `query_commitments` | store | Query the board (status filters; widget-attached) |
| `promote_commitment` | store | Raise a commitment's confidence tier |
| `search_slack_evidence` | evidence | Search Slack for completion signal (real or fixtures) |
| `search_email_evidence` | evidence | Search email for completion signal (real or fixtures) |
| `send_nudge` | nudge | Send a reminder (tone, channel, message) |
| `linear_create_ticket` | linear | Create a Linear ticket |
| `linear_get_status` | linear | Read ticket status, watchers, escalation comment |
| `linear_update_status` | linear | Change ticket status |
| `linear_escalate` | linear | Escalate a ticket to a manager |
| `simulate_days_passing` | scheduler | Advance virtual clock + run one poll |
| `reset_demo` | scheduler | Wipe state, reset clock |

---

## Widget dashboard

`query_commitments` is auto-attached to a Next.js widget
(`ui://widget/next-commitment-dashboard.html`). Inside an MCP host it renders a
theme-aware board: owner, commitment, due date, confidence, status badge,
evidence trail, and nudge count.

Standalone (no host injecting data) the page shows only a loading shell — the
data is injected by the host when the tool runs.

---

## Testing

```bash
npm run build
npx tsx scripts/e2e-smoke.mts
```

Drives the real server over the MCP stdio protocol and asserts 30+ invariants:
extraction shape, confidence tiers, due-date resolution, nudge timing, hedged
grace, aspirational immunity, evidence thresholds, escalation watcher, and
manual nudges. Ends with `ALL CHECKS PASSED`.

### Verify what the agent reports (before the demo, and anytime a tool call fails)

If a client like Claude.ai ever reports a connector error mid-demo, never assume
the answer that followed came from the server — re-run the tools live and compare:

```bash
npm run build
npx tsx scripts/check-state.mts
```

This dumps the server's actual current commitments and Linear ticket statuses
(each ticket includes `as_of`, the server-side date the status was read at), so
anything the agent quoted can be checked against ground truth. Real tool
responses always carry server data (`updated_at`, `as_of`, ticket ids); anything
that doesn't match a fresh run is client-side reconstruction, not a result.

**Demo etiquette:** if a tool call visibly fails, say "let me retry that" and
call it again instead of continuing — a visibly retried tool beats a confidently
wrong number. Connector errors ("Connector search is off", "Unable to reach …")
are almost always Claude.ai's connector settings: open **Settings → Connectors**,
confirm the FollowThrough connector is enabled/approved, and re-approve it for the
current conversation (approval is per-chat, so a new chat needs it again).

### Simulation semantics

`simulate_days_passing` takes a single `days` number and advances the virtual
clock by exactly that many days in one call (then runs one scheduler poll). So
`{ days: 9 }` jumps straight to due+9 in a single step — it is not three 3-day
calls. The demo shows a cleaner narrative with separate calls (3 → 3 → 3), which
also makes each nudge/escalation stage observable as it happens, but the tool
never requires them.

---

## Connect a client

Point any MCP client (Claude Desktop, Cursor, etc.) at the built server:

```json
{
  "mcpServers": {
    "follow-through": {
      "command": "node",
      "args": ["<path-to>/dist/index.js"],
      "cwd": "<path-to>"
    }
  }
}
```

Or hit the HTTP endpoint in dual/production mode: `http://localhost:3000/mcp`.
The CLI can also wire up Cursor automatically: `npx nitrostack-cli cursor`.

---

## Production considerations

- **Real integrations, optional.** Slack (`src/providers/slack.ts`), email
  (`src/providers/email.ts`), and Linear (`src/providers/linear.ts`) are plain
  env-driven clients. Without keys they fall back to the deterministic fixtures,
  so the demo and the production path are the same code.
  - **Slack** requires a bot token with `channels:read`, `groups:read`,
    `channels:history`, `groups:history`, `im:history`, `users:read`,
    `users:read.email`, `chat:write`. The `*:read` scopes resolve channel
    names in `SLACK_EVIDENCE_CHANNELS` to ids (names or ids both accepted);
    the bot must be added to every channel it searches. Nudges DM the
    commitment owner.
  - **Email** uses SMTP (`EMAIL_SMTP_*`) for outbound nudges and IMAP
    (`EMAIL_IMAP_*`) to search mail for evidence, parsed with `mailparser`.
  - **Linear** uses the GraphQL API (`LINEAR_API_KEY`); escalations add the
    manager as a ticket subscriber plus a contextual comment.
- **Real cadence.** Set `SCHEDULER_INTERVAL_MS` to the desired poll rate and
  remove `simulate_days_passing` / `reset_demo` from the tool surface if you
  don't want them exposed.
- **LLM cost.** Extraction is the only LLM call. It runs once per transcript;
  the polling/nudge/escalation machinery is pure code. Use OpenRouter `:free`
  models or the offline extractor to keep cost at $0.
- **Transport.** Production boots in dual mode (stdio + HTTP). OAuth logs at
  startup are framework noise unless you configure an authorization server.

---

## Troubleshooting

| Symptom | Fix |
|---|---|
| `EADDRINUSE :3000` on start | A stale server is still running — kill it (netstat/`Stop-Process`) and retry. |
| Widget missing on startup | Run `npm run build` once so `query_commitments` can register its bundled component. |
| Demo stuck on old dates | `reset_demo`, or delete `data/follow-through.json` (regenerated on boot). |
| `EACCES` / read-only fs on NitroCloud | Expected on serverless hosts — the store auto-falls back to the OS temp dir, then in-memory only (look for `[StoreService]` notes in logs). Set `DATA_DIR` if your platform offers a writable volume. |
| Slack logs `missing_scope` | The bot token lacks `channels:read` / `groups:read`, so channel names in `SLACK_EVIDENCE_CHANNELS` can't be resolved to ids. Add those scopes in the Slack app (OAuth & Permissions), reinstall the app to regenerate the token, and add the bot to each channel it should search. |
| Linear logs `Unknown argument ...` | The `LINEAR_API_KEY` is older than the app code — regenerate it so the app's `Issue`/`users(filter:)` GraphQL calls are accepted. |
| `llm_provider: "offline"` | The LLM call failed or no key is set — check `.env` and that the provider is reachable. |
| `:3001` serves a 404 at `/` | The dashboard lives at `/commitment-dashboard`; a stale `src/widgets/.next` can stall it — delete it and restart. |

---

## License

MIT — see the repository root. Built for demonstration on the NitroStack MCP
framework.

TDQS

A4/5.0

Scored across 14 tools

Disambiguation5/5

Each tool targets a distinct action or resource: extraction, store CRUD, query, nudge, escalation, evidence search, and demo controls are clearly separated. Even the Linear tools are distinct (create, read, update, escalate) with no overlapping purposes.

Naming Consistency5/5

All tool names follow a consistent verb-first snake_case pattern (e.g., extract_commitments, query_commitments, linear_update_status). The linear_ prefix is used uniformly for Linear-related tools, and the rest follow standard verb_noun naming with no style mixing.

Tool Count5/5

With 14 tools, the set is well-scoped for the commitment lifecycle domain—covering extraction, tracking, nudging, escalation, evidence gathering, and demo resets. Each tool serves a necessary role, and the count sits comfortably within the ideal range.

Completeness5/5

The tool surface covers the full workflow from commitment extraction to follow-up, escalation, evidence verification, and closure via Linear status updates. Demo utilities are intentionally separated, and there are no obvious dead ends for the stated purpose.

Maintenance

ActivitySlowing
ResponsivenessNo issues