This is the first in a series documenting how I built “Stella,” a women’s health AI assistant using AWS Strands SDK, Bedrock, and Django. The system handles personal health tracking, cycle predictions, and provides evidence-based health guidance.
Series Overview
- Part 1: Architecture (this post) - System design and component overview
- Part 2: User-Scoped Tools - Binding tools to users without breaking decorators
- Part 3: Prompts & Guardrails - AI safety and behavior control
- Part 4: Anti-Hallucination - Making AI truthful about missing data
- Part 5: Bedrock vs Strands - Comparing managed vs self-hosted agents
The Architecture
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Flutter App │────▶│ Django Backend │────▶│ Strands Agent │
│ │ │ (GraphQL API) │ │ (Stella) │
└─────────────────┘ └──────────────────┘ └────────┬────────┘
│
┌─────────────────────────────────┴─────────┐
│ │
▼ ▼
┌───────────────────────┐ ┌────────────────────────┐
│ User-Scoped Tools │ │ Amazon Bedrock │
│ (health_agent_tools) │ │ Claude 3.5 Sonnet │
└───────────┬───────────┘ └────────────────────────┘
│
▼
┌───────────────────────┐
│ Aurora PostgreSQL │
│ + pgvector │
└───────────────────────┘
Key Components
| Component | Purpose |
|---|---|
| Flutter App | Mobile client for iOS/Android |
| Django Backend | GraphQL API, auth, business logic |
| Strands Agent | AI orchestration with tool calling |
| User-Scoped Tools | Database queries bound to specific users |
| Bedrock (Claude) | LLM for natural language understanding |
| Aurora + pgvector | Health data storage and vector search |
Why Strands SDK?
AWS Strands SDK is a Python framework for building AI agents. Compared to alternatives:
| Feature | Strands | LangChain | Raw Bedrock |
|---|---|---|---|
| Tool calling | Built-in | Built-in | Manual |
| Agent loop | Automatic | Automatic | Manual |
| AWS integration | Native | Plugin | Native |
| Memory | Configurable | Configurable | None |
| Complexity | Low | High | Medium |
Strands gives you the agent loop and tool orchestration without LangChain’s complexity. It’s essentially “Bedrock Agents, but you control the code.”
The Conversation Flow
- User sends message via GraphQL mutation
askQuestion - Django validates auth using JWT + SecureUser hash
- Strands agent receives query with user-scoped tools
- Claude decides whether to call tools or respond directly
- Tools query PostgreSQL for user’s health data
- Agent formulates response using tool results + medical knowledge
- Response streamed back to Flutter app
GraphQL Mutations
mutation StartConversation {
startConversation(title: "Health Chat") {
conversationId
title
}
}
mutation AskQuestion($input: AskQuestionInput!) {
askQuestion(input: $input) {
messageId
content
nudges { type message }
}
}
User-Scoped Tools
The core challenge: tools need to query data for a specific user, but the AI shouldn’t know about user IDs. Solution: create tools that capture user_id in closures.
def create_user_scoped_tools(user_id: str) -> List[Callable]:
@tool
def get_cycle_history(days: int = 90) -> Dict:
"""Get user's menstrual cycle history."""
# user_id captured in closure
return query_user_cycles(user_id, days)
@tool
def log_symptom(symptom: str, severity: int = 5) -> Dict:
"""Log a symptom for the user."""
return save_symptom(user_id, symptom, severity)
return [get_cycle_history, log_symptom]
Why not functools.partial? It strips the @tool decorator metadata. Part 2 covers this in detail.
Available Tools
| Tool | Purpose |
|---|---|
get_cycle_history |
Period dates, cycle length, flow data |
get_tracking_summary |
Symptoms, moods, energy, sleep |
get_cycle_prediction |
Next period, ovulation, fertility windows |
get_hormone_levels |
Hormone tracking data |
log_symptom |
Record symptoms |
favorite_symptom |
Save symptoms to favorites |
analyze_patterns |
Find correlations in tracking data |
search_knowledge_base |
General health information (RAG) |
Data Model
The health tracking uses a flexible measurement system:
-- Base measurement (what was tracked)
CREATE TABLE health_item_measurements (
id UUID PRIMARY KEY,
health_item_id UUID REFERENCES health_items(id),
secure_user_id UUID NOT NULL,
tracked_at TIMESTAMP WITH TIME ZONE,
measurement_type VARCHAR(50) -- 'RANGE', 'BOOLEAN', etc.
);
-- Severity level for RANGE measurements
CREATE TABLE health_item_range_measurements (
id UUID PRIMARY KEY,
measurement_id UUID REFERENCES health_item_measurements(id),
level INTEGER -- 1-10 scale
);
-- User favorites for quick access
CREATE TABLE health_item_favorites (
id UUID PRIMARY KEY,
health_item_id UUID REFERENCES health_items(id),
secure_user_id UUID NOT NULL
);
Authentication: Two-Header System
Every authenticated request requires two headers:
x-compass-jwt-token: <JWT from login>
x-compass-hash-id: <SHA256 hash of user_id + passphrase>
The JWT identifies the account. The hash ID identifies the SecureUser for health data. This separation means:
- Account data (email, settings) uses just JWT
- Health data (symptoms, cycles) requires both
What’s Next
- Part 2: User-Scoped Tools - The closure pattern for binding tools to users
- Part 3: Prompts & Guardrails - Making Stella safe and helpful
- Part 4: Anti-Hallucination - Preventing AI from inventing data
Building Stella has been a journey in practical AI engineering. These patterns work in production with real users tracking real health data.