Skip to content

Repository files navigation

TenantShield

Demand letters that cite the right clause by number, then chase the landlord until they answer.

Built for the Convex All Gas Hackathon (sponsored by OpenAI, Firecrawl, AgentMail).

Live: https://confident-bass-919.convex.site · Worked example: /demo


What & Why

You have a landlord who won't fix the mold, the heat, the leaks. You know they're breaking the law — but you don't know which law, or what section number to cite. Googling "NYC tenant rights" gives you a wall of PDFs. A sternly-worded email won't make them blink. And even if you send one, chasing it is a part-time job nobody has time for.

TenantShield is the advocate you can't afford to hire. You tell it what happened. It reads the actual tenant-rights code for your city, pulls the operative clauses with their section numbers, drafts a demand letter that cites each clause by reference, and chases the landlord by email until they answer — with a hard cap so it never crosses the line into harassment.

The point: almost nobody will read the actual statute and pull the operative sentence. TenantShield does. Then it does the follow-up.


How it works

┌─────────┐    ┌──────────┐    ┌────────┐    ┌───────┐    ┌────────┐    ┌──────────┐
│ Intake  │ →  │ Research │ →  │ Extract│ →  │ Draft │ →  │ Send   │ →  │ Chase    │
│ (human) │    │(Firecrawl)│   │ (AI)   │    │ (AI)  │    │(AgentMail)│  │ (cron)  │
└─────────┘    └──────────┘    └────────┘    └───────┘    └────────┘    └──────────┘
                                                                             │
                                          ┌──────────┐                       │
                                          │ Settle   │ ←─────────────────────┘
                                          │ (reply)  │
                                          └──────────┘
  1. Intake. Tenant opens a case: title, both parties, city, what happened, what they want, and (optionally) a URL to the landlord's tenant handbook.
  2. Research. Convex scheduler fires Firecrawl — search for the city's tenant-rights code, top hits scraped; plus any landlord-published policy URL.
  3. Clause extraction. OpenAI walks each crawled page, returns 3-12 citeable provisions. Each clause is embedded (1024-dim) and stored in a vector index keyed by caseId. letters.rankClauses embeds the tenant's issue and returns clauses in vector-similarity order.
  4. Letter draft. Tenant picks the clauses to cite (sorted by relevance). OpenAI drafts a 250-450 word demand letter with explicit inline clauseNumber citations, a concrete remedy, and a deadline.
  5. Shared inbox. provisionCaseInbox attaches the deployment's shared AgentMail inbox (SHARED_INBOX_ID) to the case — inbox-scoped keys can't create inboxes, so every outbound carries a [TS-<caseid>] tag + per-case labels; inbound is matched by thread → subject → single-case fallback.
  6. Send + chase. Letter is sent from the case inbox. A chase row is seeded; a 30-min cron chase/sweep watches for due follow-ups (4, 9, 14 days), drafts a short follow-up in a deliberate voice (firm → escalate → final_notice), and sends it. Hard cap of 3 follow-ups; after that the case is exhausted.
  7. Reply parsing. When the landlord replies, AgentMail's signed webhook hits /agentmail/webhook. The component dispatches our onMessageReceived mutation, which inserts the message, pauses the chase, and schedules parseReplyCommitment.
  8. Commitment detection. OpenAI classifies the reply as concession | acknowledgement | refusal | stall | question. A concession pauses the chase and writes a settlements row with the landlord's own quoted commitment as evidence.
  9. Reactive UI. Every state change is a Convex query, so the case page updates live.

Architecture

flowchart TB
    UI[React 19 frontend<br/>Vite + Tailwind + Router<br/>live Convex subscriptions] <--> CX[Convex backend<br/>confident-bass-919]

    CX --> Q[Queries + mutations<br/>cases · letters · chase]
    CX --> A[Actions<br/>policies · extractClauses · draft<br/>sendLetter · onMessageReceived]
    CX --> SCHED[Scheduler<br/>runAfter chaining<br/>cron chase/sweep · 30min]
    CX --> VEC[Vector index by_embedding<br/>1024-dim clause embeddings<br/>rankClauses]
    CX --> DB[(8 tables · 12 indexes<br/>cases · policySources · clauses<br/>letters · chase · threads · settlements)]
    CX --> HTTP[HTTP actions<br/>/healthz · /agentmail/webhook]

    CX --- FC[Firecrawl component] --> FAPI[Firecrawl API<br/>search + scrape city code]
    CX --- AM[AgentMail component] --> AAPI[AgentMail<br/>shared inbox · send · inbound webhook]
    A --> LLM[OpenAI SDK · DGrid gateway default<br/>extract · draft · follow-ups · reply parsing]

    FAPI --> SRC[policySources<br/>markdown trimmed to 60k chars]
    SRC --> LLM
    AAPI --> HTTP

    style VEC fill:#1d4ed8,color:#fff
Loading

Everything reactive: every state change is a Convex query, so the case page updates live with no polling. Every AI handoff is scheduler-chained (runAfter), so a case never stalls mid-pipeline — and every AI step records its model in cases.model for judging transparency.


Tech stack

Layer Choice
Backend, DB, real-time, scheduling, vector index, HTTP actions, components Convex
Frontend React 19, Vite, Tailwind, React Router
Hosting Convex static hosting (*.convex.site)
Crawling and extraction Firecrawl (@firecrawl/firecrawl-convex component)
Email identity, send, and inbox AgentMail (@agentmail/convex component, one shared inbox per deployment)
Clause extraction, letter drafting, reply parsing, embeddings OpenAI SDK with a provider toggle: default DGrid AI Gateway (deepseek/deepseek-chat-v3.1, qwen/text-embedding-v4 1024-dim); LLM_PROVIDER=openai for gpt-4o-mini + text-embedding-3-small (truncated to 1024 dims)

Convex depth (judging criteria)

  • 8 tables, 12 indexes, plus a 1024-dim vector index over extracted clauses
  • 1 cron (chase/sweep, every 30 min) plus ctx.scheduler.runAfter for inbound-triggered follow-ups
  • HTTP actions: /healthz, /agentmail/webhook, plus component-mounted webhooks for Firecrawl and AgentMail
  • Case isolation on a shared inbox: one AgentMail inbox per deployment, [TS-<caseid>] tags + per-case labels outbound, threadId → subject → single-case fallback inbound
  • Live reactive UI: every page is a Convex subscription; no polling, no manual refresh
  • Every AI step records its model in cases.model, so a judge can see exactly which backend produced a letter

Sponsor coverage

  • Firecrawl. ingestCityCode does both a search and follow-up scrapes; ingestLandlordPage scrapes tenant-supplied URLs. Markdown is stored directly in policySources and trimmed to 60k chars so dense code pages don't blow the LLM context.
  • AgentMail. One shared inbox per deployment (inbox-scoped keys can't provision), labeled outbound (demand-letter, followup-1/2/3 for easy filtering) with [TS-<caseid>] tags. The component's webhook ingest is wired in convex/http.ts and runs onMessageReceived, which inserts + schedules reply parsing. The chase action reuses the component's send path.
  • OpenAI. Four distinct prompts: clause extraction, letter drafting, follow-up drafting, reply-commitment parsing. One embedding model for the clause index, plus rankClauses vector search for relevance ordering. Model is centralized in convex/ai.ts so it can be swapped in one place. Default is DGrid (DeepSeek + Qwen); LLM_PROVIDER=openai switches to real OpenAI with no code change.

Challenges we ran into

  1. AgentMail inbox-scoped keys can't create inboxes. Keys shaped am_us_inbox_… lack inbox_create, and the component's inbox management actions are internal-only. We pivoted to a shared-inbox architecture: one inbox per deployment, logical isolation via thread-first matching + per-case labels. The code documents this honestly in convex/emails.ts so future maintainers don't go down the same rabbit hole.

  2. Scheduler-chained extraction. Cases were stalling at researching with sources but zero clauses. The fix: every policySources insert must await ctx.scheduler.runAfter(0, api.letters.extractClauses, …). Without this, the case never progresses — the most common "it ran but nothing happened" shape on this stack.

  3. Vector search dimension mismatch. The by_embedding index is hardcoded at 1024 dims. Real OpenAI's text-embedding-3-small returns 1536 by default — we pass dimensions: 1024 explicitly in ai.ts. Judges checking the embedding dims against the schema will see they match.

  4. Embedding vectors in API responses. A 1024-dim vector is ~8KB of JSON per row. rankClauses strips the embedding before returning ranked results, or every ranking call ships megabytes. The clause picker in CaseDetail.tsx uses _score from the vector search, not the raw vector.

  5. Status machine transitions. Actions can't ctx.db.patch — they call internal.letters_internal.setStatus. A missing transition looks like a dead frontend when the backend actually succeeded. Explicit setStatus calls at each handoff.

  6. Inbound matching on a shared inbox. inbox_id alone is ambiguous when many cases share one inbox. Thread-first (pinned agentmailThreadId) → subject containment → single-case fallback. We scan the threads table (small) for a pinned match before falling back to subject matching.


Vision & roadmap

TenantShield is a working MVP for the Convex All Gas hackathon, but the shape is general:

  • Near term. Per-case inboxes (when AgentMail's org-scoped key lands and component paths go public — the REST path is documented in emails.ts as a one-line restore), attachment handling for photo evidence, SMS notification on concession/exhaust.
  • Beyond. Multi-jurisdiction cases (state + city + county codes simultaneously), escalation track that drafts an HP action filing for Housing Court, open API so tenant-rights orgs can run their own deployments, landlord-side view that shows the cited clauses and their own quoted SLA.
  • Protocol. The schema is a general "citable-provision + demand-letter + chase" primitive. Any domain where you need to cite a source and follow up — insurance claims, warranty disputes, small-claims pre-filing — can reuse the same tables with a different prompt set.

Success metrics

As of September 2026:

  • Live deployment at confident-bass-919.convex.site, 200 OK on / and /demo
  • Firecrawl integration verified: city code search + scrape returns 3-4 sources per case in <10s
  • OpenAI extraction verified: 9 clauses surfaced from 4 crawled pages, embedded and ranked
  • End-to-end test case created on prod: intake → research → extraction → ready_to_send, all via Convex reactive UI with no manual refresh

Files

tenantshield/
├── convex/
│   ├── schema.ts              # 8 tables, 12 indexes, vector index
│   ├── convex.config.ts       # mounts firecrawl + agentmail + staticHosting
│   ├── crons.ts               # 30-min chase sweep
│   ├── ai.ts                  # openai client + LLM_PROVIDER toggle + embedding helper
│   ├── policies.ts            # firecrawl actions: city code + landlord pages
│   ├── policies_internal.ts   # internal mutations for policySources
│   ├── letters.ts             # openai: extract, draft, parse reply, rankClauses (vector search)
│   ├── letters_internal.ts    # internal queries/mutations + setModel for letters flow
│   ├── emails.ts              # agentmail: shared-inbox attach (action)
│   ├── emails_internal.ts     # getCaseInbox / saveInbox
│   ├── emails_send.ts         # sendLetter, doSendFollowup, onMessageReceived
│   ├── chase.ts               # sweep (internalAction) → calls chase_internal
│   ├── chase_queries.ts       # dueChases, exhaust, loadChase
│   ├── chase_internal.ts      # sendFollowup + draft follow-up text
│   ├── cases.ts               # public mutations + queries
│   └── http.ts                # /healthz, /agentmail/webhook, static catch-all
├── src/
│   ├── main.tsx               # convex provider + router; /demo route (no keys needed)
│   ├── App.tsx                # case list + entry points
│   ├── pages/{NewCase,CaseDetail,Demo}.tsx
│   ├── components/StatusPill.tsx
│   └── index.css
├── public/shield.svg
├── index.html
├── tailwind.config.js
├── vite.config.ts
├── tsconfig.json
├── package.json
├── README.md
└── hackathon.md               # build log for judges

Live URLs

Setup (local dev)

npm install
npx convex dev           # first run logs you in, creates deployment
npm run dev              # vite

Set env vars in the Convex dashboard (Settings → Environment Variables):

OPENAI_API_KEY            sk-...
FIRECRAWL_API_KEY         fc-...
AGENTMAIL_API_KEY         am-...
AGENTMAIL_WEBHOOK_SECRET  whsec-...
# optional: real OpenAI instead of the DGrid gateway
LLM_PROVIDER              openai

Point the AgentMail dashboard webhook at https://<your-deployment>.convex.site/agentmail/webhook.

Deploy

npm run build
npx convex deploy --yes
npx @convex-dev/static-hosting deploy --skip-convex   # upload dist/

License

MIT — see LICENSE. Not a substitute for a lawyer — see the footer in-app.

About

Demand letters that cite the right clause by number, then chase the landlord until they answer

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages