Skip to content
Abhishek KolgeSenior AI Product Engineer
Index

Mosaic AI, an analyst for healthcare data. Ask in plain English, get back a chart and where the numbers came from.

Healthcare · Agentic AI · Internal tool · Walkthrough on request (opens in a new tab)

Role
Lead AI engineer, from the first product call to a running system
Team
2 engineers · rebuilt in 9 weeks
Stack
Claude on Bedrock, LangChain agents, FastAPI, Snowflake, Postgres, Temporal, Next.js 16, Terraform, Langfuse
Mosaic AI start screen: live datasets, suggested questions and the message composer

Problem

The research team had survey and cost-of-living data in Snowflake. Getting a number out meant waiting on an analyst, and writing it up for publication was a second job. In healthcare you also can’t point an LLM at the warehouse and hope for the best.

Approach

You ask a question. A lead model works out what it needs and hands each dataset to its own small SQL agent, all running at once. Every query is checked before it reaches Snowflake, and every number in the answer comes from rows a query actually returned. Adding a dataset goes through a reviewed workflow and needs no code change.

An answer in progress: per-dataset query lanes, then a chart of senior household income by state with its data source

By the numbers

793
tables in the catalog, browsable and onboardable without a deploy
7 bypasses
closed in the SQL guard, each found by live probing and written down next to the check that stops it
10/10
checks on a five-question golden set for the pilot dataset, against live data, four runs in a row
~800
automated tests across the repo, with more test code than app code in the agent service
0 keys
long-lived cloud credentials anywhere in the pipeline
24 checks
separate reasons a generated query can be refused, covered by 76 tests

Architecture

Interface
Next.js 16 and assistant-ui, with streamed per-dataset lanes, inline charts and follow-ups
Agents
FastAPI and LangChain; a Claude orchestrator on Sonnet by default, with Haiku and Opus tiers, over Haiku SQL sub-agents on Bedrock
Guardrails
sqlglot AST guard, read-only roles, row, join and time caps, PHI-safe logs
Data
Live read-only Snowflake; Postgres split by schema between app and agent
Workflows
Temporal workflows for dataset exploration, profiling and scheduled re-ingest
Platform
A colleague’s private cluster; before it, my own Terraform estate on GitHub OIDC with no static keys
Observability
Langfuse traces linked to in-app feedback
Delivery
Locked installs, ruff, pyrefly and pytest against a throwaway Postgres in CI, every Action in the API pipeline pinned to a commit SHA, a release-age gate on new packages

Decisions

  • Saying no to most of the wishlistI cut a catalog-search agent, vector routing and extra composer modes from the first release. They were reasonable ideas, but they solved problems we didn’t have yet. File uploads stayed off too, since an upload is a new way for sensitive data to walk into a system built around a read-only warehouse. The first release only had to give people one answer they could trust.
  • Every dataset gets askedThe model doesn’t decide which datasets are worth asking, because one wrong guess would leave data out of the answer. The fan-out is plain code that starts one small SQL agent per dataset, all at once. A cheap router trims the list to six when there are enough datasets to matter, and after one busy question fell over mid-answer I capped a single answer at eight datasets.
  • The model writes SQL and code runs itAgents hand back a query as text. Before Snowflake sees it, the query is parsed and checked against that dataset’s own tables and columns. The check blocks writes, SELECT * and runaway joins and adds a row cap and a timeout, all on top of a read-only role. A rejected query goes back with the reason.
  • What the logs are allowed to knowDriver errors stay on the server, because they can carry internal hostnames. We log a fingerprint of a question rather than the question itself.
  • When the charts kept breakingThe first version had the model write chart JSON straight into its answer. It kept closing the brackets early, so people saw raw JSON where a chart should have been. Charts moved into a typed tool call, and the numbers in them come from real query results instead of the model retyping them.
  • New data without a deployAdding a dataset used to mean hand-written YAML and a redeploy. Now someone picks a table and a Temporal workflow profiles it, has the model draft the docs, and checks every claim against the actual data. Official warehouse descriptions get copied word for word rather than rewritten. A person signs off before it goes live, and a re-ingest never takes the current version offline.
  • Somewhere to run, before there was a clusterThe product needed somewhere to live before the shared cluster existed, so I wrote one: 39 Terraform resources, Postgres behind a TLS-required proxy, IMDSv2 and an encrypted root with a bootstrap that fails closed, immutable scan-on-push images, a fixed egress address so the warehouse could allow-list us, and a budget alarm because it was my client’s money.
  • No pipeline holds a cloud keyThe deploy role is pinned to one repository on both the audience and the subject claim, so nothing long-lived sits in CI. Getting Postgres to verify-full took longer than it should have: the committed CA bundle alone trusts the database but not the proxy in front of it.
  • Deleting it once it was no longer neededWhen a colleague’s cluster landed, the whole estate came out in one pull request instead of living on as a second one for someone to inherit.
  • Writing it down firstWe were two engineers with a lot of moving parts, so every change started as a short written plan with the trade-offs spelled out, approved before an agent touched a file, and shipped as 42 small reviewed PRs over nine weeks, one idea per commit so a bad one could be lifted out on its own.
  • What the agents were allowed to touchThey ran in parallel only across separate services, and a reviewer pass ran before I read the diff. The rules they follow live in the repo, where reads are open and writes and commands are gated.
Dataset catalog: 793 Snowflake tables, 9 active and 784 waiting, each with its own review and ingest state

Outcome

A prototype over one table now answers across nine datasets, for people who have never written a line of SQL, with 784 more tables in the catalog ready to onboard. New data takes one reviewed workflow instead of a deploy. Two of us got there in nine weeks. It runs end to end in the dev environment the client provisioned. The production tier was next on the list, and I never ran it.

Résumé (opens in a new tab)