AI Patient Vital Signs Monitor

Build a real-time healthcare monitoring system with Go, Gemini AI, and Zerops.

Introduction

30 Second Summary

In a hospital, a patient's heart rate slowly rises while their oxygen saturation dips by just 1% every few minutes. A simple threshold alert stays silent because no single number crossed the red line. But the pattern tells a story of early respiratory failure.

In this project, you will build MedAlert, a real-time patient vital signs monitoring system deployed on Zerops. Simulated devices push vitals to a Go API, Gemini AI detects multi-signal anomalies, and alerts stream live to a dashboard via WebSocket.

What You'll Build

You'll open a browser, see patient vitals updating in real-time, trigger a simulated anomaly, and watch an AI-generated alert flash on screen explaining exactly what pattern it detected and why it matters.

By the end of this project, you'll have:

  • A multi-service architecture running on Zerops with four interconnected services (Go API, static frontend, PostgreSQL, Valkey) that you can point judges to as a live URL.
  • A real-time streaming pipeline where simulated patient devices push vitals every few seconds and connected dashboards update instantly via WebSocket and Valkey pub/sub.
  • An AI anomaly detection system that catches multi-signal patterns simple thresholds would miss, like early deterioration where O2 declines while heart rate compensates upward.
  • Secret Mission: Add a patient report generation endpoint that summarizes 24 hours of vitals with AI-generated trend analysis and recommendations.

Are there any prerequisites?

This project assumes you are comfortable writing Go, familiar with HTTP servers and SQL, and have a basic understanding of WebSockets and JSON APIs. You do not need prior experience with Zerops or the Gemini API.

Before We Start

Before diving into code, let's lock in what you're building and the healthcare problem it solves.

Set Up Zerops and Scaffold the Project

Before writing any application code, you need the infrastructure in place to run it. Zerops provisions managed services for your Go API, static frontend, database, and cache. All four need to be running before you deploy.

In this step, you will create a Zerops account, install the CLI tool, and spin up all four services with a single YAML import. Then you will scaffold the local Go project that you will build and deploy throughout the rest of this project.

In this step, get ready to:
  • Sign up for Zerops, install zCLI, and authenticate.
  • Create a multi-service Zerops project with Go API, nginx frontend, PostgreSQL, and Valkey.
  • Scaffold the local project directory with a Go module and placeholder files.
Sign up for Zerops and install zCLI

Zerops is the PaaS that will host all four services. The zCLI tool lets you deploy code and manage services from your terminal.

  • Open https://app.zerops.io in your browser and create an account.
  • Complete the sign-up flow to claim your $65 free credit (no credit card required).

What is the $65 credit?

Zerops gives every new account $65 to spend on compute, storage, and network. The Lightweight core (which this project uses) is free, so the credit covers any resource usage beyond the free baseline.

  • Install zCLI by running this command in your terminal:
curl -L https://zerops.io/zcli/install.sh | sh
  • Verify the installation by running:
zcli --version

✔️ I see a version number

zCLI is installed and ready. Continue to the next instruction below.

ⓧ Command not found

The install script may not have added zCLI to your PATH. Try opening a new terminal window and running the command again.

If that does not help, check that /usr/bin or /usr/local/bin is in your PATH by running:

echo $PATH

If neither directory is listed, add one to your shell profile and restart your terminal.

Now you need to authenticate zCLI with your Zerops account using a Personal Access Token.

  • In the Zerops GUI, click your avatar in the top-right corner.
  • Go to Access Tokens management.
  • Click Generate a new access token.
  • Copy the token that appears. You will only see it once.
  • Back in your terminal, log in by running this command (replace your-token-here with the token you just copied):
zcli login your-token-here

Login failed?

Make sure you copied the full token with no leading or trailing spaces. If the token expired or was deleted, generate a new one in the Zerops GUI and try again.

Help me troubleshoot my zCLI login

Create the multi-service Zerops project

Instead of creating each service one by one, Zerops lets you import an entire project from a single YAML file. This import defines four services that form the backbone of MedAlert.

  • In the Zerops dashboard, click Import a project in the Projects section.
  • Paste the following YAML into the import editor:
project:
  name: medalert
services:
  - hostname: app
    type: go@latest
    minContainers: 1
    maxContainers: 3
    enableSubdomainAccess: true
  - hostname: frontend
    type: nginx@latest
    minContainers: 1
    maxContainers: 1
    enableSubdomainAccess: true
  - hostname: db
    type: postgresql:single@18
  - hostname: cache
    type: valkey@7.2
  • Click Import project to start provisioning.

What does this YAML create?

  • app is the Go API service. It scales from 1 to 3 containers and has a public subdomain so you can reach it from outside Zerops.
  • frontend is a static nginx service for the dashboard HTML/JS. It also gets a public subdomain.
  • db is a single-node PostgreSQL 18 instance for storing vitals and alerts.
  • cache is a Valkey 7.2 instance used for pub/sub messaging between the API and connected WebSocket clients.

Wait about 30 seconds for all services to spin up. Once provisioning finishes, you should see all four services listed in your project dashboard with a green running status.

Services stuck in a non-running state?

Database and cache services can take up to a minute to initialise. Refresh the page after 60 seconds. If a service still shows an error, delete the project and re-import the YAML.

Help me troubleshoot my Zerops import

Scaffold the local project

With the cloud infrastructure ready, you need a local Go project to develop against. This creates the directory structure, initialises the Go module, and sets up the Gemini API key you will use for anomaly detection later.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and initialise the Go module by running these commands:
mkdir medalert
cd medalert
go mod init medalert
  • Confirm the module was created by running:
cat go.mod

You should see module medalert and go 1.26.5 in the output.

  • Create the placeholder project files by running:
touch main.go zerops.yaml
mkdir frontend
  • Confirm the files exist by running:
ls

You should see frontend, go.mod, main.go, and zerops.yaml in the output.

  • Set your Gemini API key as a local environment variable by running the following (replace your-api-key-here with your actual key):
export GOOGLE_API_KEY=your-api-key-here

Why set the key locally?

You will use this key to test the Gemini integration locally before deploying. On Zerops, you will inject it as a service environment variable in a later step.

Not sure where your Gemini key is?

If you have not saved it somewhere accessible, regenerate one at the Google AI Studio console (https://aistudio.google.com/apikey).

Help me find my Gemini API key

Before you move on, what do you expect to see when you run zcli --version one more time?

  • Confirm everything is connected end-to-end by running:
zcli --version

You should see a version number printed. Pair this with the Zerops GUI showing all four services (app, frontend, db, cache) in a running state. That confirms your cloud infrastructure and local tooling are both ready.

Your infrastructure is live and your local project is scaffolded. Next up, you will write the Go API that accepts vital sign data and stores it in PostgreSQL.

Build the Vitals Ingestion API

Your Zerops infrastructure is running, but the services have no application code yet. The core of any patient monitoring system is its ability to accept and store vital sign data from devices.

In this step, you will write a Go API that ingests vitals via a REST endpoint, stores them in PostgreSQL, and includes a built-in simulator that generates realistic patient data every few seconds.

In this step, get ready to:
  • Write a Go API with endpoints for ingesting vitals, querying patients, and health checks.
  • Add a patient simulator that generates realistic data with occasional anomalies.
  • Deploy to Zerops and confirm data persists across requests.
Set up the data model and dependencies

Every vital sign reading from a patient device has the same shape: a patient ID plus numeric values for heart rate, blood pressure, oxygen saturation, and temperature. You will define this structure first, then set up the database and cache connections.

  • Open main.go in your editor and add the package declaration, imports, global variables, and the Vitals struct by pasting the following code:
package main

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"math/rand"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"

	_ "github.com/lib/pq"
	"github.com/redis/go-redis/v9"
	"context"
)

// Global database and cache clients
var db *sql.DB
var rdb *redis.Client
var simulators sync.Map

// Vitals represents a single patient reading
type Vitals struct {
	PatientID   string  `json:"patient_id"`
	HeartRate   int     `json:"heart_rate"`
	BPSystolic  int     `json:"blood_pressure_systolic"`
	BPDiastolic int     `json:"blood_pressure_diastolic"`
	O2Sat       float64 `json:"o2_saturation"`
	Temperature float64 `json:"temperature"`
}

What does this code do?

The Vitals struct maps to the JSON that simulated devices will POST to your API. Each field uses a JSON tag so Go can automatically parse incoming requests.

The three global variables (db, rdb, simulators) hold your PostgreSQL connection pool, Valkey client, and a thread-safe map tracking active simulators.

  • Save main.go.
  • Download the two dependencies by running:
go get github.com/lib/pq github.com/redis/go-redis/v9

You should see output showing Go downloading and adding the modules to your go.mod file.

Seeing an error from go get?

If you see a parse error, make sure main.go is saved and the import block has matching parentheses. go get needs to parse your imports to resolve the right modules.

help me fix a go get error when downloading dependencies

  • Now add the database initialization, cache initialization, and vitals storage functions below the struct in main.go:
func initDB() {
	var err error
	// Connect to PostgreSQL using the Zerops-provided connection string
	db, err = sql.Open("postgres", os.Getenv("DB_URL"))
	if err != nil {
		log.Fatal(err)
	}
	// Create the vitals table if it doesn't already exist
	_, err = db.Exec(`CREATE TABLE IF NOT EXISTS vitals (
		id SERIAL PRIMARY KEY,
		patient_id TEXT NOT NULL,
		heart_rate INT,
		bp_systolic INT,
		bp_diastolic INT,
		o2_saturation FLOAT,
		temperature FLOAT,
		recorded_at TIMESTAMP DEFAULT NOW()
	)`)
	if err != nil {
		log.Fatal(err)
	}
}

func initCache() {
	// Parse the Zerops-provided Valkey connection string
	opts, err := redis.ParseURL(os.Getenv("CACHE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	rdb = redis.NewClient(opts)
}

func storeVitals(v Vitals) error {
	_, err := db.Exec(`INSERT INTO vitals (patient_id, heart_rate, bp_systolic, bp_diastolic, o2_saturation, temperature) VALUES ($1,$2,$3,$4,$5,$6)`,
		v.PatientID, v.HeartRate, v.BPSystolic, v.BPDiastolic, v.O2Sat, v.Temperature)
	return err
}

What does this code do?

initDB connects to PostgreSQL using the DB_URL environment variable (which Zerops will inject at runtime) and creates the vitals table with columns for each vital sign plus a timestamp.

initCache connects to Valkey (the Redis-compatible cache service) using CACHE_URL. storeVitals is a helper that inserts a single reading into the database.

  • Save main.go.
Build the HTTP handlers

With the persistence layer ready, you need HTTP handlers that expose the data over REST endpoints. These are the entry points that devices and the dashboard will call.

  • Add the POST and GET handlers below the storeVitals function in main.go:
func handlePostVitals(w http.ResponseWriter, r *http.Request) {
	var v Vitals
	if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
		http.Error(w, err.Error(), 400)
		return
	}
	if err := storeVitals(v); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	w.WriteHeader(201)
}

func handleGetPatients(w http.ResponseWriter, r *http.Request) {
	// Get each patient's most recent vitals reading
	rows, err := db.Query(`SELECT DISTINCT ON (patient_id) patient_id, heart_rate, bp_systolic, bp_diastolic, o2_saturation, temperature, recorded_at FROM vitals ORDER BY patient_id, recorded_at DESC`)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	defer rows.Close()
	var patients []map[string]interface{}
	for rows.Next() {
		var pid string
		var hr, bps, bpd int
		var o2, temp float64
		var recAt time.Time
		rows.Scan(&pid, &hr, &bps, &bpd, &o2, &temp, &recAt)
		patients = append(patients, map[string]interface{}{"patient_id": pid, "heart_rate": hr, "bp_systolic": bps, "bp_diastolic": bpd, "o2_saturation": o2, "temperature": temp, "recorded_at": recAt})
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(patients)
}

What do these handlers do?

handlePostVitals decodes a JSON body into a Vitals struct and stores it in PostgreSQL. It returns HTTP 201 on success.

handleGetPatients uses PostgreSQL's DISTINCT ON to fetch only the most recent reading per patient, so the dashboard always shows current state.

  • Add the health check handler below handleGetPatients:
func handleHealth(w http.ResponseWriter, r *http.Request) {
	// Verify PostgreSQL is reachable
	if err := db.Ping(); err != nil {
		http.Error(w, "db down", 500)
		return
	}
	// Verify Valkey is reachable
	if err := rdb.Ping(context.Background()).Err(); err != nil {
		http.Error(w, "cache down", 500)
		return
	}
	w.WriteHeader(200)
	fmt.Fprint(w, "ok")
}

Why does health check ping both services?

Zerops uses a readiness check to decide whether a container should receive traffic. By pinging both PostgreSQL and Valkey, you guarantee that a container only goes live when all its dependencies are reachable. If either is down, Zerops will spin up a new container automatically.

  • Save main.go.
Add the patient simulator and deploy

Testing a monitoring system requires a constant stream of data. Instead of needing real IoT devices, you will build a simulator endpoint that starts a background goroutine generating realistic vital signs every 3 seconds.

  • Add the simulator functions and the main function below handleHealth in main.go:
func simulatePatient(patientID string) {
	// Only start one simulator per patient
	if _, loaded := simulators.LoadOrStore(patientID, true); loaded {
		return
	}
	go func() {
		for {
			// Generate normal-range vitals
			v := Vitals{
				PatientID:   patientID,
				HeartRate:   70 + rand.Intn(30),
				BPSystolic:  110 + rand.Intn(30),
				BPDiastolic: 70 + rand.Intn(20),
				O2Sat:       95.0 + rand.Float64()*4,
				Temperature: 36.5 + rand.Float64()*1.5,
			}
			// 10% chance of anomalous values
			if rand.Intn(10) == 0 {
				v.HeartRate = 130 + rand.Intn(30)
				v.O2Sat = 88.0 + rand.Float64()*4
			}
			storeVitals(v)
			time.Sleep(3 * time.Second)
		}
	}()
}

How does the simulator create anomalies?

Normal vitals stay in healthy ranges (heart rate 70-100, O2 95-99%). On 10% of readings, the simulator injects anomalous values: elevated heart rate (130-160) combined with low oxygen saturation (88-92%). This multi-signal pattern is what the AI in Step 4 will learn to detect.

  • Add the simulate HTTP handler and the main function directly below simulatePatient:
func handleSimulate(w http.ResponseWriter, r *http.Request) {
	// Extract patient ID from the URL path
	parts := strings.Split(r.URL.Path, "/")
	patientID := parts[len(parts)-1]
	simulatePatient(patientID)
	w.WriteHeader(200)
	fmt.Fprintf(w, "Simulator started for %s", patientID)
}

func main() {
	initDB()
	initCache()
	// Register all API routes
	http.HandleFunc("/api/vitals", handlePostVitals)
	http.HandleFunc("/api/patients", handleGetPatients)
	http.HandleFunc("/health", handleHealth)
	http.HandleFunc("/api/simulate/", handleSimulate)
	log.Println("Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

What does main wire together?

The main function initializes the database and cache connections, registers all four route handlers, and starts an HTTP server on port 8080. This is the port that Zerops will route traffic to via its readiness check.

  • Save main.go.
  • Verify the code compiles by running:
go mod tidy && go build -o medalert .

You should see no errors, and a medalert binary appears in your project folder.

Seeing a compile error?

Check that every import in the import (...) block has matching quotes and that no function name has a typo. Common issues: missing comma between struct fields, unmatched braces around the CREATE TABLE SQL string, or a misspelled package name.

help me fix a Go compile error in my medalert API

  • Now configure the Zerops build pipeline. Open zerops.yaml and paste the following:
zerops:
  - setup: app
    build:
      base: go@latest
      buildCommands:
        - go build -o medalert .
      deployFiles:
        - medalert
    run:
      base: go@latest
      ports:
        - port: 8080
          httpSupport: true
      readinessCheck:
        httpGet:
          port: 8080
          path: /health
      envVariables:
        DB_URL: ${db_connectionString}
        CACHE_URL: ${cache_connectionString}
        GOOGLE_API_KEY: <your-gemini-api-key>

What does zerops.yaml configure?

The build section compiles your Go code on Zerops' build servers and deploys only the binary. The run section defines the runtime: port 8080 with HTTP support, a readiness check hitting /health, and environment variables.

The ${db_connectionString} and ${cache_connectionString} syntax is Zerops' cross-service referencing. At runtime, these resolve to the actual PostgreSQL and Valkey connection strings from your db and cache services.

  • Save zerops.yaml.
  • Deploy the API to Zerops by running:
zcli push

This uploads your code, triggers the build pipeline on Zerops, and deploys the resulting binary. The process takes 1-2 minutes. Wait until you see a success message in the terminal.

Build failing on Zerops?

Check the build log in the Zerops GUI (click on the app service, then the **Build log** tab). Common causes: a missing go.sum file (run go mod tidy locally first), or a typo in zerops.yaml indentation.

help me debug a failed Zerops build for my Go app

Before you test, think about what you expect to happen. When you POST a simulated patient, where does the data go? Will it survive a container restart?

  • Find your app's subdomain URL in the Zerops GUI (click the app service, look in the **Public access** section for the subdomain).
  • Start the simulator for a test patient by running:
curl -X POST https://[[APP_SUBDOMAIN="your-app-subdomain.zerops.app"]]/api/simulate/patient-1
  • Wait about 10 seconds for a few readings to generate, then query the patients list:
curl https://[[APP_SUBDOMAIN="your-app-subdomain.zerops.app"]]/api/patients

You should see a JSON array containing patient-1 with their latest vital signs (heart rate, blood pressure, O2 saturation, and temperature).

Getting an empty response or connection error?

If the curl returns an error, check that the readiness check passed in the Zerops GUI (the app service should show a green status). If it shows unhealthy, the /health endpoint cannot reach the database or cache. Verify your zerops.yaml has DB_URL: ${db_connectionString} (not ${database_connectionString} or another variation).

help me debug why my Zerops Go API is not reaching PostgreSQL

✔️ Awesome, I've got everything!

Great. Double check you have saved both main.go and zerops.yaml.

ⓧ I'd like to double check the full code

Your main.go should contain these sections in order (scroll up to confirm each block is present):

  • Package declaration, imports, global variables, and Vitals struct.
  • initDB, initCache, and storeVitals functions.
  • handlePostVitals and handleGetPatients handlers.
  • handleHealth handler.
  • simulatePatient, handleSimulate, and main functions.

Your zerops.yaml should match this exactly:

zerops:
  - setup: app
    build:
      base: go@latest
      buildCommands:
        - go build -o medalert .
      deployFiles:
        - medalert
    run:
      base: go@latest
      ports:
        - port: 8080
          httpSupport: true
      readinessCheck:
        httpGet:
          port: 8080
          path: /health
      envVariables:
        DB_URL: ${db_connectionString}
        CACHE_URL: ${cache_connectionString}
        GOOGLE_API_KEY: <your-gemini-api-key>

Your API is live and storing patient data in PostgreSQL. Next up, you will add real-time WebSocket streaming so the dashboard receives vitals the instant they arrive, instead of polling the API.

Add Real-Time WebSocket Streaming via Valkey Pub/Sub

Your API stores vitals in PostgreSQL, but a monitoring dashboard needs instant updates. Polling the REST endpoint every second wastes bandwidth and introduces lag.

WebSockets solve this by keeping a persistent connection open between the server and every dashboard client. Combined with Valkey pub/sub, the API can broadcast each new vital reading the instant it arrives. Every connected client receives the data simultaneously without making a single extra request.

In this step, get ready to:
  • Build a WebSocket hub that manages multiple connected clients.
  • Publish every new vital reading to a Valkey channel so broadcasting is decoupled from ingestion.
  • Deploy and verify real-time streaming end-to-end.
Add the WebSocket package and client hub

The golang.org/x/net/websocket package handles the HTTP-to-WebSocket upgrade automatically. You wrap a handler function with websocket.Handler and register it on your mux.

  • Install the WebSocket package by running this command in your medalert folder:
go get golang.org/x/net@v0.38.0
  • Open main.go and replace the entire import (...) block with this updated version:
import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"math/rand"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"

	_ "github.com/lib/pq"
	"github.com/redis/go-redis/v9"
	"golang.org/x/net/websocket"
)

What changed in the imports?

  • context moved to its alphabetical position at the top of the standard library group.
  • golang.org/x/net/websocket replaces the bare context that was previously at the bottom of the third-party group.

Next, you need a central hub to track every connected WebSocket client and broadcast messages to all of them at once.

  • Add this line directly below var simulators sync.Map:
var hub = newHub()
  • Add the Hub struct and its constructor below the Vitals struct:
type Hub struct {
	clients   map[*websocket.Conn]bool
	broadcast chan []byte
	mu        sync.Mutex
}

func newHub() *Hub {
	return &Hub{
		clients:   make(map[*websocket.Conn]bool),
		broadcast: make(chan []byte, 256),
	}
}

What does the Hub do?

The Hub holds a map of every active WebSocket connection. Its broadcast channel receives messages that need to go out to all clients. A mutex protects the map from concurrent access since multiple goroutines will add and remove connections.

  • Add the three Hub methods directly below the newHub function:
func (h *Hub) run() {
	for msg := range h.broadcast {
		h.mu.Lock()
		for conn := range h.clients {
			websocket.Message.Send(conn, string(msg))
		}
		h.mu.Unlock()
	}
}

func (h *Hub) add(conn *websocket.Conn) {
	h.mu.Lock()
	h.clients[conn] = true
	h.mu.Unlock()
}

func (h *Hub) remove(conn *websocket.Conn) {
	h.mu.Lock()
	delete(h.clients, conn)
	h.mu.Unlock()
}

What do these methods do?

  • run() loops forever, reading from the broadcast channel and sending each message to every connected client.
  • add() registers a new WebSocket connection when a client connects.
  • remove() deletes a connection when the client disconnects.
  • Save main.go and verify it compiles by running:
go build -o medalert .

You should see no output, meaning the build succeeded with no errors.

Seeing a compilation error?

  • If Go cannot find golang.org/x/net/websocket, confirm you ran go get golang.org/x/net@v0.38.0 and that go.mod now lists the dependency.
  • If newHub is reported as undefined, make sure the Hub struct and newHub function appear above the var hub = newHub() line in the file.

help me fix a Go compilation error in my WebSocket hub code

Publish vitals to Valkey on every write

Right now, storing vitals in PostgreSQL is a dead end for real-time clients. By publishing to a Valkey channel after each write, you decouple ingestion from broadcasting.

  • Add the publishVitals function directly above your existing storeVitals function:
// publishVitals sends the vital reading to the Valkey pub/sub channel
func publishVitals(v Vitals) {
	data, _ := json.Marshal(map[string]interface{}{"type": "vitals", "data": v})
	rdb.Publish(context.Background(), "vitals:stream", data)
}

What does publishVitals do?

It wraps the vital reading in a JSON envelope with a type field set to vitals, then publishes it to the vitals:stream channel on Valkey. Any subscriber listening on that channel receives the message instantly.

Now you need to call publishVitals every time a vital is successfully stored.

  • Find your existing storeVitals function. It currently looks like this:
func storeVitals(v Vitals) error {
	_, err := db.Exec(`INSERT INTO vitals (patient_id, heart_rate, bp_systolic, bp_diastolic, o2_saturation, temperature) VALUES ($1,$2,$3,$4,$5,$6)`,
		v.PatientID, v.HeartRate, v.BPSystolic, v.BPDiastolic, v.O2Sat, v.Temperature)
	return err
}
  • Replace it with this version that publishes to Valkey after a successful insert:
func storeVitals(v Vitals) error {
	_, err := db.Exec(`INSERT INTO vitals (patient_id, heart_rate, bp_systolic, bp_diastolic, o2_saturation, temperature) VALUES ($1,$2,$3,$4,$5,$6)`,
		v.PatientID, v.HeartRate, v.BPSystolic, v.BPDiastolic, v.O2Sat, v.Temperature)
	if err == nil {
		publishVitals(v)
	}
	return err
}

Why publish only on success?

The if err == nil guard ensures you only broadcast vitals that actually made it into the database. If the insert fails, no misleading data reaches connected clients.

  • Save main.go and verify the build:
go build -o medalert .

No output means the build succeeded. Both the simulator and the POST endpoint now publish through the same storeVitals path, so every vital reaches Valkey automatically.

Build failing after storeVitals changes?

  • Make sure publishVitals appears above storeVitals in the file so Go can resolve the function call.
  • Check that the context import is in your import block. The rdb.Publish call needs context.Background().

help me fix my publishVitals function

Add the WebSocket handler, subscribe to Valkey, and deploy

The final pieces connect everything together. A WebSocket handler registers new clients with the hub, and a Valkey subscriber feeds incoming messages into the hub's broadcast channel.

  • Add these two functions above main():
// handleWS registers a WebSocket client and keeps the connection alive
func handleWS(conn *websocket.Conn) {
	hub.add(conn)
	defer hub.remove(conn)
	var msg string
	for {
		if err := websocket.Message.Receive(conn, &msg); err != nil {
			break
		}
	}
}

// startValkeySub subscribes to Valkey channels and feeds the hub
func startValkeySub() {
	go func() {
		pubsub := rdb.Subscribe(context.Background(), "vitals:stream", "alerts:stream")
		ch := pubsub.Channel()
		for msg := range ch {
			hub.broadcast <- []byte(msg.Payload)
		}
	}()
}

What do these functions do?

  • handleWS adds the connection to the hub, then blocks reading from the client. When the client disconnects, the read returns an error and the deferred remove cleans up.
  • startValkeySub spins up a goroutine that subscribes to both vitals:stream and alerts:stream (used in the next step). Every message arriving on those channels gets pushed into the hub's broadcast channel for all connected clients.

Now wire the hub, subscriber, and WebSocket route into your main() function.

  • Find your main() function. It currently starts like this:
func main() {
	initDB()
	initCache()
	http.HandleFunc("/api/vitals", handlePostVitals)
  • Replace the entire main() function with this updated version:
func main() {
	initDB()
	initCache()
	go hub.run()
	startValkeySub()
	http.HandleFunc("/api/vitals", handlePostVitals)
	http.HandleFunc("/api/patients", handleGetPatients)
	http.HandleFunc("/health", handleHealth)
	http.HandleFunc("/api/simulate/", handleSimulate)
	http.Handle("/ws", websocket.Handler(handleWS))
	log.Println("Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

What's new in main()?

  • go hub.run() starts the broadcast loop in its own goroutine so it runs concurrently with the HTTP server.
  • startValkeySub() begins listening for messages on Valkey channels.
  • http.Handle("/ws", websocket.Handler(handleWS)) registers the WebSocket endpoint. Note this uses Handle (not HandleFunc) because websocket.Handler returns an http.Handler.
  • Save main.go and confirm it compiles:
go build -o medalert .

No output means you are ready to deploy.

✔️ Awesome, I've got everything!

Great. Confirm you have saved main.go and the build passed with no errors.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these chunks exists in your main.go, in order:

  • Updated import block with context at the top and golang.org/x/net/websocket in the third-party group.
  • var hub = newHub() after the other var declarations.
  • Vitals struct (unchanged).
  • Hub struct with newHub constructor.
  • Hub methods: run(), add(), remove().
  • publishVitals function.
  • storeVitals with the if err == nil { publishVitals(v) } addition.
  • handleWS and startValkeySub functions.
  • Updated main() with go hub.run(), startValkeySub(), and the /ws route.
  • Deploy the updated API to Zerops by running:
zcli push

Wait for the deploy to complete. The readiness check on /health ensures the new container is fully connected to PostgreSQL and Valkey before receiving traffic.

Before you test, think about what you expect to happen. If you connect to the WebSocket and then trigger the simulator, do you think vitals will appear on the WebSocket connection without any page refresh?

  • Open your browser and navigate to your app's subdomain URL (visible in the Zerops GUI under the app service's Public access & internal ports section).
  • Open the browser's developer console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows/Linux).
  • Paste this JavaScript snippet to connect to your WebSocket endpoint:
const ws = new WebSocket(`wss://${location.host}/ws`);
ws.onmessage = (e) => console.log('VITAL:', JSON.parse(e.data));
  • In a separate terminal, start the simulator for a test patient by running:
curl -X POST https://[[APP_SUBDOMAIN="your app subdomain, e.g. app-xxx-8080.prg1.zerops.app"]]/api/simulate/patient-1

Within 3 seconds, you should see VITAL: messages appearing in your browser console with live heart rate, blood pressure, O2 saturation, and temperature data. Each message arrives automatically with no page refresh.

Not seeing messages in the console?

  • Confirm the deploy finished successfully in the Zerops GUI. Look for a green status on the app service.
  • Check the WebSocket URL uses wss:// (not ws://). Zerops serves over HTTPS, so WebSocket connections must use the secure protocol.
  • If the console shows a connection error, verify your subdomain URL is correct. Copy it directly from the Zerops GUI.

help me debug why my WebSocket connection to Zerops is not receiving messages

Your monitoring system now pushes data the instant it arrives. Every connected dashboard receives live updates without polling. Next up, you will plug Gemini AI into this pipeline so it can analyze patterns across multiple vital signs and flag anomalies in real time.

Integrate Gemini AI for Anomaly Detection

Your monitoring system streams vitals in real-time, but it has no intelligence yet. A basic threshold check (heart rate above 120 triggers an alert) is what every monitoring system does.

The interesting patterns are multi-signal. O2 saturation slowly dropping while heart rate compensates upward over several readings signals early respiratory failure. In this step, you will wire Gemini AI into the ingestion pipeline to catch exactly these patterns.

In this step, get ready to:
  • Add AI-powered anomaly detection that analyzes multi-signal vital patterns using Gemini.
  • Store and broadcast AI-generated alerts via Valkey pub/sub to connected WebSocket clients.
  • Deploy and verify AI alerts appear in the live WebSocket stream.
Add the Gemini SDK and define alert types

Before writing the analysis logic, you need the google.golang.org/genai SDK and new data types to represent alerts.

  • Install the Gemini Go SDK by running this command in your medalert directory:
go get google.golang.org/genai@v1.14.0

You should see Go download the package and update your go.mod and go.sum files.

Download failing?

Make sure you are inside the medalert folder that contains go.mod. Run ls go.mod to confirm.

help me troubleshoot my Go module download

  • In main.go, add the new import and types by finding the import block and adding the Gemini SDK import, then adding the new structs below your existing Vitals struct. Copy this code and paste it directly below your Vitals struct:
type Alert struct {
	PatientID      string `json:"patient_id"`
	Severity       string `json:"severity"`
	Summary        string `json:"summary"`
	Recommendation string `json:"recommendation"`
	CreatedAt      time.Time `json:"created_at"`
}

type GeminiResponse struct {
	Severity       string `json:"severity"`
	Summary        string `json:"summary"`
	Recommendation string `json:"recommendation"`
}

What do these types do?

  • Alert represents a stored alert with patient ID, severity level, a human-readable summary, and a recommendation.
  • GeminiResponse maps to the exact JSON shape you will ask Gemini to return, making it easy to unmarshal the AI output.
  • Also add the genai import to your import block. Find this line in your imports:
	"golang.org/x/net/websocket"
  • Add the Gemini import directly below it so the import block ends with:
	"golang.org/x/net/websocket"
	"google.golang.org/genai"
  • Next, add the per-patient reading counter. Find this line near the top of the file:
var hub = newHub()
  • Add this line directly below it:
var patientReadingCount sync.Map
  • Now add the alerts table creation inside your initDB() function. Find the closing } of the initDB function and add this code just before it:
	_, err = db.Exec(`CREATE TABLE IF NOT EXISTS alerts (
		id SERIAL PRIMARY KEY,
		patient_id TEXT NOT NULL,
		severity TEXT,
		summary TEXT,
		recommendation TEXT,
		created_at TIMESTAMP DEFAULT NOW()
	)`)
	if err != nil {
		log.Fatal(err)
	}

What does this table store?

The alerts table persists every AI-generated alert so you can query them later via the API. Each row stores the patient, severity level, a summary of what Gemini detected, and a recommended action.

  • Verify the code compiles by running:
go build .

You should see no output, meaning the build succeeded.

Seeing import errors?

If you see an error about genai being unused, that is expected at this stage. The import will be used once you add the analyzeVitals function in the next chunk. You can temporarily comment out the import line to confirm the rest compiles.

help me fix my Go build errors after adding the genai import

Build the AI analysis function

The core of the AI integration is a function that fetches the last 10 readings for a patient, sends them to gemini-2.5-flash, and processes the response into an actionable alert.

  • Add the first part of the analyzeVitals function below your publishVitals function. This half fetches readings and builds the prompt:
func analyzeVitals(patientID string) {
	// Fetch the last 10 readings for this patient
	rows, err := db.Query(`SELECT heart_rate, bp_systolic, bp_diastolic, o2_saturation, temperature, recorded_at FROM vitals WHERE patient_id=$1 ORDER BY recorded_at DESC LIMIT 10`, patientID)
	if err != nil {
		return
	}
	defer rows.Close()
	var readings []map[string]interface{}
	for rows.Next() {
		var hr, bps, bpd int
		var o2, temp float64
		var recAt time.Time
		rows.Scan(&hr, &bps, &bpd, &o2, &temp, &recAt)
		readings = append(readings, map[string]interface{}{"heart_rate": hr, "bp_systolic": bps, "bp_diastolic": bpd, "o2_saturation": o2, "temperature": temp, "recorded_at": recAt})
	}
	if len(readings) == 0 {
		return
	}
	// Build a structured prompt with the readings as JSON
	readingsJSON, _ := json.Marshal(readings)
	prompt := fmt.Sprintf(`You are a medical vital signs analyzer. Given these readings for patient %s, identify any concerning patterns. Respond with JSON only, no markdown: {"severity": "critical"|"warning"|"normal", "summary": "...", "recommendation": "..."}\n\nReadings: %s`, patientID, string(readingsJSON))

What is happening in this code?

  • The query fetches the 10 most recent vitals for the given patient, ordered newest first.
  • Readings are collected into a slice of maps so they can be serialized to JSON for the prompt.
  • The prompt instructs Gemini to respond with structured JSON only (no markdown), which makes parsing the response reliable.
  • Now add the second part of the function directly below (still inside the same function). This half calls Gemini and processes the response:
	// Call Gemini API with the readings
	ctx := context.Background()
	client, err := genai.NewClient(ctx, &genai.ClientConfig{
		APIKey:  os.Getenv("GOOGLE_API_KEY"),
		Backend: genai.BackendGeminiAPI,
	})
	if err != nil {
		return
	}
	result, err := client.Models.GenerateContent(ctx, "gemini-2.5-flash", genai.Text(prompt), nil)
	if err != nil {
		return
	}
	// Parse the AI response into a structured alert
	var gr GeminiResponse
	respText := result.Text()
	if err := json.Unmarshal([]byte(respText), &gr); err != nil {
		return
	}
	// Store and broadcast alerts for warning or critical severity
	if gr.Severity == "warning" || gr.Severity == "critical" {
		db.Exec(`INSERT INTO alerts (patient_id, severity, summary, recommendation) VALUES ($1,$2,$3,$4)`, patientID, gr.Severity, gr.Summary, gr.Recommendation)
		alertData, _ := json.Marshal(map[string]interface{}{"type": "alert", "data": Alert{PatientID: patientID, Severity: gr.Severity, Summary: gr.Summary, Recommendation: gr.Recommendation, CreatedAt: time.Now()}})
		rdb.Publish(context.Background(), "alerts:stream", alertData)
	}
}

What does the Gemini call do?

  • genai.NewClient creates a Gemini client using your API key and the BackendGeminiAPI backend.
  • GenerateContent sends the prompt to gemini-2.5-flash and returns structured text.
  • Only alerts with severity warning or critical are stored in PostgreSQL and published to the alerts:stream Valkey channel. Normal readings are silently discarded.
  • Verify the function compiles by running:
go build .

No output means success. The genai import is now used and the function is syntactically complete.

Seeing 'undefined: genai' errors?

Run go mod tidy to ensure all dependencies are resolved. Also confirm the import path is exactly google.golang.org/genai with no trailing version suffix.

help me fix undefined genai errors in my Go build

Wire the analyzer, add the alerts endpoint, and deploy

The analysis function exists but nothing calls it yet. You need to trigger it every 5th reading per patient to stay within free tier limits, and expose an endpoint for querying stored alerts.

  • In main.go, find the existing storeVitals function. Locate this block inside it:
	if err == nil {
		publishVitals(v)
	}
  • Replace that block with the following, which adds the reading counter and triggers AI analysis every 5th reading:
	if err == nil {
		publishVitals(v)
		// Increment per-patient counter and analyze every 5th reading
		val, _ := patientReadingCount.LoadOrStore(v.PatientID, 0)
		count := val.(int) + 1
		patientReadingCount.Store(v.PatientID, count)
		if count%5 == 0 {
			go analyzeVitals(v.PatientID)
		}
	}

Why every 5th reading?

Calling Gemini on every single vital sign would exhaust your free tier quota quickly. Every 5th reading means one analysis every 15 seconds per patient (since the simulator sends vitals every 3 seconds). This gives Gemini enough data points to spot trends without burning through your API allocation.

  • Now add the handleGetAlerts function below your existing handleGetPatients function:
func handleGetAlerts(w http.ResponseWriter, r *http.Request) {
	rows, err := db.Query(`SELECT patient_id, severity, summary, recommendation, created_at FROM alerts ORDER BY created_at DESC LIMIT 50`)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	defer rows.Close()
	var alerts []Alert
	for rows.Next() {
		var a Alert
		rows.Scan(&a.PatientID, &a.Severity, &a.Summary, &a.Recommendation, &a.CreatedAt)
		alerts = append(alerts, a)
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(alerts)
}

What does this endpoint return?

It queries the 50 most recent alerts from PostgreSQL, ordered newest first, and returns them as a JSON array. This gives the frontend (and you, during testing) a way to see all historical alerts without needing a live WebSocket connection.

  • Register the new route in your main() function. Find this line:
	http.HandleFunc("/api/patients", handleGetPatients)
  • Add the alerts route directly below it so the two lines read:
	http.HandleFunc("/api/patients", handleGetPatients)
	http.HandleFunc("/api/alerts", handleGetAlerts)
  • Confirm everything compiles cleanly by running:
go build .

No output means the build passed. Time to deploy.

  • Before you deploy, think about what you expect to see. When the simulator fires, every 5th reading triggers a Gemini analysis. If the vitals include anomalous values, what do you think will appear in the WebSocket stream?
  • Deploy the updated API by running:
zcli push

Wait for the build and deploy to complete. You should see a success message from Zerops.

  • Start the simulator to generate readings. Replace the subdomain with your app's subdomain from the Zerops GUI:
curl -X POST https://[[APP_SUBDOMAIN="your-app-subdomain.prg1.zerops.app"]]/api/simulate/patient-1
  • Now connect to the WebSocket to watch for live alerts. In a second terminal, run:
websocat wss://[[APP_SUBDOMAIN="your-app-subdomain.prg1.zerops.app"]]/ws

You will see vitals streaming every 3 seconds. After the 5th reading (about 15 seconds), Gemini analyzes the batch. If the simulator generated anomalous values (10% chance each reading), you will see an alert message appear with a type of alert containing a severity, summary, and recommendation from the AI.

  • You can also verify stored alerts directly by running:
curl https://[[APP_SUBDOMAIN="your-app-subdomain.prg1.zerops.app"]]/api/alerts

You should see a JSON array of alerts with AI-generated summaries describing the detected anomaly patterns.

What if you only see normal readings?

The simulator has a 10% chance per reading to generate anomalous values. Wait 30-60 seconds for more readings to accumulate. Gemini may also classify borderline readings as normal, which means no alert is stored. This is correct behavior.

Not seeing any alerts after a minute?

  • Check that your GOOGLE_API_KEY is set correctly in zerops.yaml. Open the file and confirm the value under envVariables is your actual Gemini API key, not the placeholder.
  • Check the Zerops runtime logs for your app service in the GUI. Look for any error messages related to the Gemini API call.
  • Confirm the alerts table was created by hitting the /api/alerts endpoint. If you get a 500 error, the table creation may have failed.

help me debug why my Gemini AI alerts are not appearing in the WebSocket stream

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and that your deploy completed successfully.

ⓧ I'd like to double check the full code

Your main.go is now too long to show in one block. Scroll up and confirm each piece is present in this order:

  • Import block includes "google.golang.org/genai" as the last import.
  • Global variables include var patientReadingCount sync.Map below var hub = newHub().
  • Alert and GeminiResponse structs sit below the Vitals struct.
  • initDB() creates both vitals and alerts tables.
  • analyzeVitals() function sits below publishVitals() and above storeVitals().
  • storeVitals() increments the counter and calls go analyzeVitals() every 5th reading.
  • handleGetAlerts() function exists below handleGetPatients().
  • main() registers /api/alerts with handleGetAlerts.

Your monitoring system now has intelligence. Gemini analyzes combinations of signals that simple threshold rules would miss entirely. Next up, you will build the real-time dashboard frontend that makes all this data visual and demo-ready.

Build the Real-Time Dashboard Frontend

Right now, your MedAlert system works end-to-end: vitals flow in, AI analyzes patterns, and alerts stream via WebSocket. But judges see none of this unless they open a terminal and read JSON.

In this step, you will build a real-time dashboard that makes the demo visual. Patients list on the left, color-coded vitals updating live in the center, a line chart tracking the last 30 readings, and AI alerts flashing red or yellow as they arrive. This is the moment your project goes from "working backend" to "hackathon demo that wows."

In this step, get ready to:
  • Create a static HTML dashboard with a patient sidebar, vitals grid, and alerts panel.
  • Add Chart.js for real-time line graphs and WebSocket for live data streaming.
  • Deploy the frontend to Zerops as an nginx service.
Create the dashboard layout and styles

The dashboard lives in a single index.html file with embedded CSS and JavaScript. You will build it in stages, starting with the visual structure.

  • Create the frontend/ directory inside your medalert folder by running:
mkdir frontend
  • Create frontend/index.html and paste the following HTML head and CSS styles:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>MedAlert - Patient Vital Signs Monitor</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; display: flex; height: 100vh; }
    #sidebar { width: 220px; background: #1e293b; padding: 16px; overflow-y: auto; }
    #sidebar h2 { font-size: 14px; color: #94a3b8; margin-bottom: 12px; text-transform: uppercase; }
    .patient-item { padding: 10px; border-radius: 8px; cursor: pointer; margin-bottom: 6px; background: #334155; }
    .patient-item:hover, .patient-item.active { background: #3b82f6; }
    #main { flex: 1; padding: 24px; overflow-y: auto; }
    #main h1 { font-size: 24px; margin-bottom: 20px; color: #f1f5f9; }
    .vitals-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
    .vital-card { background: #1e293b; border-radius: 12px; padding: 20px; text-align: center; }
    .vital-card .label { font-size: 12px; color: #94a3b8; margin-bottom: 8px; }
    .vital-card .value { font-size: 36px; font-weight: bold; }
    .vital-card.normal .value { color: #22c55e; }
    .vital-card.warning .value { color: #eab308; }
    .vital-card.critical .value { color: #ef4444; }
    #chart-container { background: #1e293b; border-radius: 12px; padding: 20px; margin-bottom: 24px; }
    #alerts-panel { background: #1e293b; border-radius: 12px; padding: 20px; }
    #alerts-panel h3 { margin-bottom: 12px; color: #f1f5f9; }
    .alert-item { padding: 12px; border-radius: 8px; margin-bottom: 8px; }
    .alert-item.warning { background: #422006; border-left: 4px solid #eab308; }
    .alert-item.critical { background: #450a0a; border-left: 4px solid #ef4444; }
    .alert-item .severity { font-size: 11px; font-weight: bold; text-transform: uppercase; margin-bottom: 4px; }

What does this code do?

This sets up the HTML document head and the first portion of CSS. Chart.js is loaded from a CDN so you can render line graphs without installing anything locally.

The CSS creates a dark-themed layout with a fixed sidebar for patients and a main content area. Each .vital-card class has color variants: green for normal, yellow for warning, red for critical.

  • Directly below what you just pasted, add the remaining CSS rules and the HTML body structure:
    .alert-item.warning .severity { color: #eab308; }
    .alert-item.critical .severity { color: #ef4444; }
    .alert-item .summary { font-size: 13px; color: #e2e8f0; }
    .alert-item .recommendation { font-size: 12px; color: #94a3b8; margin-top: 4px; }
    #connection-status { font-size: 12px; color: #94a3b8; margin-bottom: 16px; }
    #connection-status.connected { color: #22c55e; }
  </style>
</head>
<body>
  <div id="sidebar">
    <h2>Patients</h2>
    <div id="patient-list"></div>
  </div>
  <div id="main">
    <h1>🏥 MedAlert Dashboard</h1>
    <div id="connection-status">Connecting...</div>
    <div class="vitals-grid">
      <div class="vital-card normal" id="card-hr"><div class="label">Heart Rate</div><div class="value" id="val-hr">--</div><div class="label">bpm</div></div>
      <div class="vital-card normal" id="card-bp"><div class="label">Blood Pressure</div><div class="value" id="val-bp">--/--</div><div class="label">mmHg</div></div>
      <div class="vital-card normal" id="card-o2"><div class="label">O2 Saturation</div><div class="value" id="val-o2">--</div><div class="label">%</div></div>
      <div class="vital-card normal" id="card-temp"><div class="label">Temperature</div><div class="value" id="val-temp">--</div><div class="label">°C</div></div>
    </div>
    <div id="chart-container"><canvas id="vitals-chart" height="100"></canvas></div>
    <div id="alerts-panel"><h3>⚠️ AI Alerts</h3><div id="alerts-list"><p style="color:#94a3b8;font-size:13px">No alerts yet.</p></div></div>
  </div>
  <script>

What does this code do?

The body has two sections: a #sidebar for the patient list, and #main for everything else. The main area contains four vital-sign cards (heart rate, BP, O2, temperature), a canvas for the Chart.js line graph, and an alerts panel.

The <script> tag is opened at the end because the JavaScript goes in the next chunk.

  • Save frontend/index.html and open it in your browser to see the static layout.

You should see a dark page with a sidebar labeled "Patients", four placeholder vital cards showing "--", an empty chart area, and an "AI Alerts" section.

Seeing a blank page?

Make sure the two code blocks are pasted in order with no gap between them. The first block ends mid-CSS (at .alert-item .severity) and the second block picks up immediately after.

Check for any stray characters between the two pasted sections. Help me debug my dashboard layout

Add Chart.js visualization and WebSocket interactivity

The static layout is ready. Now you will add the JavaScript that connects to your API via WebSocket, renders a live line chart of the last 30 readings, and color-codes vital cards as values change.

  • Directly after the <script> tag you just pasted, add the chart initialization and status-check logic:
    const API_BASE = window.location.protocol === 'https:' ? 'https://' : 'http://';
    const WS_BASE = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
    const APP_HOST = '[[APP_SUBDOMAIN="your-app-subdomain"]]';
    let selectedPatient = null;
    let patientData = {};
    let chart;
    const chartLabels = [];
    const chartDatasets = {
      hr: [], o2: [], temp: []
    };

    const ctx = document.getElementById('vitals-chart').getContext('2d');
    chart = new Chart(ctx, {
      type: 'line',
      data: {
        labels: chartLabels,
        datasets: [
          { label: 'Heart Rate', data: chartDatasets.hr, borderColor: '#3b82f6', tension: 0.3, pointRadius: 2 },
          { label: 'O2 Sat', data: chartDatasets.o2, borderColor: '#22c55e', tension: 0.3, pointRadius: 2 },
          { label: 'Temp (x10)', data: chartDatasets.temp, borderColor: '#f59e0b', tension: 0.3, pointRadius: 2 }
        ]
      },
      options: { responsive: true, animation: { duration: 300 }, scales: { y: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' } }, x: { ticks: { color: '#94a3b8', maxTicksLimit: 8 }, grid: { color: '#334155' } } }, plugins: { legend: { labels: { color: '#e2e8f0' } } } }
    });

    function getVitalStatus(type, value) {
      if (type === 'hr') return value < 60 || value > 120 ? (value > 140 ? 'critical' : 'warning') : 'normal';
      if (type === 'o2') return value < 90 ? 'critical' : value < 95 ? 'warning' : 'normal';
      if (type === 'temp') return value > 38.5 ? 'critical' : value > 37.5 ? 'warning' : 'normal';
      return 'normal';
    }

What does this code do?

APP_HOST points to your Go API's subdomain on Zerops. The protocol detection ensures WebSocket uses wss:// on HTTPS and ws:// on HTTP.

The Chart.js line chart tracks three datasets: heart rate (blue), O2 saturation (green), and temperature multiplied by 10 (amber, scaled so it shares the Y-axis with the other values). getVitalStatus returns a severity level for color-coding each card.

  • Below that, add the functions that update the vitals display and render alerts:
    function updateVitalsDisplay(v) {
      document.getElementById('val-hr').textContent = v.heart_rate;
      document.getElementById('val-bp').textContent = v.blood_pressure_systolic + '/' + v.blood_pressure_diastolic;
      document.getElementById('val-o2').textContent = v.o2_saturation.toFixed(1);
      document.getElementById('val-temp').textContent = v.temperature.toFixed(1);
      document.getElementById('card-hr').className = 'vital-card ' + getVitalStatus('hr', v.heart_rate);
      document.getElementById('card-o2').className = 'vital-card ' + getVitalStatus('o2', v.o2_saturation);
      document.getElementById('card-temp').className = 'vital-card ' + getVitalStatus('temp', v.temperature);
      const now = new Date().toLocaleTimeString();
      if (chartLabels.length > 30) { chartLabels.shift(); chartDatasets.hr.shift(); chartDatasets.o2.shift(); chartDatasets.temp.shift(); }
      chartLabels.push(now);
      chartDatasets.hr.push(v.heart_rate);
      chartDatasets.o2.push(v.o2_saturation);
      chartDatasets.temp.push(v.temperature * 10);
      chart.update();
    }

    function addAlert(a) {
      const list = document.getElementById('alerts-list');
      const existing = list.querySelector('p');
      if (existing) existing.remove();
      const div = document.createElement('div');
      div.className = 'alert-item ' + a.severity;
      div.innerHTML = `<div class="severity">${a.severity} \u2014 ${a.patient_id}</div><div class="summary">${a.summary}</div><div class="recommendation">${a.recommendation}</div>`;
      list.prepend(div);
    }

What does this code do?

updateVitalsDisplay writes new values into the four vital cards, applies a severity color class, and pushes the reading onto the chart (keeping a rolling window of 30 points).

addAlert prepends a new alert entry to the alerts panel with a colored left border matching its severity.

  • Save the file and refresh it in your browser.

You should see the Chart.js chart axes rendered (empty, with a legend showing Heart Rate, O2 Sat, and Temp) and the status text still showing "Connecting..." since the WebSocket host is not yet set.

  • Continue adding the patient list loader and WebSocket connection below the addAlert function:
    function loadPatients() {
      fetch(API_BASE + APP_HOST + '/api/patients').then(r => r.json()).then(patients => {
        const list = document.getElementById('patient-list');
        list.innerHTML = '';
        patients && patients.forEach(p => {
          const div = document.createElement('div');
          div.className = 'patient-item' + (selectedPatient === p.patient_id ? ' active' : '');
          div.textContent = p.patient_id;
          div.onclick = () => { selectedPatient = p.patient_id; loadPatients(); };
          list.appendChild(div);
        });
      }).catch(() => {});
    }

What does this code do?

loadPatients fetches the list of known patients from your Go API's /api/patients endpoint. It renders each patient as a clickable item in the sidebar. Clicking a patient sets it as the selectedPatient so live vitals display for that patient.

  • Finally, add the WebSocket connection and closing tags to complete the file:
    const ws = new WebSocket(WS_BASE + APP_HOST + '/ws');
    ws.onopen = () => { const s = document.getElementById('connection-status'); s.textContent = '\u25CF Connected'; s.className = 'connected'; };
    ws.onclose = () => { const s = document.getElementById('connection-status'); s.textContent = '\u25CB Disconnected'; s.className = ''; };
    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      if (msg.type === 'vitals') {
        const v = msg.data;
        if (!patientData[v.patient_id]) patientData[v.patient_id] = [];
        patientData[v.patient_id].push(v);
        if (!selectedPatient) selectedPatient = v.patient_id;
        if (v.patient_id === selectedPatient) updateVitalsDisplay(v);
        loadPatients();
      } else if (msg.type === 'alert') {
        addAlert(msg.data);
      }
    };

    loadPatients();
    setInterval(loadPatients, 10000);
  </script>
</body>
</html>

What does this code do?

The WebSocket connects to /ws on your API service. When a message arrives, it checks the type field: vitals messages update the cards and chart, while alert messages render in the alerts panel.

loadPatients runs immediately on load and refreshes every 10 seconds as a fallback. The onopen handler switches the status indicator to green "Connected" so you know the link is live.

  • Save frontend/index.html.

Seeing JavaScript errors in the console?

Open DevTools (F12) and check the Console tab. A WebSocket connection error is expected at this point since APP_HOST still contains the placeholder value. You will replace it with the real subdomain after confirming the deploy.

If you see a Chart is not defined error, confirm the Chart.js CDN <script> tag is in the <head> before the <style> block. Help me fix JavaScript errors in my dashboard

✔️ Awesome, I've got everything!

Great. Make sure frontend/index.html is saved with all six code sections pasted in order.

ⓧ I'd like to double check the full code

Scroll up and confirm each section is present in this order:

  • HTML head + first CSS block (from <!DOCTYPE html> through .alert-item .severity).
  • Remaining CSS + body HTML (from .alert-item.warning .severity through the opening <script> tag).
  • Chart init + getVitalStatus (from const API_BASE through closing } of getVitalStatus).
  • updateVitalsDisplay + addAlert functions.
  • loadPatients function.
  • WebSocket connection + closing </script></body></html> tags.
Deploy the frontend to Zerops

The HTML file is complete. Now you need a zerops.yaml entry that tells Zerops to serve it as a static nginx site.

  • Open zerops.yaml in your medalert folder.
  • Find the last line of the existing app setup (the GOOGLE_API_KEY line).
  • Below it, add this new entry for the frontend service:
  - setup: frontend
    build:
      base: nodejs@22
      buildCommands:
        - echo "Static files ready"
      deployFiles: ./
    run:
      base: nginx@latest
      ports:
        - port: 80
          httpSupport: true
      documentRoot: ./

What does this config do?

The frontend service uses nodejs@22 as its build base only because Zerops requires a build step. The echo command is a no-op since there is nothing to compile. deployFiles: ./ pushes all files in the working directory.

At runtime, nginx@latest serves the files on port 80. documentRoot: ./ tells nginx to serve from the root of the deployed files, where index.html lives.

  • Save zerops.yaml.

✔️ Awesome, I've got everything!

Great. Confirm your zerops.yaml now has two top-level entries under zerops: (one for app, one for frontend).

ⓧ I'd like to double check the full code

zerops:
  - setup: app
    build:
      base: go@latest
      buildCommands:
        - go build -o medalert .
      deployFiles:
        - medalert
    run:
      base: go@latest
      ports:
        - port: 8080
          httpSupport: true
      readinessCheck:
        httpGet:
          port: 8080
          path: /health
      envVariables:
        DB_URL: ${db_connectionString}
        CACHE_URL: ${cache_connectionString}
        GOOGLE_API_KEY: <your-gemini-api-key>
  - setup: frontend
    build:
      base: nodejs@22
      buildCommands:
        - echo "Static files ready"
      deployFiles: ./
    run:
      base: nginx@latest
      ports:
        - port: 80
          httpSupport: true
      documentRoot: ./

Before you deploy, you need to set the APP_HOST value in your frontend/index.html to your actual app subdomain.

  • In the Zerops GUI, open your medalert project and click the app service.
  • Find the Public access through zerops.app subdomain section and copy the full hostname (it looks like app-xxxxx-8080.prg1.zerops.app).
  • In frontend/index.html, find this line:
    const APP_HOST = '[[APP_SUBDOMAIN="your-app-subdomain"]]';
  • Replace the placeholder with the hostname you copied (e.g. app-xxxxx-8080.prg1.zerops.app).
  • Save frontend/index.html.

Now deploy both services to Zerops by running:

  • Deploy the full project by running:
zcli push

What happens during this deploy?

zcli push detects both entries in zerops.yaml and deploys them in parallel. The app service rebuilds the Go binary. The frontend service runs the no-op build command and copies index.html into the nginx container.

Before you open the dashboard, what do you expect to see when the frontend connects to your API's WebSocket?

  • In the Zerops GUI, click the frontend service and find its public subdomain URL.
  • Open that URL in your browser.

You should see the MedAlert dashboard with the connection status showing "Connected" in green. The patient list and vitals will be empty until you start the simulator.

  • Trigger the simulator to generate live data by running:
curl -X POST https://[[APP_SUBDOMAIN="your-app-subdomain"]]/api/simulate/patient-1

Within a few seconds, you should see:

  • "patient-1" appearing in the sidebar.
  • Vital sign numbers updating every 3 seconds with green, yellow, or red colors.
  • The line chart drawing new data points as they arrive.
  • AI alerts appearing in the alerts panel after every 5th reading (when Gemini flags an anomaly).

Dashboard shows Disconnected or no data?

Confirm that APP_HOST in your index.html exactly matches the subdomain from the Zerops GUI (including the port number in the hostname, e.g. app-xxxxx-8080.prg1.zerops.app).

If the WebSocket connects but no vitals appear, verify the app service is healthy by visiting its /health endpoint directly in your browser. It should return ok.

Help me debug my MedAlert WebSocket connection

Your full MedAlert system is live. Patients stream in, Gemini AI analyzes patterns, alerts fire, and the dashboard shows it all in real time. Next up: a secret mission to add AI-powered patient report generation.

Secret mission

AI-Powered Patient Report Generation

Your monitoring dashboard shows real-time state, but doctors also need shift summaries. Add a report endpoint that uses Gemini to generate a narrative summary of a patient's last 24 hours, including trend analysis and actionable recommendations.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on Zerops using your $65 signup credit, so running services will gradually consume that credit.

Cost warning

Your medalert project with 4 services (Go API, nginx frontend, PostgreSQL, Valkey) consumes resources from your $65 free credit while running. The Lightweight core itself is free, but active services cost approximately $8-10/month from your credit.

If you are done with this project, delete it to preserve your remaining credit for future projects.

Resources you used:

  • Zerops project "medalert" with 4 services: app (Go API), frontend (nginx), db (PostgreSQL), cache (Valkey).
  • Local project directory ~/Desktop/medalert with Go source code and frontend files.

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to extend it for the hackathon.

  • Your 4 services remain running and accessible via their subdomain URLs.
  • The simulator continues generating vitals if you started one.
  • Your $65 credit will be consumed at approximately $8-10/month while services are active.
  • The zCLI binary remains installed at /usr/bin/zcli or /usr/local/bin/zcli. There is no documented uninstall command for zCLI.

Pause - I'll come back to this later

Delete the Zerops project to stop credit consumption, but keep your local source code intact. You can reimport and redeploy anytime.

  • In the Zerops GUI, open your medalert project.
  • Click Project Settings then Delete project to remove the cloud services and stop credit usage.
  • Your local ~/Desktop/medalert directory stays intact with all your source code.
  • When you are ready to resume, reimport the project YAML in the Zerops GUI and redeploy with:
zcli push
  • Your PostgreSQL data (vitals and alerts) will be lost when the project is deleted. Export anything important before deleting.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent and cannot be undone.

  • In the Zerops GUI, open your medalert project.
  • Click Project Settings then Delete project to remove all 4 services, databases, and stored data.

What does this delete?

Deleting the project removes the Go API, nginx frontend, PostgreSQL database (including all vitals and alerts data), and the Valkey cache. All subdomain URLs stop working immediately.

  • Delete your local project files by running:
rm -rf ~/Desktop/medalert
  • Confirm the folder is gone by running:
ls ~/Desktop/medalert

You should see a message confirming the directory does not exist.

Nice Work!

Nice Work!

You just built and deployed a production-grade patient vital signs monitoring system on Zerops. Real patients (simulated ones, at least) are streaming live vitals to your Go API, Gemini AI is catching multi-signal anomalies, and a polished dashboard is updating in real-time.

You've learned how to:

  • Build a multi-service PaaS architecture on Zerops with four interconnected services (Go API, nginx frontend, PostgreSQL, and Valkey) wired together via environment variable references.
  • Implement real-time streaming using WebSocket and Valkey pub/sub so connected clients receive instant vital sign updates the moment they arrive.
  • Integrate Gemini AI for intelligent anomaly detection that catches multi-signal patterns simple threshold checks would miss.
  • Secret Mission: Added an AI-powered patient report generation endpoint that summarizes 24 hours of vitals with trend analysis and narrative recommendations.

Ready to quiz yourself?