Skip to content

Teaching Claude to Stop Asking Me How to Query DynamoDB

Three Claude Code skills that encode patient data lookup patterns once so I never have to re-explain OpenPhone GSIs or Heidi session schemas again.

Matt Dennis

Every patient-related conversation in Claude Code was starting the same way. I’d ask something like “what did Prudence Featherbottom text us?” and then spend the next few exchanges explaining that the messages are in DynamoDB, that the table is called OpenPhone, that the relevant GSI is type-createdAt-index, that the body field is nested three levels deep as data.M.object.M.body.S, and that you need to do a second lookup against OpenPhoneContacts to resolve the phone number to a name.


Every session. From scratch.


What Claude Code Skills Actually Are

A skill is a markdown file Claude Code automatically loads into its context when it detects a relevant trigger phrase. That’s the whole mechanism. There’s no plugin system, no function registration, no API — just a SKILL.md with a description field that the runtime uses to decide when to pull it in.


The structure is about three lines of frontmatter and then whatever documentation is most useful:

---
name: openphone-search
description: "Use when asked to look up texts from a patient..."
---

# openphone-search

[actual reference material]

The key insight is that the description field is doing the routing work. Claude reads it to decide if this conversation is one where the skill is relevant, then loads the full content. So the skill isn’t a script or a tool — it’s a reference doc that gets injected at the right moment. The hard part is writing documentation tight enough to actually be useful in a 2000-line context window.


The Problem: N Sessions, N Re-Derivations

The Dr. Ray practice stack has 44+ services. Patient data is scattered across DynamoDB tables, an S3 bucket, a patient search API that fans out to Google Drive and Airtable, and OpenPhone’s webhook event log. Every one of those systems has its own schema quirks. DynamoDB reserved words that need ExpressionAttributeNames. JSON fields that are stored as strings and require json.loads(). A User_ID field that’s type N (not S) and will throw a ValidationException if you get it wrong.


None of that is hard to figure out once. But figuring it out once per session is friction that compounds. And more importantly, it’s the kind of thing Claude shouldn’t need to rediscover — the schemas don’t change, the GSIs don’t move, the office phone number is still +18053640996.


patient-lookup: The Four-Step Pipeline

The first skill encodes the full patient context workflow. When I say “look up [patient],” it now knows to run four things in sequence:


Step 1 — Hit the patient search service at api.drjuliaray.com/patient-search/. This is a Fastify API on the VPS that fans out to Google Drive (file search), Airtable (Contacts + Opportunities tables in the Julia Ray CRM), and optionally a Make.com webhook for patient-comms. The Drive + Airtable path takes about 3 seconds. Adding comms adds up to 90 seconds because Make.com’s webhook response time is what it is.


Step 2 — Scan the Faxes DynamoDB table with contains(BedrockResult, :name). The BedrockResult field is a JSON string (not an object — parse it), and it contains the patient name as extracted by Bedrock Nova from the fax PDF. Searching by last name catches most faxes without false positives.


Step 3 — Query HeidiSessions via the patientNameLower-createdAt-index GSI. Heidi is the AI clinical note system Dr. Ray uses during patient visits; the sessions live in DynamoDB with S3 transcripts. This step hands off to the second skill for anything deeper.


Step 4 — OpenPhone, which hands off to the third skill.


The patient-lookup skill is a coordinator. It does light queries at each layer and surfaces enough to know where to dig deeper.


heidi-lookup: Schema Reference for Clinical Sessions

The Heidi sessions skill is the densest of the three. The HeidiSessions table has three GSIs and around 60 relevant fields, and the transcripts live in S3 at s3://dr-julia-ray-heidi/heidi-sessions/transcripts/{sessionId}.json with a nested structure that varies depending on whether the transcript has been edited.


The most important thing the skill encodes is the name matching problem. Heidi transcribes patient names phonetically during the session — the AI hears the name, writes it down, and sometimes gets it wrong. “Thistlewaite” becomes “Thistlwaite.” “Wobblethorpe” becomes “Wobblethorp.” The patientNameLower-createdAt-index GSI requires an exact lowercase match, so a search for “honoria wobblethorpe” returns zero results if Heidi wrote “honoria wobblethorp.”


The fallback is a contains() scan on patientNameLower, which is slower (full table scan) but catches partial matches. If that fails, there’s HeidiNameAliases — a separate DynamoDB table that maps known Heidi misspellings and patient nicknames to canonical names. The alias table is also what /add-pt-alias writes to.


The skill also handles consult note retrieval and S3 transcript parsing in a single code block that prefers editedTranscriptS3Key over transcriptS3Key when both exist.


openphone-search: SMS Threads and Contact Resolution

OpenPhone webhook events land in a DynamoDB table called OpenPhone. The event types that matter for patient communication are message.received (inbound SMS), message.delivered (outbound), and the call variants. The table has four GSIs: by source+date, by type+date, by from phone, and by to phone.


The existing openphone-search.sh script already handles keyword search with date range filtering and contact name enrichment. The skill wraps it:

/Users/mattdennis/drray/openphone-search.sh "Featherbottom" --after 2026-05-01

But the skill also documents the raw DynamoDB patterns for cases the script doesn’t cover — specifically, pulling the full thread for a known phone number (inbound + outbound interleaved by timestamp) and checking whether a patient got a reply. The “did we reply to this person” query is the one that comes up most after missed messages get flagged: fetch their inbound events, then query to-createdAt-index with their phone number and a createdAt >= condition to see if anything was sent back.


The gotcha worth knowing: some contacts have phoneNumber: "none" in OpenPhoneContacts. If the number lookup returns nothing, fall back to Airtable by name.


The Overhead of Institutional Knowledge

These three skills are documentation, not code. Nothing runs when they load. The value is that they carry the specific, non-obvious things that make this particular system work — the type coercions, the GSI key structures, the fallback sequences, the shell script paths — so Claude doesn’t have to re-ask and I don’t have to re-explain.


It’s the same reason good runbooks exist. The difference is that a runbook gets read when something is broken; a skill gets read before every relevant conversation.


The Prudence Featherbottom search that opened this post — finding an 11-day-old unanswered text from a prospective patient — took about 30 seconds once the skill was in context. Before: four rounds of back-and-forth reconstructing the query. After: one question, one answer.