Skip to main content

Home · Projects · Platform / Civic Tech

Link Health

Benefits enrollment: public chat + clinic case management on one AWS domain.

Role
Full Stack Platform Engineering
Type
Production enrollment platform
Client
Link Health · A Healthier Democracy
Status
Live
ReactTypeScriptHonoAWSPostgreSQLDynamoDB
Link Health Massachusetts benefits enrollment — programs and Chat with LEO

Outcome

Built a production enrollment platform that combines case management, document processing, eligibility logic, and AI-assisted applicant support on a shared domain model.

Problem

Eligible people leave food, phone/internet, and healthcare assistance unclaimed because state portals are fragmented, paperwork-heavy, and hard to use alone. Clinics were stitching chatbots, spreadsheets, and portal logins with no shared application state, weak document handling, and little visibility into follow-up. Constraints: MA and TX portals rarely expose clean APIs; rules differ by location and program; staff and the public cannot share a UI; documents cannot live only in the browser.

Solution

One SST/AWS monorepo, two surfaces: a public React app (Leo chat + program forms) and an internal ops dashboard (screener, CRM, case management, analytics). Eligibility and intake rules live in shared config. Lambdas (Hono) own auth, sync, OCR, notifications, and chat. Chatbot applications can sync into the staff CRM so follow-through does not start from a blank form.

Architecture
Frontend
React 18 + Vite (public)React 18 + Vite (admin)TanStack Router · Jotai · i18n
API
TypeScript Lambdas + HonoOpenAuth · Textract · SES · Slack
Data
Aurora Postgres 16 / DrizzleDynamoDB (limits · tokens · feedback)
External
Bedrock chat KBs + guardrails (ownership TBD)S3 + TextractDTA Connect · Your Texas Benefits
Infra
SST v3 · VPC · StaticSitesGitHub Actions OIDC
Frontend
Two Vite SPAs: public (home, Leo, forms) and admin (CRM, cases, analytics, users, chatbot admin). TanStack Router, Jotai, i18n.
Backend
Hono on Lambda. Domain logic in packages/functions; shared types from packages/core — not duplicated in the UI.
APIs
HTTP for applications, dashboard CRM, OCR jobs, notifications, auth, and chat. Cron Lambdas for reports and probes.
Databases
Aurora PostgreSQL 16 (Drizzle / Data API) for applications, CRM, history, chat. DynamoDB for rate limits, Tampermonkey tokens, feedback, status checks.
Infrastructure
SST v3: VPC, StaticSites, S3, SES, alarms. Prod on link-health-app.org / admin.link-health-app.org.
Integrations
S3 + Textract. SES and Slack. Portal prefill for DTA Connect and Your Texas Benefits. Bedrock is in the deployed stack for chat KBs/guardrails.
Auth
OpenAuth. Admin routes are role-scoped; the server is authoritative.
Deployment
GitHub Actions OIDC: automatic deploys to dev; production is a manual promotion.
Key features
  • Public path: Leo chat plus program forms, with applications tied to the original session
  • Clinic path: consent, screener, navigator assignment, contact attempts, follow-ups
  • Multi-program cases across SNAP, WIC, Lifeline, MSP, and related benefits
  • Config-driven locations and screener (questions, income tables, eligibility AST) so MA/TX expansion is data/rules work, not a fork
  • Documents: S3 + Textract OCR, PSI PDF templates, Tampermonkey maps for DTA Connect and Your Texas Benefits
  • Ops: role-scoped analytics, Excel/SES reports, Slack alerts, chatbot admin, health probes
Engineering challenges

Challenge

Public chat applications and clinic CRM records had to stay connected without double-entry or conflicting status.

Decision

Two tables (applications vs dashboard_applications) plus an explicit sync path — not one overloaded row type.

Implementation

syncToDashboard copies approved chat applications into the staff CRM. Chat history stays on the original thread; navigators own follow-through after approval.

Result

Staff work from a CRM record after sync. The public thread remains attributable without mutating caseworker state.

Challenge

MA/TX portals rarely expose APIs, but navigators still finish work in those systems.

Decision

Treat portals as assisted-prefill surfaces, not pretend APIs.

Implementation

Field maps and Tampermonkey token flows prefill DTA Connect and Your Texas Benefits. Failures stay in case notes/status.

Result

Less retyping in the portal. External failures are visible on the case instead of disappearing in the browser.

Challenge

Eligibility and screener logic differed by location, program, and language — and could not live only in UI conditionals.

Decision

Consent, questions, income tables, and an eligibility AST live in shared core config used by web and Lambdas.

Implementation

Public app and API read the same rules package. Location packs (MA/TX live) swap data rather than forking product code.

Result

Chat and clinic paths cannot disagree on the same question. New-state work is config and rules, not a second codebase.

Technology decisions

Hono on Lambda (not Express on a long-lived box)

Traffic is bursty. Per-route Lambdas keep idle cost down and isolate OCR/chat from CRUD.

Alternative. A single Node process on ECS/EC2.

Tradeoff. Cold starts and local/prod parity cost more than an always-on server. SST + shared types offset some of that.

Aurora Postgres + DynamoDB

Cases need relational integrity and migrations. Rate limits, tokens, and feedback are key-value with TTL.

Alternative. Postgres for everything, or DynamoDB for everything.

Tradeoff. Two operational surfaces. Case queries stay SQL; hot-path counters do not contend with OLTP tables.

Drizzle (not a heavier ORM)

Schema in repo, SQL stays explicit, Data API fits Aurora Serverless.

Alternative. Prisma or raw SQL.

Tradeoff. More hand-written queries for complex CRM lists.

Shared eligibility config (not UI-only branching)

Chat and clinic must not disagree on income tables or program rules.

Alternative. Hardcoded React conditionals per state.

Tradeoff. Config is reviewed like code. New states are slower to 'just ship a screen' and faster to keep consistent.

Applied AI
Problem
Applicants need eligibility guidance without a navigator on the line; staff still need a CRM record afterward.
Model / provider
AWS Bedrock (present in the deployed stack)
Architecture
Public chat UI → API Lambda → Bedrock knowledge bases / guardrails → application row → optional CRM sync.
Tools
Bedrock chat KBs · guardrail config in admin
Failure handling
Chat is guidance. Case state is not inferred solely from a model reply. Staff CRM is the follow-through system of record after sync.
Human in the loop
Patient Navigators own case status, contact attempts, and portal completion after an application exists.
My ownership
  • BuiltPublic and admin React surfaces (routing, i18n, intake/case UI wiring)
  • BuiltHono handlers for applications, CRM sync, documents, notifications, and ops
  • BuiltDrizzle schema and Aurora/Dynamo access patterns used by those handlers
  • BuiltConfig-driven location/screener consumption on web and API
  • BuiltPortal prefill maps and Tampermonkey token flow integration
  • BuiltSST wiring and GitHub Actions OIDC deploy path as used in this repo
  • OthersClinic workflow and program policy are defined with Link Health / A Healthier Democracy
  • OthersState portals (DTA Connect, Your Texas Benefits) are third-party systems
  • TODO: Confirm exact ownership of Bedrock/Leo (prompts, knowledge bases, guardrails, evaluation).
Results

Public and clinic traffic share one application domain. Chat applications can enter the staff CRM without re-entry. Document intake is an OCR pipeline rather than email attachments. MA/TX-style expansion is config/rules work. Quantitative funnel metrics are not published here.