March 30, 2026 · 12 min read

Build a Health AI Agent with the Sahha MCP Server

Wire your remote Sahha MCP server into an agentic loop that autonomously queries health data, finds patterns, surfaces insights, and produces a structured output.

What you are building

The previous tutorials in this series built a Sahha MCP server, connected it to Claude Desktop and Claude Code, and deployed it to Railway. This tutorial is the capstone: using that server as the data layer for a health AI agent.

The agent is a Node.js script that:

  1. Connects to your remote Sahha MCP server as an MCP client
  2. Gives an AI model access to the Sahha tools (get_scores, get_biomarkers, get_archetypes, get_profile)
  3. Runs an agentic loop — the AI decides which tools to call, in what order, and how many times
  4. Analyses the retrieved data for trends, correlations, and patterns across health dimensions
  5. Decides whether an action is warranted and what it should be
  6. Returns a structured JSON output ready to store, display, or feed into a downstream workflow
Your Node.js agent script

MCP Client connects to remote Sahha MCP Server

AI runs agentic loop — calls Sahha tools as needed

AI analyses data: trends, correlations, patterns

Structured report + recommended action returned

Your app stores or acts on the result

This is what makes Sahha data genuinely agentic — instead of you deciding what to query and when, the AI drives the investigation based on what it finds.

Prerequisites

This tutorial assumes you have completed the earlier tutorials in this series:

You will also need:

How the agentic loop works

In a standard API call, you decide what to ask and get one response. In an agentic loop, the AI decides what tools to call based on what it finds — and keeps going until it has enough information to answer fully.

For a health agent this means:

  • The AI might fetch scores first, notice a low readiness trend, then pull biomarkers to understand why
  • It might check archetypes to put short-term data in the context of long-term behavioural patterns
  • It might call get_scores more than once with different date ranges to compare periods

You give the agent a goal. It figures out how to achieve it using the tools available.

1. Create the agent project

mkdir sahha-health-agent
cd sahha-health-agent
npm init -y

Install dependencies:

npm install @anthropic-ai/sdk @modelcontextprotocol/sdk dotenv
npm install -D typescript @types/node tsx

Add tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"]
}

Update package.json:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/agent.js",
    "dev": "tsx src/agent.ts"
  }
}

2. Set up your environment

Create .env:

ANTHROPIC_API_KEY=your-anthropic-api-key
SAHHA_MCP_URL=https://your-railway-url.up.railway.app/mcp
SAHHA_MCP_TOKEN=your-mcp-bearer-token

Add .env to .gitignore.

3. Build the MCP client

Create src/mcp-client.ts. This connects to your remote Sahha MCP server and exposes its tools in a format the Anthropic SDK understands.

// src/mcp-client.ts

import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'

const MCP_URL = process.env.SAHHA_MCP_URL
const MCP_TOKEN = process.env.SAHHA_MCP_TOKEN

if (!MCP_URL || !MCP_TOKEN) {
  throw new Error('SAHHA_MCP_URL and SAHHA_MCP_TOKEN must be set')
}

export async function createSahhaMcpClient() {
  const client = new Client({ name: 'sahha-health-agent', version: '1.0.0' })

  const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
    requestInit: {
      headers: { Authorization: `Bearer ${MCP_TOKEN}` },
    },
  })

  await client.connect(transport)
  return client
}

// Convert MCP tool definitions to Anthropic tool format
export async function getSahhaTools(client: Client) {
  const { tools } = await client.listTools()
  return tools.map((tool) => ({
    name: tool.name,
    description: tool.description ?? '',
    input_schema: tool.inputSchema,
  }))
}

// Call a tool on the MCP server and return its text output
export async function callSahhaTool(
  client: Client,
  toolName: string,
  toolInput: Record<string, unknown>
): Promise<string> {
  const result = await client.callTool({ name: toolName, arguments: toolInput })
  return result.content
    .map((block) => (block.type === 'text' ? block.text : ''))
    .filter(Boolean)
    .join('\n')
}

4. Build the agent

Create src/agent.ts. This is the full agentic loop.

// src/agent.ts

import Anthropic from '@anthropic-ai/sdk'
import { createSahhaMcpClient, getSahhaTools, callSahhaTool } from './mcp-client.js'
import 'dotenv/config'

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })

// The structured report the agent produces
interface HealthAgentReport {
  profileId: string
  generatedAt: string
  summary: string
  trends: string[]
  correlations: string[]
  insights: string[]
  recommendedAction: {
    type: 'notification' | 'no_action' | 'flag_for_review'
    reason: string
    message?: string
  }
  confidence: 'high' | 'medium' | 'low'
}

async function runHealthAgent(externalId: string): Promise<HealthAgentReport> {
  console.log(`\nRunning health agent for profile: ${externalId}`)

  const mcpClient = await createSahhaMcpClient()
  const tools = await getSahhaTools(mcpClient)

  console.log(`Connected. Tools: ${tools.map((t) => t.name).join(', ')}`)

  const systemPrompt = `You are a health data analyst agent with access to Sahha health data tools.

Your job is to investigate the health and lifestyle data for a given profile and produce a structured analysis.

You should:
- Query scores, biomarkers, and archetypes to build a complete picture of the profile
- Look across multiple time ranges to identify trends (improving, declining, or stable)
- Find correlations between different data types (e.g. poor sleep biomarkers correlating with low readiness scores)
- Surface patterns using archetypes to add long-term behavioural context
- Decide whether the data warrants an action

When deciding on an action:
- Recommend 'notification' only when there is a clear, actionable insight the user would benefit from knowing
- Recommend 'flag_for_review' when data is unusual enough to warrant human attention
- Recommend 'no_action' when data is within normal ranges and trending stable or positively

Rules:
- Do not make medical claims or diagnoses
- Keep all outputs practical, supportive, and appropriate for end users
- Make multiple tool calls to build a thorough picture before drawing conclusions
- When you have enough data, respond with ONLY a valid JSON object — no preamble, no markdown fences`

  const userPrompt = `Investigate the health data for profile "${externalId}".

Query their scores, biomarkers, and archetypes across at least the past 14 days. Look for trends, correlations between data types, and any patterns worth noting. Use the archetype data to add long-term behavioural context to what you find in the short-term scores and biomarkers.

Produce your final report as a JSON object with this exact shape:

{
  "profileId": string,
  "generatedAt": ISO 8601 timestamp,
  "summary": string (2-3 sentence overview),
  "trends": string[] (observed trends, e.g. "Sleep score has declined over the past 5 days"),
  "correlations": string[] (correlations found between data types),
  "insights": string[] (meaningful patterns or behavioural observations),
  "recommendedAction": {
    "type": "notification" | "no_action" | "flag_for_review",
    "reason": string,
    "message": string (only if type is "notification" — the text to send the user)
  },
  "confidence": "high" | "medium" | "low"
}`

  const messages: Anthropic.MessageParam[] = [
    { role: 'user', content: userPrompt },
  ]

  let finalReport: HealthAgentReport | null = null
  let iterations = 0
  const MAX_ITERATIONS = 10

  while (iterations < MAX_ITERATIONS) {
    iterations++
    console.log(`\n--- Iteration ${iterations} ---`)

    const response = await anthropic.messages.create({
      model: 'claude-sonnet-4-5',
      max_tokens: 4096,
      system: systemPrompt,
      tools: tools as Anthropic.Tool[],
      messages,
    })

    console.log(`Stop reason: ${response.stop_reason}`)
    messages.push({ role: 'assistant', content: response.content })

    if (response.stop_reason === 'end_turn') {
      const textBlock = response.content.find((b) => b.type === 'text')
      if (textBlock && textBlock.type === 'text') {
        const clean = textBlock.text.replace(/```json|```/g, '').trim()
        finalReport = JSON.parse(clean) as HealthAgentReport
        console.log('Agent produced final report.')
      }
      break
    }

    if (response.stop_reason === 'tool_use') {
      const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use')
      const toolResults: Anthropic.ToolResultBlockParam[] = []

      for (const block of toolUseBlocks) {
        if (block.type !== 'tool_use') continue
        console.log(`Calling: ${block.name}(${JSON.stringify(block.input)})`)

        try {
          const result = await callSahhaTool(
            mcpClient,
            block.name,
            block.input as Record<string, unknown>
          )
          console.log(`Result: ${result.length} chars`)
          toolResults.push({
            type: 'tool_result',
            tool_use_id: block.id,
            content: result,
          })
        } catch (error) {
          console.error(`Tool failed: ${block.name}`, error)
          toolResults.push({
            type: 'tool_result',
            tool_use_id: block.id,
            content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
            is_error: true,
          })
        }
      }

      messages.push({ role: 'user', content: toolResults })
    }
  }

  await mcpClient.close()

  if (!finalReport) {
    throw new Error(`Agent did not produce a report after ${MAX_ITERATIONS} iterations`)
  }

  return finalReport
}

// --- Run ---
const externalId = process.argv[2]

if (!externalId) {
  console.error('Usage: npm run dev <externalId>')
  process.exit(1)
}

const report = await runHealthAgent(externalId)

console.log('\n=== HEALTH AGENT REPORT ===\n')
console.log(JSON.stringify(report, null, 2))

if (report.recommendedAction.type === 'notification' && report.recommendedAction.message) {
  console.log('\n=== RECOMMENDED NOTIFICATION ===')
  console.log(report.recommendedAction.message)
  // Send this to OneSignal, Braze, Klaviyo, or your own push infrastructure
}

5. Run the agent

npm run dev user-123

Replace user-123 with a real externalId from your Sahha account. You will see the agent working through its investigation in real time:

Running health agent for profile: user-123
Connected. Tools: get_scores, get_biomarkers, get_archetypes, get_profile

--- Iteration 1 ---
Stop reason: tool_use
Calling: get_scores({"externalId":"user-123","types":"activity,sleep,readiness,wellbeing","startDateTime":"2026-03-16T00:00:00Z","endDateTime":"2026-03-30T00:00:00Z"})
Result: 3241 chars

--- Iteration 2 ---
Stop reason: tool_use
Calling: get_biomarkers({"externalId":"user-123","categories":"sleep","startDateTime":"2026-03-23T00:00:00Z","endDateTime":"2026-03-30T00:00:00Z"})
Result: 5872 chars

--- Iteration 3 ---
Stop reason: tool_use
Calling: get_archetypes({"externalId":"user-123","periodicity":"weekly"})
Result: 1203 chars

--- Iteration 4 ---
Stop reason: end_turn
Agent produced final report.

Example output

{
  "profileId": "user-123",
  "generatedAt": "2026-03-30T09:15:00Z",
  "summary": "This profile has shown a consistent decline in sleep quality over the past 7 days, with readiness scores following a similar downward pattern. Activity levels have remained stable, suggesting the issue is sleep-specific rather than a general fatigue trend.",
  "trends": [
    "Sleep score has declined from 74 to 51 over the past 7 days",
    "Readiness score has dropped from 68 to 44 over the same period",
    "Activity score has remained stable between 61 and 67"
  ],
  "correlations": [
    "Lower sleep duration biomarkers correlate with next-day readiness scores",
    "Sleep efficiency has dropped below 80% on 5 of the last 7 nights, coinciding with readiness dips",
    "No correlation found between activity levels and sleep quality in this period"
  ],
  "insights": [
    "The profile is assigned the 'Night Owl' archetype — late sleep onset may be contributing to reduced total sleep duration",
    "Sleep score decline began 7 days ago and has been consistent, suggesting a lifestyle factor rather than a one-off event",
    "Readiness recovery is lagging — even on days with slightly better sleep, readiness does not fully recover the next day"
  ],
  "recommendedAction": {
    "type": "notification",
    "reason": "Sustained 7-day decline in sleep quality with downstream impact on readiness warrants a supportive nudge",
    "message": "We've noticed your sleep has been lighter than usual this week. Even small changes — like winding down 30 minutes earlier tonight — can make a real difference to how you feel tomorrow."
  },
  "confidence": "high"
}

How the agent decides what to investigate

The agent does not follow a fixed script. It drives its own investigation based on what it finds:

Starts broad — fetches all score types across two weeks to establish a baseline.

Narrows based on findings — if sleep scores are low, it pulls sleep biomarkers to understand why. If readiness is declining, it may cross-reference both sleep and activity data to isolate the cause.

Uses archetypes for context — long-term behavioural patterns help the agent interpret whether a trend is new or consistent with how this user typically behaves. A declining sleep score means something different for a ‘Night Owl’ than for an ‘Early Riser’.

Finds cross-dimension correlations — the most useful insights come from combining signals across data types, such as low sleep efficiency driving low next-day readiness, or a sedentary archetype compounding the impact of low activity scores.

Makes grounded recommendations — the agent only recommends a notification when there is a clear, actionable insight. It recommends no_action when data is stable, and flag_for_review when something unusual needs human attention.

Adapting the agent

Change the output schema — add fields relevant to your product such as engagementScore, riskLevel, or suggestedContent.

Adjust the investigation scope — modify the user prompt to focus on specific data types, a shorter time window, or a particular health dimension.

Change the action types — replace notification, no_action, and flag_for_review with actions that match your product, such as send_push, update_segment, trigger_email, or unlock_reward.

Swap the model — the agentic loop is model-agnostic. Replace claude-sonnet-4-5 with any model that supports tool use. The MCP client and tool definitions stay exactly the same.

Production considerations

  • Store reports before acting on them — write the structured output to your database before sending any notification, so you have a record of every recommendation the agent made
  • Add idempotency — if the agent runs on a schedule, track which profiles have been processed to avoid duplicate outputs
  • Handle low-confidence reports — decide how your product should treat confidence: "low" outputs, such as skipping the action or routing to human review
  • Monitor token usage — each agent run makes multiple API calls; track cost per run and set alerts if iteration counts or token usage spike unexpectedly
  • Separate environments — use the Sahha sandbox for testing and only point at api.sahha.ai for real user data

What to build next

This agent runs on demand from the command line. The natural next steps are triggering it automatically and acting on its output:

  • Trigger the agent from a Sahha webhook — run the agent whenever Sahha sends a new score event, so every profile gets an analysis the moment fresh data arrives
  • Run on a schedule — use Railway’s cron jobs to analyse all active profiles daily
  • Connect the output to your engagement stack — pipe the structured report into Braze, OneSignal, or Klaviyo to trigger campaigns based on what the agent recommends