DEV Community

Cover image for Building Adaptive HealthOS: A Multi-Agent AI System with Gemini, Google ADK, and MongoDB Atlas
Vivek Kasturi
Vivek Kasturi

Posted on Originally published at Medium

Building Adaptive HealthOS: A Multi-Agent AI System with Gemini, Google ADK, and MongoDB Atlas

How I designed an orchestrated agent pipeline that plans, routes, executes, and remembers — instead of just responding.


The Problem

Most AI fitness apps collect data but don't act on it. They track workouts, log meals, and generate charts — but users remain responsible for turning information into decisions.

Adaptive HealthOS takes a different approach: a multi-agent system that classifies your intent, routes to the right specialist, executes tool chains against a persistent memory layer, and updates your state — all from a single natural language input. No form-filling. No manual categorization. No static plans that never change.

The stack: Gemini 2.5 Flash, Google ADK, MongoDB Atlas, FastMCP, FastAPI, React.


Architecture: Six Agents, One Pipeline

The system is built on a manager-specialist pattern:

User message
    ↓
OrchestratorAgent       ← classifies intent as structured JSON
    ↓
[route to specialist]
    ↓
NutritionAgent          ← food logs, macro tracking, meal plans
WorkoutAgent            ← exercise logs, plan adjustments
RecoveryAgent           ← sleep analysis, rest day decisions
ProgressAgent           ← weight trends, goal tracking
ForecastingAgent        ← 30-day projection with 3 scenarios
AccountabilityAgent     ← streak management, motivational check-ins
    ↓
MongoDB Atlas           ← all state, decisions, logs, plans persisted
    ↓
React UI                ← dashboard, agent activity panel, gamification
Enter fullscreen mode Exit fullscreen mode

Every agent is an ADK LlmAgent with its own system prompt, tool bindings, and runner. The orchestrator doesn't just route — it also extracts structured data from the user's message (calories, duration, weight values) and passes it downstream, so specialist agents receive clean inputs rather than raw natural language.

This distinction matters. It's the difference between "I ate a burger" reaching the NutritionAgent as prose versus as {"name": "burger", "calories": 550, "protein_g": 28, "meal_type": "lunch"}.


The MCP Layer: MongoDB as a Tool Interface

The most architecturally significant decision in this system was how agents interact with data.

I built a custom FastMCP server (mcp_server.py) that wraps every MongoDB Atlas operation and exposes them as Model Context Protocol tools. Agents don't write raw queries. They call structured, named tools:

@mcp.tool()
async def insert_health_log(user_id: str, log_type: str, data: dict) -> dict:
    """Insert a health log entry (food, workout, weight, sleep) into MongoDB."""
    log_id = await _insert({"user_id": user_id, "type": log_type, "data": data})
    return {"log_id": log_id, "status": "logged"}

@mcp.tool()
async def log_agent_decision(user_id: str, agent_name: str, trigger: str, 
                              decision: str, actions_taken: list) -> dict:
    """Persist an agent decision to MongoDB agent_decisions collection."""
    doc_id = await _log({...})
    return {"decision_id": doc_id, "status": "logged"}
Enter fullscreen mode Exit fullscreen mode

This matters for three reasons:

1. Agents are tool-bound, not query-bound. A NutritionAgent cannot accidentally write to the wrong collection. It can only call the tools it was given. This is deterministic behavior, not hoped-for behavior.

2. State is an explicit action, not a side effect. Every time an agent writes to MongoDB — logging a decision, inserting a health entry, updating a streak — it's a named function call that appears in the tools_used list returned to the UI. The agent activity panel in the dashboard shows exactly which tools each agent called, in order. This is observable intelligence.

3. Memory is first-class. Agents aren't stateless responders. The ForecastingAgent calls get_recent_logs to retrieve 30 days of weight history before generating projections. The RecoveryAgent reads sleep quality scores to decide whether to recommend a rest day. Context lives in MongoDB and is retrieved as needed — persistent across sessions, queryable by any agent.


The Hard Problems

1. LLM JSON Instability

The OrchestratorAgent needs to return structured JSON every time:

{
  "intent": "FOOD_LOG",
  "confidence": 0.94,
  "extracted_data": {"name": "grilled chicken", "calories": 320, ...},
  "routing_reason": "User described a meal with specific food items"
}
Enter fullscreen mode Exit fullscreen mode

In practice, Gemini sometimes wraps the JSON in markdown code fences, adds an explanation sentence before it, or returns slight variations in key names. A naive json.loads(response) fails frequently.

The fix was a substring extraction approach: find the first { and last } in the response string, extract that slice, parse it, and fall back to intent: "GENERAL" if parsing still fails. Simple, but essential for production reliability. A fragile parser here breaks the entire routing pipeline.

start = text.find("{")
end = text.rfind("}") + 1
if start >= 0 and end > start:
    parsed = json.loads(text[start:end])
    intent = parsed.get("intent", "GENERAL")
Enter fullscreen mode Exit fullscreen mode

This pattern — parsing LLM output as structured data with explicit fallback — is something every multi-agent system needs and few tutorials cover.

2. ADK Session vs. MongoDB State

Google ADK uses InMemorySessionService for conversation context. MongoDB owns persistent state. These two layers serve different purposes and must not be conflated.

The ADK session manages the short-term context window for a single agent run. MongoDB manages everything that needs to survive across requests: logs, plans, agent decisions, forecasts, gamification state.

The architectural rule I settled on: ADK sessions are ephemeral and per-request. MongoDB is the source of truth for all agent memory. Each call to run_agent() creates a fresh ADK session, but every meaningful state change is persisted to MongoDB via MCP tools before the session ends. This keeps the agent execution stateless while maintaining rich, queryable history.

3. Runner Lifecycle and Caching

ADK's Runner is the execution engine for an agent. Creating a new runner per request is expensive and unnecessary — but creating runners at import time causes issues with async initialization order.

The solution: a lazy runner cache keyed by agent name.

_runners: dict[str, Runner] = {}

def _get_runner(agent) -> Runner:
    if agent.name not in _runners:
        _runners[agent.name] = Runner(
            agent=agent,
            app_name="healthos",
            session_service=_session_service,
        )
    return _runners[agent.name]
Enter fullscreen mode Exit fullscreen mode

One runner per agent, created on first use, reused for every subsequent request. This keeps initialization cost at one-time-per-agent while avoiding the overhead of reconstruction.

4. Async Orchestration Latency

The pipeline runs two Gemini calls per user message: first the OrchestratorAgent, then the specialist. Under a naive implementation this compounds latency.

The run_async event loop in ADK streams events — tool calls, intermediate responses, and the final response — as they occur. Capturing tool usage alongside the final response required iterating over the full event stream rather than awaiting only the final response:

async for event in runner.run_async(...):
    if event.content:
        for part in event.content.parts:
            if hasattr(part, "function_call") and part.function_call:
                tools_used.append(part.function_call.name)
            if hasattr(part, "text") and part.text:
                response_text = part.text
    if event.is_final_response():
        break
Enter fullscreen mode Exit fullscreen mode

This pattern — accumulating tool calls from the stream, not just extracting the final text — is what enables the UI to show real agent activity rather than a simulated trace.

5. The ForecastingAgent Multi-Step Chain

The ForecastingAgent executes a four-step tool chain autonomously, without human intervention between steps:

  1. get_user(user_id) — retrieve goal and target weight
  2. get_recent_logs(user_id, "weight", limit=30) — fetch weight history
  3. Calculate trajectory across three scenarios (optimistic, realistic, pessimistic)
  4. save_forecast(...) — persist the projection
  5. log_agent_decision(...) — record the analysis

This is the clearest example of genuine agentic behavior in the system. The agent is not responding to a query — it is executing a workflow. It decides what data it needs, fetches it, reasons over it, produces a structured output, and stores that output for other agents and the UI to consume. No human in the loop between steps.


Why the MCP + ADK Combination Works

What made Google ADK + MCP feel like the right architecture (rather than a raw LangChain agent or a custom prompt loop) was the separation it enforces:

  • ADK handles agent cognition — the reasoning loop, tool call execution, session context
  • MCP handles tool exposure — what tools exist, what their schemas are, how they connect to data
  • MongoDB handles memory — persistent state, queryable history, cross-agent data sharing

Each layer has a single responsibility. Agents don't know about MongoDB's collection structure. The MCP server doesn't know about agent logic. The result is a system where you can swap out any layer without cascading rewrites — which matters when you're building and iterating fast.


What "Agentic" Actually Means

There's a tendency to call anything that uses an LLM "agentic." It isn't.

A chatbot answers questions. An agent executes workflows.

The distinction shows up in concrete behavior. When you tell HealthOS you ate a meal, the system:

  1. Classifies your input (OrchestratorAgent → FOOD_LOG)
  2. Extracts nutritional estimates (same agent, structured JSON output)
  3. Writes the log to MongoDB (NutritionAgent → insert_health_log)
  4. Checks today's running macros (→ get_daily_nutrition_totals)
  5. Compares against your active nutrition plan (→ get_active_plan)
  6. Updates the plan if needed (→ create_plan)
  7. Awards XP (→ award_xp)
  8. Updates your streak (→ update_streak)
  9. Logs the full decision trace (→ log_agent_decision)

That's nine tool calls triggered by one natural language input. No form. No menu. No "click save." The user said something, the system acted.

That's what agentic means.


By the Numbers

Some concrete figures from the system:

  • 11 MCP tools exposed by the FastMCP server — each maps to a named MongoDB operation
  • 6 MongoDB collections: users, health_logs, plans, agent_decisions, forecasts, gamification
  • 6–9 MCP tool calls triggered by a typical food logging request
  • 2 Gemini calls per request — OrchestratorAgent + specialist — running sequentially
  • ~3–4 seconds average end-to-end latency for standard requests (food log, workout log)
  • ~6–8 seconds for ForecastingAgent requests, which run a 5-step tool chain over 30 days of weight history
  • Intent routing accuracy: 94%+ on common health inputs in manual testing; falls back to GENERAL intent for anything ambiguous

The latency comes almost entirely from the two sequential Gemini calls — MongoDB ops over Atlas are fast. The main optimization lever, if needed, is parallelizing orchestration and data prefetch, not the database layer.


What Didn't Work

Letting agents query MongoDB directly. The first prototype gave agents a raw database connection. They hallucinated collection names, wrote malformed queries, and occasionally inserted data into the wrong place. The MCP tool interface exists because direct DB access for LLM agents is a bad idea — named tools with typed inputs are the only reliable contract.

Single-agent design. I tried building a single "health assistant" agent with all tools attached. It worked on simple inputs and failed on complex ones — it would log food correctly but forget to check macros or update the plan. Specialist agents with narrow scopes and clear responsibilities outperform one generalist agent with many tools attached.

Prompt-only routing. Early versions used a prompt like "decide what to do and call the right function." The orchestrator would sometimes skip routing and try to handle everything itself, or route to the wrong agent based on superficial keywords. Extracting routing into a dedicated classification step with structured JSON output — separate from the specialist response — made the pipeline reliable.

Shared tool access across agents. When all agents could call all tools, they would call irrelevant ones "just in case." The WorkoutAgent would call get_daily_nutrition_totals. The NutritionAgent would call update_streak. Scoping each agent's tool list to only what it needs eliminated this noise.

Long-lived ADK sessions. Keeping the same session open across multiple user requests caused context accumulation — previous tool results would bleed into new decisions. Per-request sessions with MongoDB as the durable state layer was the right separation.


Conclusion

The interesting question isn't whether agents can call tools. That's solved. The challenge is designing systems where multiple agents can coordinate, persist state, and make decisions that compound over time — without becoming brittle, unpredictable, or expensive to debug.

The patterns that made this work: a dedicated orchestration layer that handles routing separately from reasoning, MCP tool boundaries that force explicit contracts between agents and data, and a persistent memory layer that every agent reads from and writes to. None of these are novel individually. Together, they make the system behave like something that actually owns its state rather than something that just responds to prompts.

Adaptive HealthOS is one experiment in that direction. The same architecture — orchestrated routing, scoped tool access, persistent agent memory — applies to any domain where users have longitudinal data and goals that evolve over time. Health is just a convenient starting point.


Built solo for the Google Cloud Rapid Agent Hackathon 2026.

Stack: Google ADK · Gemini 2.5 Flash · MongoDB Atlas · FastMCP · FastAPI · React · Vercel · Railway

GitHub · Live demo


Vivek Kasturi — Applied AI Systems Engineer
LinkedIn · X/Twitter

Top comments (0)