What you are building
The previous tutorial built a health AI agent as a Node.js script you run on demand. This tutorial turns it into a server that runs the agent automatically.
You will build two trigger patterns on top of the same agent logic:
Pattern 1 — Webhook trigger: Sahha calls your endpoint every time it generates new scores for a profile. Your server receives the event, runs the agent for that profile, and stores the result.
Pattern 2 — Cron trigger: A scheduled job runs nightly, loops through a list of profile IDs, runs the agent for each one, and stores the results.
Pattern 1: Sahha → webhook event → your Express server → agent → stored insight
Pattern 2: Cron schedule → your Express server → agent (per profile) → stored insightsBoth patterns use the same runHealthAgent function from the previous tutorial. The only thing that changes is what starts the agent running.
Prerequisites
This tutorial builds directly on Build a Health AI Agent with the Sahha MCP Server. You will need:
- The agent project from that tutorial with
runHealthAgentworking - A Sahha webhook configured (set one up here)
- A way to expose your local server to the internet during development — ngrok works well
1. Add Express and a scheduler
In your agent project, install the additional dependencies:
npm install express node-cron
npm install -D @types/express @types/node-cronexpress— HTTP server to receive webhook eventsnode-cron— lightweight cron scheduler for the nightly pattern
2. Extract the agent into a shared module
If runHealthAgent is currently defined inside src/agent.ts alongside the run script, move it into its own module so both trigger patterns can import it.
Create src/run-agent.ts:
// src/run-agent.ts
// Re-export runHealthAgent so both webhook and cron triggers can use it.
export { runHealthAgent } from './agent.js'Or if you prefer, move the function directly into a dedicated file and update src/agent.ts to import from it. Either approach works — the key is that runHealthAgent is importable without also running the CLI entrypoint code.
3. Build the server
Create src/server.ts. This sets up the Express server with both trigger patterns.
// src/server.ts
import express from 'express'
import cron from 'node-cron'
import { runHealthAgent } from './run-agent.js'
const app = express()
app.use(express.json())
// ---------------------------------------------------------------------------
// Simple in-memory insight store.
// In production, replace this with your database of choice.
// ---------------------------------------------------------------------------
const insightStore = new Map<string, unknown>()
// ---------------------------------------------------------------------------
// Pattern 1: Webhook trigger
//
// Sahha calls this endpoint when new scores are generated for a profile.
// The webhook payload includes the externalId of the profile.
// ---------------------------------------------------------------------------
app.post('/webhooks/sahha', async (req, res) => {
// Acknowledge the webhook immediately — Sahha expects a fast 200 response.
// The agent runs asynchronously after the response is sent.
res.status(200).json({ ok: true })
const payload = req.body
// Extract the externalId from the Sahha webhook payload.
// Adjust this field path to match your webhook event shape.
const externalId: string | undefined = payload?.externalId ?? payload?.profileId
if (!externalId) {
console.warn('Webhook received with no externalId — skipping agent run')
return
}
// Only run the agent for score events — ignore other event types if needed.
// Remove this check if you want to trigger on all Sahha webhook events.
const eventType: string | undefined = payload?.type
if (eventType && !eventType.includes('score')) {
console.log(`Skipping agent for event type: ${eventType}`)
return
}
console.log(`Webhook received for profile: ${externalId}. Running agent...`)
try {
const insight = await runHealthAgent(externalId)
insightStore.set(externalId, insight)
console.log(`Agent complete for profile: ${externalId}`)
} catch (error) {
console.error(`Agent failed for profile ${externalId}:`, error)
}
})
// ---------------------------------------------------------------------------
// Pattern 2: Cron trigger
//
// Runs nightly at midnight for a fixed list of profile IDs.
// In production, replace PROFILE_IDS with a database query.
// ---------------------------------------------------------------------------
const PROFILE_IDS = (process.env.PROFILE_IDS ?? '').split(',').filter(Boolean)
cron.schedule('0 0 * * *', async () => {
if (PROFILE_IDS.length === 0) {
console.log('Cron: no profiles configured — set PROFILE_IDS in your environment')
return
}
console.log(`Cron: running agent for ${PROFILE_IDS.length} profiles...`)
for (const externalId of PROFILE_IDS) {
try {
console.log(` → Running agent for: ${externalId}`)
const insight = await runHealthAgent(externalId)
insightStore.set(externalId, insight)
console.log(` ✓ Complete: ${externalId}`)
} catch (error) {
console.error(` ✗ Failed for ${externalId}:`, error)
// Continue to the next profile even if one fails
}
}
console.log('Cron: all profiles processed')
})
// ---------------------------------------------------------------------------
// Utility: retrieve a stored insight by profile ID
// ---------------------------------------------------------------------------
app.get('/insights/:externalId', (req, res) => {
const insight = insightStore.get(req.params.externalId)
if (!insight) {
return res.status(404).json({ error: 'No insight found for this profile' })
}
return res.json(insight)
})
// Health check
app.get('/health', (_req, res) => {
res.json({ ok: true, profiles: insightStore.size })
})
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
const PORT = process.env.PORT ?? 3000
app.listen(PORT, () => {
console.log(`Sahha health agent server listening on port ${PORT}`)
if (PROFILE_IDS.length > 0) {
console.log(`Cron scheduled for ${PROFILE_IDS.length} profiles: ${PROFILE_IDS.join(', ')}`)
} else {
console.log('Cron: no PROFILE_IDS set — webhook trigger only')
}
})4. Update your environment
Add the profile list for the cron trigger to your .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
PROFILE_IDS=user-123,user-456,user-789
PORT=3000PROFILE_IDS is a comma-separated list of Sahha externalId values to process on the nightly cron. Leave it empty if you only want webhook-triggered runs.
5. Update package.json
Add a start script for the server:
{
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"start:agent": "node dist/agent.js",
"dev": "tsx src/server.ts",
"dev:agent": "tsx src/agent.ts"
}
}6. Test locally with the webhook trigger
Start the server:
npm run devIn a separate terminal, expose it with ngrok:
ngrok http 3000Copy the ngrok URL (e.g. https://abc123.ngrok.io) and register it as a Sahha webhook endpoint in your Sahha dashboard:
https://abc123.ngrok.io/webhooks/sahhaTrigger a test event from the Sahha dashboard or use the Demo App to generate new scores. You should see the agent run in your terminal:
Webhook received for profile: user-123. Running agent...
→ Calling tool: get_scores ...
← Result: [{"type":"sleep"...
→ Calling tool: get_biomarkers ...
Agent complete for profile: user-123Retrieve the stored insight:
curl http://localhost:3000/insights/user-1237. Test the cron trigger manually
The cron runs at midnight, which is not ideal for testing. You can trigger a manual run by adding a test endpoint temporarily:
// Add to src/server.ts during development — remove before deploying
app.post('/run-cron-now', async (_req, res) => {
res.json({ ok: true, message: 'Cron running in background' })
for (const externalId of PROFILE_IDS) {
try {
const insight = await runHealthAgent(externalId)
insightStore.set(externalId, insight)
} catch (error) {
console.error(`Failed for ${externalId}:`, error)
}
}
})Trigger it:
curl -X POST http://localhost:3000/run-cron-nowDeploy to Railway
The server deploys to Railway the same way as the MCP server from tutorial three.
railway init
railway variables set ANTHROPIC_API_KEY=your-key
railway variables set SAHHA_MCP_URL=https://your-mcp-server.up.railway.app/mcp
railway variables set SAHHA_MCP_TOKEN=your-token
railway variables set PROFILE_IDS=user-123,user-456
railway upOnce deployed, update your Sahha webhook URL from the ngrok URL to your Railway URL:
https://your-agent-server.up.railway.app/webhooks/sahha:::info Persistent storage
The in-memory insightStore in this tutorial resets every time the server restarts. For production, replace it with a database — Supabase, Firebase, or Postgres on Railway all work well with this pattern.
:::
Choosing between webhook and cron
Both patterns use the same agent. The choice comes down to your use case:
| Webhook | Cron | |
|---|---|---|
| When it runs | As soon as Sahha generates new data | On a fixed schedule |
| Best for | Real-time, per-user experiences | Nightly digests, batch processing |
| Profile scope | One profile per trigger | Many profiles per run |
| Latency | Low — runs within seconds of new data | Fixed — runs at scheduled time |
Many production apps use both: webhooks for real-time triggers on active users, and a nightly cron as a catch-up pass for profiles that did not trigger a webhook that day.
What to build next
You now have a complete, production-ready Sahha health agent pipeline:
- MCP server exposing Sahha tools
- Remote deployment on Railway
- Agent querying and analysing health data
- Automated triggers via webhook and cron
From here, natural extensions include storing insights in a database, sending the output to a notification platform like OneSignal or Braze, or building a UI that displays the structured insight for each user.