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
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.
┌─────────┐ ┌──────────┐ ┌────────┐ ┌───────┐ ┌────────┐ ┌──────────┐
│ Intake │ → │ Research │ → │ Extract│ → │ Draft │ → │ Send │ → │ Chase │
│ (human) │ │(Firecrawl)│ │ (AI) │ │ (AI) │ │(AgentMail)│ │ (cron) │
└─────────┘ └──────────┘ └────────┘ └───────┘ └────────┘ └──────────┘
│
┌──────────┐ │
│ Settle │ ←─────────────────────┘
│ (reply) │
└──────────┘
- Intake. Tenant opens a case: title, both parties, city, what happened, what they want, and (optionally) a URL to the landlord's tenant handbook.
- Research. Convex scheduler fires Firecrawl —
searchfor the city's tenant-rights code, top hitsscraped; plus any landlord-published policy URL. - 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.rankClausesembeds the tenant's issue and returns clauses in vector-similarity order. - Letter draft. Tenant picks the clauses to cite (sorted by relevance). OpenAI drafts a 250-450 word demand letter with explicit inline
clauseNumbercitations, a concrete remedy, and a deadline. - Shared inbox.
provisionCaseInboxattaches 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. - Send + chase. Letter is sent from the case inbox. A
chaserow is seeded; a 30-min cronchase/sweepwatches 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 isexhausted. - Reply parsing. When the landlord replies, AgentMail's signed webhook hits
/agentmail/webhook. The component dispatches ouronMessageReceivedmutation, which inserts the message, pauses the chase, and schedulesparseReplyCommitment. - Commitment detection. OpenAI classifies the reply as
concession | acknowledgement | refusal | stall | question. A concession pauses the chase and writes asettlementsrow with the landlord's own quoted commitment as evidence. - Reactive UI. Every state change is a Convex query, so the case page updates live.
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
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.
| 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) |
- 8 tables, 12 indexes, plus a 1024-dim vector index over extracted clauses
- 1 cron (
chase/sweep, every 30 min) plusctx.scheduler.runAfterfor 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
- Firecrawl.
ingestCityCodedoes both asearchand follow-upscrapes;ingestLandlordPagescrapes tenant-supplied URLs. Markdown is stored directly inpolicySourcesand 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/3for easy filtering) with[TS-<caseid>]tags. The component's webhook ingest is wired inconvex/http.tsand runsonMessageReceived, 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
rankClausesvector search for relevance ordering. Model is centralized inconvex/ai.tsso it can be swapped in one place. Default is DGrid (DeepSeek + Qwen);LLM_PROVIDER=openaiswitches to real OpenAI with no code change.
-
AgentMail inbox-scoped keys can't create inboxes. Keys shaped
am_us_inbox_…lackinbox_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 inconvex/emails.tsso future maintainers don't go down the same rabbit hole. -
Scheduler-chained extraction. Cases were stalling at
researchingwith sources but zero clauses. The fix: everypolicySourcesinsert mustawait 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. -
Vector search dimension mismatch. The
by_embeddingindex is hardcoded at 1024 dims. Real OpenAI'stext-embedding-3-smallreturns 1536 by default — we passdimensions: 1024explicitly inai.ts. Judges checking the embedding dims against the schema will see they match. -
Embedding vectors in API responses. A 1024-dim vector is ~8KB of JSON per row.
rankClausesstrips the embedding before returning ranked results, or every ranking call ships megabytes. The clause picker inCaseDetail.tsxuses_scorefrom the vector search, not the raw vector. -
Status machine transitions. Actions can't
ctx.db.patch— they callinternal.letters_internal.setStatus. A missing transition looks like a dead frontend when the backend actually succeeded. ExplicitsetStatuscalls at each handoff. -
Inbound matching on a shared inbox.
inbox_idalone is ambiguous when many cases share one inbox. Thread-first (pinnedagentmailThreadId) → subject containment → single-case fallback. We scan thethreadstable (small) for a pinned match before falling back to subject matching.
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.tsas 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.
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
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
- Frontend: https://confident-bass-919.convex.site
- Worked example (one-click): https://confident-bass-919.convex.site/demo
- API root: https://confident-bass-919.convex.cloud
npm install
npx convex dev # first run logs you in, creates deployment
npm run dev # viteSet 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.
npm run build
npx convex deploy --yes
npx @convex-dev/static-hosting deploy --skip-convex # upload dist/MIT — see LICENSE. Not a substitute for a lawyer — see the footer in-app.