Build a Voice AI Alarm Clock

Build a mobile alarm clock that uses Gemini Live API to wake you up.

Introduction

30 Second Summary

Everyone knows the feeling of hitting snooze over and over, waking up late, and regretting it for the rest of the day. What if your alarm clock could actually talk to you, challenge you to prove you're awake, and charge you real money every time you hit snooze?

In this project, you will build a mobile alarm clock app powered by a real-time voice AI agent. When your alarm fires, it starts a live conversation that escalates penalties every time you snooze or fail an alertness check.

What You'll Build

A full-stack voice AI alarm system with a FastAPI backend relaying real-time audio between your phone and the Gemini Live API, deployed to Google Cloud Run, with an Expo mobile app that streams audio and tracks penalties in Firestore.

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

  • A real-time bidirectional audio streaming backend that connects your phone to a Gemini voice agent via WebSockets.
  • A penalty tracking system using function calling that logs escalating financial consequences ($1, $2, $5, $10, $20) every time you snooze.
  • A serverless Cloud Run deployment with Docker that scales to zero between alarms, keeping costs at $0 when idle.
  • A mobile app with native audio streaming, alarm scheduling, and a penalty dashboard installed on your Android phone.
  • Secret Mission: Add escalating penalty tiers that double each consecutive snooze day and send yourself a daily penalty summary notification.

Are there any prerequisites?

You should have experience with Python and be comfortable running terminal commands. You also need a Google Cloud account with billing enabled and a Google AI Studio API key for the Gemini API.

Node.js and an Android phone are required for the mobile app portion of the project.

Set Up Your Development Environment

Your tools are installed and your Google Cloud project is ready. Now it's time to create the actual backend project that will relay voice audio between your phone and the Gemini Live API.

In this step, you'll create the backend project folder, install the Python libraries that power real-time audio streaming, and configure your environment for local development.

In this step, get ready to:
  • Create the alarm-backend/ project directory.
  • Install Python dependencies including FastAPI and the google-genai SDK.
  • Configure your environment variables for local development.
Create the backend project and install dependencies

The backend is a Python server that accepts audio from your phone over a WebSocket and forwards it to the Gemini Live API. You need a dedicated folder for this server's code and dependencies.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the backend project folder and move into it by running these commands:
mkdir alarm-backend
cd alarm-backend
  • Confirm the folder was created by running:
ls

You should see an empty directory. That's expected since you just created it.

Your backend depends on several Python packages. A requirements.txt file lists these dependencies with pinned versions so your install is reproducible.

  • Create a file called requirements.txt in your alarm-backend/ folder and add the following content:
fastapi==0.115.6
uvicorn[standard]==0.34.0
websockets==14.1
google-genai==1.14.0
google-cloud-firestore==2.19.0
python-dotenv==1.0.1

What does each package do?

  • fastapi is the web framework that handles your WebSocket endpoint.
  • uvicorn is the async server that runs FastAPI.
  • google-genai is Google's SDK for connecting to the Gemini Live API with real-time audio streaming.
  • google-cloud-firestore lets you read and write penalty data to Firestore.
  • python-dotenv loads environment variables from a .env file during local development.
  • Save requirements.txt.
  • Make sure your Python virtual environment is active (you should see its name in parentheses at the start of your terminal prompt).
  • Install all the dependencies by running:
pip install -r requirements.txt

You should see pip downloading and installing each package. The final lines will say Successfully installed followed by the package names.

Seeing install errors?

  • Make sure your virtual environment is activated. If you don't see (venv) or similar at the start of your terminal prompt, activate it before running pip install.
  • Check that you're inside the alarm-backend/ folder where requirements.txt lives.
  • If you see a permissions error, you are likely running pip outside a virtual environment. Never use sudo pip install.

Why is pip install failing in my virtual environment?

Set up your environment variables

Your backend needs your Gemini API key to connect to the Live API. A .env file keeps this secret out of your code so you never accidentally commit it to version control.

  • Create a file called .env in your alarm-backend/ folder and add the following line:
GEMINI_API_KEY=your-gemini-api-key-here
  • Replace your-gemini-api-key-here with your actual Gemini API key from Google AI Studio.
  • Save .env.

Why use a .env file?

The python-dotenv package reads this file automatically when your server starts. This means you don't have to export the variable in your terminal every time you open a new session.

When you deploy to Cloud Run later, you'll set the API key as a Cloud Run environment variable instead. The .env file is only for local development.

  • Verify your setup by running this command to confirm the packages are installed:
python -c "import fastapi; import google.genai; print('All packages installed!')"

You should see All packages installed! printed to your terminal.

Seeing a ModuleNotFoundError?

  • This means pip installed the packages into a different Python than the one you're running. Make sure your virtual environment is still active.
  • Try running which python (macOS) or where python (Windows) to check that it points to your virtual environment path.

Why is Python not finding my installed packages?

✔️ Awesome, I've got everything!

Great. Double check you've saved both requirements.txt and .env in your alarm-backend/ folder.

ⓧ I'd like to double check the full code

Here is the complete requirements.txt file. Make sure yours matches exactly:

fastapi==0.115.6
uvicorn[standard]==0.34.0
websockets==14.1
google-genai==1.14.0
google-cloud-firestore==2.19.0
python-dotenv==1.0.1

Your backend project is scaffolded and every dependency is locked in. Next up, you'll write the core voice session that connects to the Gemini Live API and streams audio in real time.

Build the Gemini Live API Voice Session

Your development environment is set up and ready to go. Now it is time to build the core of your alarm clock: a real-time voice session with the Gemini Live API.

The Gemini Live API uses a persistent WebSocket connection to stream audio in both directions simultaneously. Your phone sends raw microphone audio up, the AI processes it, and audio responses stream back. In this step, you will build the Python classes that manage this bidirectional audio flow and the penalty system that makes snoozing costly.

In this step, get ready to:
  • Create the penalty tracking module that logs escalating snooze fees to Firestore.
  • Build the Gemini voice session class that connects to the Live API, sends audio, receives responses, and calls penalty tools.
  • Test bidirectional audio locally with a command-line script to verify your AI agent speaks back.
Create the penalty tracking module

Before the AI can penalize you for snoozing, it needs a system to track penalties. The PenaltyManager class handles escalating fee tiers, Firestore persistence, and streak tracking.

  • Create a new file called penalty.py inside your alarm-backend folder by running:
touch penalty.py
  • Open penalty.py and paste the following code:
import logging
from datetime import datetime, timezone
from google.cloud import firestore

logger = logging.getLogger(__name__)

PENALTY_TIERS = [1, 2, 5, 10, 20]


class PenaltyManager:
    def __init__(self, user_id: str):
        self.user_id = user_id
        self.db = firestore.Client()
        self.snooze_count = 0
        self._load_today_state()

    def _load_today_state(self):
        today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        doc_ref = self.db.collection("penalties").document(
            f"{self.user_id}_{today}"
        )
        doc = doc_ref.get()
        if doc.exists:
            data = doc.to_dict()
            self.snooze_count = data.get("snooze_count", 0)

    def _get_penalty_amount(self) -> int:
        index = min(self.snooze_count, len(PENALTY_TIERS) - 1)
        return PENALTY_TIERS[index]

    def log_snooze(self) -> dict:
        amount = self._get_penalty_amount()
        self.snooze_count += 1

        today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        doc_ref = self.db.collection("penalties").document(
            f"{self.user_id}_{today}"
        )
        doc_ref.set(
            {
                "user_id": self.user_id,
                "date": today,
                "snooze_count": self.snooze_count,
                "total_penalty": self._get_total_today(),
                "last_penalty": amount,
                "updated_at": datetime.now(timezone.utc).isoformat(),
            }
        )

        entry_ref = self.db.collection("penalty_entries").document()
        entry_ref.set(
            {
                "user_id": self.user_id,
                "date": today,
                "amount": amount,
                "snooze_number": self.snooze_count,
                "timestamp": datetime.now(timezone.utc).isoformat(),
            }
        )

        logger.info(
            f"Penalty logged: ${amount} (snooze #{self.snooze_count})"
        )
        return {
            "penalty_amount": amount,
            "snooze_count": self.snooze_count,
            "total_today": self._get_total_today(),
            "message": f"Penalty: ${amount}. Total today: ${self._get_total_today()}.",
        }

    def _get_total_today(self) -> int:
        total = 0
        for i in range(self.snooze_count):
            index = min(i, len(PENALTY_TIERS) - 1)
            total += PENALTY_TIERS[index]
        return total

    def log_success(self):
        today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        doc_ref = self.db.collection("wake_successes").document(
            f"{self.user_id}_{today}"
        )
        doc_ref.set(
            {
                "user_id": self.user_id,
                "date": today,
                "woke_up_clean": self.snooze_count == 0,
                "snoozes_before_wake": self.snooze_count,
                "timestamp": datetime.now(timezone.utc).isoformat(),
            }
        )

    def get_status(self) -> dict:
        return {
            "snooze_count": self.snooze_count,
            "total_today": self._get_total_today(),
            "next_penalty": self._get_penalty_amount(),
        }

What does this code do?

PENALTY_TIERS = [1, 2, 5, 10, 20] defines the escalating dollar amounts. First snooze costs $1, second costs $2, then $5, $10, and $20 for every snooze after that.

_load_today_state() checks Firestore when the class is created. If you already snoozed earlier today, it picks up where you left off instead of resetting to zero.

log_snooze() writes two documents: one summary for the day (total penalties, snooze count) and one individual entry per snooze event. This lets you display both a daily total and a detailed history on the mobile app.

  • Save penalty.py.

Import error on google.cloud.firestore?

Make sure your virtual environment is activated and that you installed the dependencies from requirements.txt in Step 1. Run pip list | grep firestore to confirm google-cloud-firestore is installed.

help me fix my Firestore import error in penalty.py

Build the Gemini voice session class

This class is the heart of your alarm. It opens a persistent connection to the Gemini Live API, streams raw PCM audio in both directions, and exposes tool functions that the AI can call to penalize you.

  • Create a new file called gemini_session.py inside your alarm-backend folder by running:
touch gemini_session.py
  • Open gemini_session.py and paste the following code:
import asyncio
import json
import logging
import os
from typing import AsyncGenerator

from google import genai
from google.genai import types

from penalty import PenaltyManager

logger = logging.getLogger(__name__)

MODEL = "gemini-2.5-flash-native-audio-preview-12-2025"

SYSTEM_INSTRUCTION = """You are a strict but motivating alarm clock AI agent. Your job is to wake the user up and verify they are alert.

Behavior:
1. When the session starts, immediately greet the user loudly and energetically. Tell them the current time and that it is time to wake up.
2. Ask them an alertness question (simple math, what day it is, or what they plan to do today).
3. If they answer correctly, call the check_alertness_answer tool with passed=true and congratulate them.
4. If they say "snooze" or do not respond within 30 seconds, call log_snooze_penalty to record the penalty. Then warn them about the cost and try again with increasing urgency.
5. After 3 failed attempts, call log_snooze_penalty one final time and tell them their total penalty amount before ending.
6. Get progressively more intense and direct with each snooze. Start friendly, become firm, then become brutally honest about their choices.

Keep responses SHORT and punchy. This is a wake-up call, not a podcast."""


class GeminiVoiceSession:
    def __init__(self, penalty_manager: PenaltyManager):
        self.penalty_manager = penalty_manager
        self.client = genai.Client(
            api_key=os.environ.get("GEMINI_API_KEY"),
            http_options={"api_version": "v1alpha"},
        )
        self.session = None
        self.audio_out_queue = asyncio.Queue()
        self.event_queue = asyncio.Queue()
        self._running = False

    def _get_tools(self):
        return [
            self.log_snooze_penalty,
            self.check_alertness_answer,
            self.get_penalty_status,
        ]

    def log_snooze_penalty(self) -> dict:
        """Log a snooze penalty for the user. Call this when the user says snooze or fails to respond."""
        result = self.penalty_manager.log_snooze()
        asyncio.create_task(
            self.event_queue.put({"type": "penalty", "data": result})
        )
        return result

    def check_alertness_answer(self, passed: bool) -> dict:
        """Check whether the user passed the alertness test. Call with passed=true if they answered correctly, passed=false if they failed."""
        if passed:
            self.penalty_manager.log_success()
            result = {"status": "awake", "message": "User is awake!"}
        else:
            result = self.log_snooze_penalty()
        asyncio.create_task(
            self.event_queue.put({"type": "alertness", "data": result})
        )
        return result

    def get_penalty_status(self) -> dict:
        """Get the current penalty status for the user including total owed and snooze count."""
        return self.penalty_manager.get_status()

    async def start(self):
        config = {
            "response_modalities": ["AUDIO"],
            "system_instruction": SYSTEM_INSTRUCTION,
            "tools": self._get_tools(),
        }
        self.session = await self.client.aio.live.connect(
            model=MODEL, config=config
        )
        self._running = True
        await self.session.send_client_content(
            turns=types.Content(
                role="user", parts=[types.Part(text="The alarm just fired. Wake me up NOW.")]
            ),
            turn_complete=True,
        )

    async def send_audio(self, audio_bytes: bytes):
        if self.session and self._running:
            await self.session.send_realtime_input(
                audio={"data": audio_bytes, "mime_type": "audio/pcm"}
            )

    async def receive_audio(self) -> AsyncGenerator[bytes, None]:
        while self._running:
            try:
                turn = self.session.receive()
                async for response in turn:
                    if data := response.data:
                        yield data
            except Exception as e:
                logger.error(f"Receive error: {e}")
                break

    async def receive_events(self) -> AsyncGenerator[dict, None]:
        while self._running:
            event = await self.event_queue.get()
            yield event

    async def stop(self):
        self._running = False
        if self.session:
            try:
                await self.session.close()
            except Exception:
                pass

What does this code do?

The GeminiVoiceSession class manages the entire lifecycle of a voice conversation with Gemini. It initializes the SDK client with the v1alpha API version required for native audio models.

The three tool methods (log_snooze_penalty, check_alertness_answer, get_penalty_status) are Python functions that Gemini can call autonomously during the conversation. When the AI decides you are snoozing, it calls log_snooze_penalty on its own. This is function calling in action.

The start() method connects to the Live API and immediately sends a text prompt ("The alarm just fired. Wake me up NOW.") to kick off the AI's wake-up behavior. From that point, send_audio() streams your microphone data up and receive_audio() yields PCM bytes back for playback.

Why v1alpha for the API version?

The Gemini Live API with native audio models is currently in preview. The v1alpha API version gives you access to the bidirectional audio streaming endpoint. Once the feature moves to general availability, you can switch to a stable version.

  • Save gemini_session.py.
  • Confirm both files exist by running:
ls

You should see gemini_session.py, penalty.py, requirements.txt, and .env listed in the output.

Missing GEMINI_API_KEY at runtime?

The GeminiVoiceSession reads your API key from the GEMINI_API_KEY environment variable. Make sure your .env file contains it. The python-dotenv library loads this file at startup in main.py (which you will create in a later step).

help me debug my GEMINI_API_KEY environment variable

✔️ Awesome, I've got everything!

Great. Make sure you have saved both files before continuing.

ⓧ I'd like to double check the full code

Here is the complete penalty.py:

import logging
from datetime import datetime, timezone
from google.cloud import firestore

logger = logging.getLogger(__name__)

PENALTY_TIERS = [1, 2, 5, 10, 20]


class PenaltyManager:
    def __init__(self, user_id: str):
        self.user_id = user_id
        self.db = firestore.Client()
        self.snooze_count = 0
        self._load_today_state()

    def _load_today_state(self):
        today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        doc_ref = self.db.collection("penalties").document(
            f"{self.user_id}_{today}"
        )
        doc = doc_ref.get()
        if doc.exists:
            data = doc.to_dict()
            self.snooze_count = data.get("snooze_count", 0)

    def _get_penalty_amount(self) -> int:
        index = min(self.snooze_count, len(PENALTY_TIERS) - 1)
        return PENALTY_TIERS[index]

    def log_snooze(self) -> dict:
        amount = self._get_penalty_amount()
        self.snooze_count += 1

        today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        doc_ref = self.db.collection("penalties").document(
            f"{self.user_id}_{today}"
        )
        doc_ref.set(
            {
                "user_id": self.user_id,
                "date": today,
                "snooze_count": self.snooze_count,
                "total_penalty": self._get_total_today(),
                "last_penalty": amount,
                "updated_at": datetime.now(timezone.utc).isoformat(),
            }
        )

        entry_ref = self.db.collection("penalty_entries").document()
        entry_ref.set(
            {
                "user_id": self.user_id,
                "date": today,
                "amount": amount,
                "snooze_number": self.snooze_count,
                "timestamp": datetime.now(timezone.utc).isoformat(),
            }
        )

        logger.info(
            f"Penalty logged: ${amount} (snooze #{self.snooze_count})"
        )
        return {
            "penalty_amount": amount,
            "snooze_count": self.snooze_count,
            "total_today": self._get_total_today(),
            "message": f"Penalty: ${amount}. Total today: ${self._get_total_today()}.",
        }

    def _get_total_today(self) -> int:
        total = 0
        for i in range(self.snooze_count):
            index = min(i, len(PENALTY_TIERS) - 1)
            total += PENALTY_TIERS[index]
        return total

    def log_success(self):
        today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        doc_ref = self.db.collection("wake_successes").document(
            f"{self.user_id}_{today}"
        )
        doc_ref.set(
            {
                "user_id": self.user_id,
                "date": today,
                "woke_up_clean": self.snooze_count == 0,
                "snoozes_before_wake": self.snooze_count,
                "timestamp": datetime.now(timezone.utc).isoformat(),
            }
        )

    def get_status(self) -> dict:
        return {
            "snooze_count": self.snooze_count,
            "total_today": self._get_total_today(),
            "next_penalty": self._get_penalty_amount(),
        }

And here is the complete gemini_session.py:

import asyncio
import json
import logging
import os
from typing import AsyncGenerator

from google import genai
from google.genai import types

from penalty import PenaltyManager

logger = logging.getLogger(__name__)

MODEL = "gemini-2.5-flash-native-audio-preview-12-2025"

SYSTEM_INSTRUCTION = """You are a strict but motivating alarm clock AI agent. Your job is to wake the user up and verify they are alert.

Behavior:
1. When the session starts, immediately greet the user loudly and energetically. Tell them the current time and that it is time to wake up.
2. Ask them an alertness question (simple math, what day it is, or what they plan to do today).
3. If they answer correctly, call the check_alertness_answer tool with passed=true and congratulate them.
4. If they say "snooze" or do not respond within 30 seconds, call log_snooze_penalty to record the penalty. Then warn them about the cost and try again with increasing urgency.
5. After 3 failed attempts, call log_snooze_penalty one final time and tell them their total penalty amount before ending.
6. Get progressively more intense and direct with each snooze. Start friendly, become firm, then become brutally honest about their choices.

Keep responses SHORT and punchy. This is a wake-up call, not a podcast."""


class GeminiVoiceSession:
    def __init__(self, penalty_manager: PenaltyManager):
        self.penalty_manager = penalty_manager
        self.client = genai.Client(
            api_key=os.environ.get("GEMINI_API_KEY"),
            http_options={"api_version": "v1alpha"},
        )
        self.session = None
        self.audio_out_queue = asyncio.Queue()
        self.event_queue = asyncio.Queue()
        self._running = False

    def _get_tools(self):
        return [
            self.log_snooze_penalty,
            self.check_alertness_answer,
            self.get_penalty_status,
        ]

    def log_snooze_penalty(self) -> dict:
        """Log a snooze penalty for the user. Call this when the user says snooze or fails to respond."""
        result = self.penalty_manager.log_snooze()
        asyncio.create_task(
            self.event_queue.put({"type": "penalty", "data": result})
        )
        return result

    def check_alertness_answer(self, passed: bool) -> dict:
        """Check whether the user passed the alertness test. Call with passed=true if they answered correctly, passed=false if they failed."""
        if passed:
            self.penalty_manager.log_success()
            result = {"status": "awake", "message": "User is awake!"}
        else:
            result = self.log_snooze_penalty()
        asyncio.create_task(
            self.event_queue.put({"type": "alertness", "data": result})
        )
        return result

    def get_penalty_status(self) -> dict:
        """Get the current penalty status for the user including total owed and snooze count."""
        return self.penalty_manager.get_status()

    async def start(self):
        config = {
            "response_modalities": ["AUDIO"],
            "system_instruction": SYSTEM_INSTRUCTION,
            "tools": self._get_tools(),
        }
        self.session = await self.client.aio.live.connect(
            model=MODEL, config=config
        )
        self._running = True
        await self.session.send_client_content(
            turns=types.Content(
                role="user", parts=[types.Part(text="The alarm just fired. Wake me up NOW.")]
            ),
            turn_complete=True,
        )

    async def send_audio(self, audio_bytes: bytes):
        if self.session and self._running:
            await self.session.send_realtime_input(
                audio={"data": audio_bytes, "mime_type": "audio/pcm"}
            )

    async def receive_audio(self) -> AsyncGenerator[bytes, None]:
        while self._running:
            try:
                turn = self.session.receive()
                async for response in turn:
                    if data := response.data:
                        yield data
            except Exception as e:
                logger.error(f"Receive error: {e}")
                break

    async def receive_events(self) -> AsyncGenerator[dict, None]:
        while self._running:
            event = await self.event_queue.get()
            yield event

    async def stop(self):
        self._running = False
        if self.session:
            try:
                await self.session.close()
            except Exception:
                pass
Test bidirectional audio locally

Before wiring everything to a WebSocket and deploying to the cloud, you should verify your voice session works by running a quick command-line test. This script uses PyAudio to capture microphone input, send it to Gemini, and play back the AI's audio response through your speakers.

  • Install PyAudio in your virtual environment by running:
pip install pyaudio

PortAudio is required for PyAudio

On macOS, run brew install portaudio before installing PyAudio. On Linux, run sudo apt-get install portaudio19-dev. On Windows, pip install pyaudio should work directly.

  • Create a test script called test_voice.py by running:
touch test_voice.py
  • Open test_voice.py and paste the following code:
import asyncio
import pyaudio
from dotenv import load_dotenv
from penalty import PenaltyManager
from gemini_session import GeminiVoiceSession

load_dotenv()

FORMAT = pyaudio.paInt16
CHANNELS = 1
SEND_SAMPLE_RATE = 16000
RECEIVE_SAMPLE_RATE = 24000
CHUNK_SIZE = 1024


async def main():
    pya = pyaudio.PyAudio()
    penalty_manager = PenaltyManager("test_user")
    session = GeminiVoiceSession(penalty_manager)

    print("Starting voice session... Use headphones to avoid echo!")
    await session.start()
    print("Connected to Gemini. Start speaking! Press Ctrl+C to stop.")

    # Open mic input stream at 16kHz (required by Gemini Live API)
    mic_stream = pya.open(
        format=FORMAT,
        channels=CHANNELS,
        rate=SEND_SAMPLE_RATE,
        input=True,
        frames_per_buffer=CHUNK_SIZE,
    )

    # Open speaker output stream at 24kHz (Gemini's output sample rate)
    speaker_stream = pya.open(
        format=FORMAT,
        channels=CHANNELS,
        rate=RECEIVE_SAMPLE_RATE,
        output=True,
    )

    async def send_mic_audio():
        loop = asyncio.get_running_loop()
        while True:
            data = await loop.run_in_executor(
                None, mic_stream.read, CHUNK_SIZE, False
            )
            await session.send_audio(data)

    async def play_response_audio():
        async for audio_chunk in session.receive_audio():
            await asyncio.to_thread(speaker_stream.write, audio_chunk)

    async def print_events():
        async for event in session.receive_events():
            print(f"Event: {event}")

    try:
        await asyncio.gather(
            send_mic_audio(),
            play_response_audio(),
            print_events(),
        )
    except KeyboardInterrupt:
        pass
    finally:
        await session.stop()
        mic_stream.close()
        speaker_stream.close()
        pya.terminate()
        print("Session ended.")


if __name__ == "__main__":
    asyncio.run(main())

What does this test script do?

It opens your microphone at 16kHz (the input format required by the Gemini Live API) and a speaker stream at 24kHz (the output format Gemini sends back). Three concurrent tasks run: one reads mic chunks and sends them to Gemini, one receives AI audio and plays it through your speakers, and one prints penalty events to the terminal.

Use headphones. Without them, the AI hears its own voice through your speakers and interrupts itself.

  • Put on headphones, then run the test script:
python test_voice.py

You should hear the AI greet you energetically and ask an alertness question. Try answering, or say "snooze" to trigger a penalty event in the terminal. Press Ctrl+C to stop.

No audio coming through?

Check that your GEMINI_API_KEY is correctly set in your .env file. Also confirm your microphone is not muted at the OS level.

If you see IOError: [Errno -9996] Invalid input device, your system has no default input device configured. Check your audio settings.

help me troubleshoot my Gemini Live API voice test

Your voice session works end-to-end. Next up, you will add the WebSocket relay and Firestore persistence so the mobile app can connect to this backend over the network.

Add the WebSocket Relay and Firestore Persistence

You have a working voice session that connects to the Gemini Live API, and a penalty manager that tracks snoozes in Firestore. The missing piece is a web server that your phone can actually connect to.

In this step, you will build a FastAPI server with a WebSocket endpoint that acts as a relay. Your phone sends microphone audio up through the WebSocket, the server pipes it to Gemini, and Gemini's audio responses flow back down to your phone in real time.

In this step, get ready to:
  • Create a FastAPI WebSocket endpoint that bridges your phone to the Gemini Live API.
  • Understand how Firestore persistence keeps penalty state alive across server restarts.
  • Test the full relay locally with a Python WebSocket client.
Create the FastAPI app and health endpoint

The main.py file is the entry point for your backend. It initializes FastAPI, loads environment variables, and defines the endpoints your mobile app will connect to.

  • Create a new file called main.py inside your alarm-backend folder.
  • Add the imports, app initialization, and health endpoint by pasting this code:
import asyncio
import json
import os
import logging

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from dotenv import load_dotenv

from gemini_session import GeminiVoiceSession
from penalty import PenaltyManager

# Load .env file and configure logging
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Create the FastAPI application
app = FastAPI()


@app.get("/health")
async def health():
    return {"status": "ok"}

What does this code do?

This sets up the foundation for your server.

  • load_dotenv() reads your .env file so your GEMINI_API_KEY is available as an environment variable.
  • The /health endpoint gives Cloud Run a simple way to check if your server is alive.
  • The imports pull in your GeminiVoiceSession and PenaltyManager classes from the files you built in previous steps.
  • Save main.py.
Add the WebSocket relay handler

The WebSocket endpoint is the core of your relay. It accepts a connection from your phone, starts a Gemini voice session, then runs three concurrent tasks: one to forward audio from your phone to Gemini, one to send Gemini's audio back to your phone, and one to relay penalty events as text messages.

  • In main.py, add the WebSocket handler below the health endpoint:
@app.websocket("/ws/alarm")
async def alarm_websocket(websocket: WebSocket):
    await websocket.accept()
    logger.info("Client connected")

    # Extract user_id from query params for penalty tracking
    user_id = websocket.query_params.get("user_id", "default_user")
    penalty_manager = PenaltyManager(user_id)

    session = GeminiVoiceSession(penalty_manager)

    try:
        await session.start()

        # Task 1: Forward audio from phone to Gemini
        async def receive_from_client():
            try:
                while True:
                    data = await websocket.receive_bytes()
                    await session.send_audio(data)
            except WebSocketDisconnect:
                logger.info("Client disconnected")
            except Exception as e:
                logger.error(f"Error receiving from client: {e}")

        # Task 2: Send Gemini's audio responses back to phone
        async def send_to_client():
            try:
                async for audio_chunk in session.receive_audio():
                    await websocket.send_bytes(audio_chunk)
            except WebSocketDisconnect:
                logger.info("Client disconnected during send")
            except Exception as e:
                logger.error(f"Error sending to client: {e}")

        # Task 3: Relay penalty and alertness events as JSON text
        async def send_events_to_client():
            try:
                async for event in session.receive_events():
                    await websocket.send_text(json.dumps(event))
            except WebSocketDisconnect:
                pass
            except Exception as e:
                logger.error(f"Error sending events: {e}")

        # Run all three tasks concurrently
        await asyncio.gather(
            receive_from_client(),
            send_to_client(),
            send_events_to_client(),
        )
    except Exception as e:
        logger.error(f"Session error: {e}")
    finally:
        await session.stop()
        logger.info("Session ended")

What does this code do?

This handler runs three async tasks at the same time using asyncio.gather().

  • receive_from_client() listens for raw PCM audio bytes from the phone and forwards them to the Gemini session.
  • send_to_client() streams Gemini's audio responses back to the phone as binary WebSocket messages.
  • send_events_to_client() sends penalty and alertness events as JSON text messages so the phone can update its UI.
  • The finally block ensures the Gemini session is cleaned up even if the phone disconnects unexpectedly.

Why three separate tasks instead of one loop?

Audio flows in both directions simultaneously. The user talks while the AI is still finishing its sentence. If you handled send and receive sequentially, you would block one direction while waiting on the other.

The events task is separate because penalty notifications are text-based JSON, not binary audio. Mixing them into the audio stream would corrupt playback on the phone.

  • Save main.py.
Add the server runner block
  • At the bottom of main.py, add the runner block that starts the server:
if __name__ == "__main__":
    import uvicorn
    port = int(os.environ.get("PORT", 8080))
    uvicorn.run(app, host="0.0.0.0", port=port)

What does this code do?

This block starts the Uvicorn ASGI server on port 8080 when you run python main.py directly. Cloud Run sets the PORT environment variable automatically, so the server always listens on the right port.

  • Save main.py.

✔️ Awesome, I've got everything!

Double check you have saved main.py. The next substep explains how your server stays consistent across restarts.

ⓧ I'd like to double check the full code

import asyncio
import json
import os
import logging

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from dotenv import load_dotenv

from gemini_session import GeminiVoiceSession
from penalty import PenaltyManager

load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

app = FastAPI()


@app.get("/health")
async def health():
    return {"status": "ok"}


@app.websocket("/ws/alarm")
async def alarm_websocket(websocket: WebSocket):
    await websocket.accept()
    logger.info("Client connected")

    user_id = websocket.query_params.get("user_id", "default_user")
    penalty_manager = PenaltyManager(user_id)

    session = GeminiVoiceSession(penalty_manager)

    try:
        await session.start()

        async def receive_from_client():
            try:
                while True:
                    data = await websocket.receive_bytes()
                    await session.send_audio(data)
            except WebSocketDisconnect:
                logger.info("Client disconnected")
            except Exception as e:
                logger.error(f"Error receiving from client: {e}")

        async def send_to_client():
            try:
                async for audio_chunk in session.receive_audio():
                    await websocket.send_bytes(audio_chunk)
            except WebSocketDisconnect:
                logger.info("Client disconnected during send")
            except Exception as e:
                logger.error(f"Error sending to client: {e}")

        async def send_events_to_client():
            try:
                async for event in session.receive_events():
                    await websocket.send_text(json.dumps(event))
            except WebSocketDisconnect:
                pass
            except Exception as e:
                logger.error(f"Error sending events: {e}")

        await asyncio.gather(
            receive_from_client(),
            send_to_client(),
            send_events_to_client(),
        )
    except Exception as e:
        logger.error(f"Session error: {e}")
    finally:
        await session.stop()
        logger.info("Session ended")


if __name__ == "__main__":
    import uvicorn
    port = int(os.environ.get("PORT", 8080))
    uvicorn.run(app, host="0.0.0.0", port=port)
Understand Firestore persistence across cold starts

When your backend runs on Cloud Run, containers can be shut down between requests to save resources. This means any in-memory state disappears. Your penalty system survives cold starts because PenaltyManager reads from Firestore every time a new connection arrives.

How does persistence work here?

Your PenaltyManager uses three Firestore collections to keep track of everything.

  • penalties stores the daily summary for each user (snooze count, total penalty for that day).
  • penalty_entries stores individual snooze events with timestamps and dollar amounts.
  • wake_successes logs successful wake-ups so you can track your streak.

When a new WebSocket connection arrives, _load_today_state() in penalty.py reads today's document from the penalties collection. This restores the snooze count so penalties continue escalating correctly even after a cold start.

Test the WebSocket endpoint locally

Before deploying anywhere, you need to confirm that your WebSocket relay accepts connections, starts a Gemini session, and streams audio back. You will run the server in one terminal and connect with a test client in another.

  • Make sure your virtual environment is activated and you are inside the alarm-backend folder.
  • Start the FastAPI server by running:
python main.py

You should see output showing Uvicorn running on http://0.0.0.0:8080.

Seeing a ModuleNotFoundError?

  • Make sure your virtual environment is activated. You should see (venv) or similar in your terminal prompt.
  • Confirm you installed all dependencies with pip install -r requirements.txt from inside the alarm-backend folder.

Help me debug my ModuleNotFoundError when running main.py

  • Open a second terminal window.
  • Activate your virtual environment in this new terminal as well.
  • Test the health endpoint first by running:
curl http://localhost:8080/health

You should see {"status":"ok"} in the response.

  • Now test the WebSocket connection by creating a temporary test script. Create a file called test_ws.py in your alarm-backend folder and paste this:
import asyncio
import websockets

async def test_connection():
    uri = "ws://localhost:8080/ws/alarm?user_id=test_user"
    async with websockets.connect(uri) as ws:
        print("Connected to WebSocket!")

        # Send a small chunk of silent audio (zeros)
        silent_audio = bytes(3200)  # 100ms of 16kHz 16-bit silence
        await ws.send(silent_audio)
        print("Sent test audio chunk")

        # Listen for a response (audio or text event)
        try:
            response = await asyncio.wait_for(ws.recv(), timeout=10)
            if isinstance(response, bytes):
                print(f"Received audio: {len(response)} bytes")
            else:
                print(f"Received event: {response}")
        except asyncio.TimeoutError:
            print("No response within 10s (check your GEMINI_API_KEY)")

asyncio.run(test_connection())

What does this test script do?

  • It opens a WebSocket connection to your local server with a test user ID.
  • It sends 100ms of silent audio (all zeros) to simulate microphone input.
  • It waits up to 10 seconds for Gemini's audio response or a penalty event.
  • Run the test script in your second terminal:
python test_ws.py

You should see "Connected to WebSocket!" followed by either "Received audio" (meaning Gemini responded) or a text event. In your first terminal, you should see "Client connected" logged by the server.

Connection refused or no audio response?

  • If you see "Connection refused", make sure your server is still running in the first terminal on port 8080.
  • If you see "No response within 10s", check that your GEMINI_API_KEY is set in your .env file and that the key is valid.
  • If you see a Firestore authentication error, make sure you have run gcloud auth application-default login so your local environment can access Firestore.

Help me debug my WebSocket test connection

  • Once you have confirmed the WebSocket connects and receives a response, you can delete test_ws.py. It was only needed for verification.
  • Stop the server in your first terminal by pressing Ctrl+C.

Your relay is working. The phone can connect, audio flows to Gemini, and responses stream back. Next up, you will containerize this server and deploy it to Cloud Run so your phone can reach it from anywhere.

Deploy to Google Cloud Run

Your backend works locally, but your phone needs to reach it over the internet.

In this step, you will containerize your FastAPI app with Docker and deploy it to Cloud Run. Cloud Run gives you a public HTTPS URL with WebSocket support, and it scales to zero when your alarm is not ringing.

In this step, get ready to:
  • Write a Dockerfile and build the container image.
  • Deploy to Cloud Run with a 5-minute timeout and session affinity.
  • Verify the deployment by hitting the health endpoint and testing the WebSocket.
Write the Dockerfile

A Dockerfile tells Docker how to package your app into a portable container image. Cloud Run runs containers, so this is the bridge between your local code and the cloud.

  • Create a file called Dockerfile in your alarm-backend/ folder with the following content:
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

What does this Dockerfile do?

  • FROM python:3.11-slim starts from a lightweight Python image.
  • COPY requirements.txt and RUN pip install install your dependencies in a cached layer, so rebuilds are fast.
  • COPY . . copies the rest of your backend code into the container.
  • CMD runs Uvicorn on port 8080, which is the default port Cloud Run expects.
  • Save Dockerfile.
  • Confirm the file exists by running:
ls

You should see Dockerfile listed alongside main.py, gemini_session.py, penalty.py, and requirements.txt.

Now build the container image and push it to Artifact Registry using Cloud Build. This single command builds remotely so you do not need Docker installed locally.

  • Build and push the container image by running this command:
gcloud builds submit --tag gcr.io/[[GCP_PROJECT_ID="your-gcp-project-id"]]/alarm-agent

What does this command do?

gcloud builds submit uploads your code to Google Cloud, builds the Docker image remotely, and stores it in Container Registry. The --tag names the image so Cloud Run can find it.

This takes 2-3 minutes. When it finishes, you should see SUCCESS in the output.

Build failed or permission denied?

  • Make sure you are inside the alarm-backend/ folder when you run the command.
  • Confirm your project ID is correct. Run gcloud config get-value project to check.
  • If you see a Cloud Build API error, enable it by running gcloud services enable cloudbuild.googleapis.com.

Still stuck? Help me debug my gcloud builds submit error

Deploy to Cloud Run

With the image built, you can now deploy it as a Cloud Run service. Cloud Run supports WebSockets natively, so your phone can stream audio to it over a persistent connection.

  • Deploy the service by running this command:
gcloud run deploy alarm-agent \
  --image gcr.io/[[GCP_PROJECT_ID="your-gcp-project-id"]]/alarm-agent \
  --region us-central1 \
  --platform managed \
  --allow-unauthenticated \
  --timeout 300 \
  --session-affinity \
  --set-env-vars GEMINI_API_KEY=your-api-key-here

What do these flags mean?

  • --timeout 300 gives each connection 5 minutes before Cloud Run kills it. An alarm conversation should last 3-5 minutes at most.
  • --session-affinity keeps a client pinned to the same container instance for the duration of the WebSocket connection.
  • --allow-unauthenticated lets your phone connect without an OAuth token. Fine for a personal alarm clock.
  • --set-env-vars injects your Gemini API key into the container at runtime.

Why Cloud Run instead of a VM?

Cloud Run scales to zero when no alarms are firing. You only pay for the seconds your container is actually handling a request. A VM would run 24/7 and cost money even while you sleep peacefully.

  • Wait for the deploy to finish. When it completes, the terminal prints a service URL like https://alarm-agent-xxxx-uc.a.run.app.
  • Copy this URL. You will need it in the next step when configuring the mobile app.

Deploy failed?

  • If you see a quota or billing error, confirm billing is enabled on your project in the Google Cloud Console.
  • If the image is not found, double check that the --image flag uses the same project ID you used in the build command.
  • If the service starts but immediately crashes, check the logs with gcloud run services logs read alarm-agent --region us-central1.

Need help? Help me debug my Cloud Run deployment error

Verify the deployment

Before wiring up the mobile app, confirm the service is healthy and the WebSocket endpoint responds.

  • Test the health endpoint by running:
curl https://[[CLOUD_RUN_URL="your-cloud-run-url"]].run.app/health

You should see {"status":"ok"} in the response.

  • Test the WebSocket endpoint by running this Python script:
import asyncio
import websockets

async def test():
    # Connect to the deployed WebSocket endpoint
    uri = "wss://[[CLOUD_RUN_URL=\"your-cloud-run-url\"]].run.app/ws/alarm?user_id=test_user"
    async with websockets.connect(uri) as ws:
        print("Connected!")
        # Listen for the first audio response from the AI
        msg = await asyncio.wait_for(ws.recv(), timeout=15)
        print(f"Received {len(msg)} bytes of audio")
        await ws.close()

asyncio.run(test())

What does this test script do?

It opens a WebSocket connection to your deployed service, which triggers the Gemini Live API session. The AI immediately starts talking (the system instruction says to greet loudly), so you should receive audio bytes within a few seconds.

  • Run the test script:
python test_ws.py

You should see Connected! followed by a message showing the number of audio bytes received.

Connection timed out or refused?

  • Make sure you are using wss:// (not ws://). Cloud Run requires HTTPS/WSS.
  • If you see a timeout, the container might be cold-starting. Cloud Run can take 5-10 seconds to spin up the first time. Try again.
  • If you see a 403 error, confirm you deployed with --allow-unauthenticated.

Still having trouble? Help me connect to my Cloud Run WebSocket endpoint

✔️ Awesome, I've got everything!

Great. Double check you have saved your Dockerfile and noted your Cloud Run service URL.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile:

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

Your backend is live in the cloud and ready to accept WebSocket connections. Next up, you will build the Expo mobile app that streams your microphone audio to this endpoint and plays the AI's voice responses back through your phone's speaker.

Create the Expo Mobile App

Your backend is deployed and waiting for connections. Now you need something to connect to it.

In this step, you will build the mobile app that ties everything together. The app schedules alarms, streams your voice to the backend over a WebSocket, and plays the AI's audio response back in real time using Expo and the @edkimmel/expo-audio-stream library.

In this step, get ready to:
  • Initialize an Expo project and install real-time audio streaming dependencies.
  • Build the alarm scheduling screen with notification triggers.
  • Create the audio streaming screen that connects to your Cloud Run WebSocket.
Initialize the Expo project and install dependencies

You will scaffold a new Expo project and install the packages needed for real-time audio, navigation, and notifications.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a new Expo project with the blank TypeScript template by running:
npx create-expo-app wake-up-agent --template blank-typescript

What does this command do?

This scaffolds a React Native project using Expo's toolchain with TypeScript preconfigured. The blank-typescript template gives you a minimal starting point without extra boilerplate.

  • Move into the new project folder by running:
cd wake-up-agent
  • Install the project dependencies by running:
npx expo install @edkimmel/expo-audio-stream @react-native-community/datetimepicker @react-navigation/native @react-navigation/native-stack expo-notifications react-native-screens react-native-safe-area-context

What are these packages?

  • @edkimmel/expo-audio-stream provides real-time microphone streaming and jitter-buffered audio playback. This is what makes bidirectional voice conversations possible.
  • @react-navigation/native and @react-navigation/native-stack handle screen-to-screen navigation.
  • expo-notifications schedules local alarm notifications that fire at the time you set.
  • @react-native-community/datetimepicker gives you the time picker wheel for selecting your alarm time.
  • Confirm the packages installed by running:
cat package.json

You should see all the packages listed under dependencies, including @edkimmel/expo-audio-stream and expo-notifications.

Install failing?

Make sure you are inside the wake-up-agent folder, not its parent directory. Run pwd to check. If you see peer dependency warnings, those are safe to ignore for Expo projects.

Help me debug my expo install errors

The @edkimmel/expo-audio-stream library includes an Expo config plugin that automatically adds the RECORD_AUDIO Android permission to your build. Notification permissions are requested at runtime in your alarm screen code.

Now create the navigation structure. This is the entry point that defines the three screens in your app.

  • Open the wake-up-agent folder in your code editor.
  • Replace the contents of App.tsx with the following code:
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import AlarmScreen from "./src/screens/AlarmScreen";
import WakeUpScreen from "./src/screens/WakeUpScreen";
import PenaltyScreen from "./src/screens/PenaltyScreen";

// Create the navigation stack
const Stack = createNativeStackNavigator();

export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator initialRouteName="Alarm">
        <Stack.Screen
          name="Alarm"
          component={AlarmScreen}
          options={{ title: "Wake Up Agent" }}
        />
        <Stack.Screen
          name="WakeUp"
          component={WakeUpScreen}
          options={{ headerShown: false }}
        />
        <Stack.Screen
          name="Penalty"
          component={PenaltyScreen}
          options={{ title: "Penalty Ledger" }}
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

What does this code do?

  • NavigationContainer wraps the entire app and manages navigation state.
  • Stack.Navigator creates a stack of screens where each new screen slides on top. The initialRouteName sets the alarm screen as the first thing the user sees.
  • The WakeUp screen hides the header (headerShown: false) because it takes over the full screen during an alarm.
  • Save App.tsx.

You will see import errors in your editor because the screen files do not exist yet. That is expected.

Build the alarm scheduling screen

The alarm screen lets the user pick a wake-up time and schedules a local notification that fires at that moment. When the user taps the notification, the app navigates to the voice conversation screen.

  • Create the screens directory by running:
mkdir -p src/screens
  • Create a new file at src/screens/AlarmScreen.tsx and add the following code:
import React, { useState } from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import DateTimePicker from "@react-native-community/datetimepicker";
import * as Notifications from "expo-notifications";

// Configure how notifications display when the app is in the foreground
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

export default function AlarmScreen({ navigation }: any) {
  const [alarmTime, setAlarmTime] = useState(new Date());
  const [alarmSet, setAlarmSet] = useState(false);

  const scheduleAlarm = async () => {
    // Request notification permissions from the user
    const { status } = await Notifications.requestPermissionsAsync();
    if (status !== "granted") {
      alert("Permission needed for alarm notifications");
      return;
    }

    // If the selected time has already passed today, schedule for tomorrow
    const trigger = new Date(alarmTime);
    if (trigger <= new Date()) {
      trigger.setDate(trigger.getDate() + 1);
    }

    // Schedule the local notification at the chosen time
    await Notifications.scheduleNotificationAsync({
      content: {
        title: "WAKE UP!",
        body: "Your AI alarm is calling. Answer or pay.",
        sound: "default",
        data: { screen: "WakeUp" },
      },
      trigger: { date: trigger },
    });

    setAlarmSet(true);
  };

  // Listen for notification taps and navigate to the WakeUp screen
  React.useEffect(() => {
    const subscription = Notifications.addNotificationResponseReceivedListener(
      () => {
        navigation.navigate("WakeUp");
      }
    );
    return () => subscription.remove();
  }, [navigation]);

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Set Your Alarm</Text>
      <DateTimePicker
        value={alarmTime}
        mode="time"
        display="spinner"
        onChange={(_, date) => date && setAlarmTime(date)}
      />
      <TouchableOpacity style={styles.button} onPress={scheduleAlarm}>
        <Text style={styles.buttonText}>
          {alarmSet ? "Alarm Set!" : "Set Alarm"}
        </Text>
      </TouchableOpacity>
      <TouchableOpacity
        style={styles.secondaryButton}
        onPress={() => navigation.navigate("WakeUp")}
      >
        <Text style={styles.secondaryText}>Test Wake Up Now</Text>
      </TouchableOpacity>
      <TouchableOpacity
        style={styles.secondaryButton}
        onPress={() => navigation.navigate("Penalty")}
      >
        <Text style={styles.secondaryText}>View Penalty Ledger</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: "center", alignItems: "center", padding: 20, backgroundColor: "#1a1a2e" },
  title: { fontSize: 28, fontWeight: "bold", color: "#fff", marginBottom: 30 },
  button: { backgroundColor: "#e94560", paddingHorizontal: 40, paddingVertical: 15, borderRadius: 10, marginTop: 20 },
  buttonText: { color: "#fff", fontSize: 18, fontWeight: "bold" },
  secondaryButton: { marginTop: 15, padding: 10 },
  secondaryText: { color: "#aaa", fontSize: 16 },
});

What does this code do?

  • setNotificationHandler at the top configures how notifications behave when the app is open. Without it, notifications would be silently swallowed.
  • scheduleAlarm requests notification permissions, then schedules a notification at the selected time. If the time already passed today, it rolls to tomorrow.
  • addNotificationResponseReceivedListener fires when the user taps the notification. It navigates straight to the WakeUp screen to start the AI conversation.
  • The "Test Wake Up Now" button lets you skip the alarm and jump directly to the voice screen for testing.
  • Save src/screens/AlarmScreen.tsx.

You cannot test this screen yet because the other screens it references do not exist. You will build those next.

Seeing TypeScript errors?

Make sure you installed all packages in the previous step. Missing the @react-native-community/datetimepicker or expo-notifications package will cause import errors.

Help me fix TypeScript errors in my AlarmScreen

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/screens/AlarmScreen.tsx before moving on.

ⓧ I'd like to double check the full code

import React, { useState } from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import DateTimePicker from "@react-native-community/datetimepicker";
import * as Notifications from "expo-notifications";

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

export default function AlarmScreen({ navigation }: any) {
  const [alarmTime, setAlarmTime] = useState(new Date());
  const [alarmSet, setAlarmSet] = useState(false);

  const scheduleAlarm = async () => {
    const { status } = await Notifications.requestPermissionsAsync();
    if (status !== "granted") {
      alert("Permission needed for alarm notifications");
      return;
    }

    const trigger = new Date(alarmTime);
    if (trigger <= new Date()) {
      trigger.setDate(trigger.getDate() + 1);
    }

    await Notifications.scheduleNotificationAsync({
      content: {
        title: "WAKE UP!",
        body: "Your AI alarm is calling. Answer or pay.",
        sound: "default",
        data: { screen: "WakeUp" },
      },
      trigger: { date: trigger },
    });

    setAlarmSet(true);
  };

  React.useEffect(() => {
    const subscription = Notifications.addNotificationResponseReceivedListener(
      () => {
        navigation.navigate("WakeUp");
      }
    );
    return () => subscription.remove();
  }, [navigation]);

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Set Your Alarm</Text>
      <DateTimePicker
        value={alarmTime}
        mode="time"
        display="spinner"
        onChange={(_, date) => date && setAlarmTime(date)}
      />
      <TouchableOpacity style={styles.button} onPress={scheduleAlarm}>
        <Text style={styles.buttonText}>
          {alarmSet ? "Alarm Set!" : "Set Alarm"}
        </Text>
      </TouchableOpacity>
      <TouchableOpacity
        style={styles.secondaryButton}
        onPress={() => navigation.navigate("WakeUp")}
      >
        <Text style={styles.secondaryText}>Test Wake Up Now</Text>
      </TouchableOpacity>
      <TouchableOpacity
        style={styles.secondaryButton}
        onPress={() => navigation.navigate("Penalty")}
      >
        <Text style={styles.secondaryText}>View Penalty Ledger</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: "center", alignItems: "center", padding: 20, backgroundColor: "#1a1a2e" },
  title: { fontSize: 28, fontWeight: "bold", color: "#fff", marginBottom: 30 },
  button: { backgroundColor: "#e94560", paddingHorizontal: 40, paddingVertical: 15, borderRadius: 10, marginTop: 20 },
  buttonText: { color: "#fff", fontSize: 18, fontWeight: "bold" },
  secondaryButton: { marginTop: 15, padding: 10 },
  secondaryText: { color: "#aaa", fontSize: 16 },
});
Create the audio streaming and penalty screens

The WakeUp screen is the core of your app. It opens a WebSocket to your Cloud Run backend, streams your microphone audio up as PCM chunks, and plays the AI's audio response back through a jitter-buffered pipeline.

  • Create a new file at src/screens/WakeUpScreen.tsx and add the following code:
import React, { useEffect, useState, useRef } from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import ExpoPlayAudioStream, { Pipeline } from "@edkimmel/expo-audio-stream";

// Replace with your actual Cloud Run service URL
const BACKEND_URL = "wss://[[CLOUD_RUN_DOMAIN="your-cloud-run-service-url.run.app"]]/ws/alarm?user_id=default_user";

export default function WakeUpScreen({ navigation }: any) {
  const [connected, setConnected] = useState(false);
  const [penaltyTotal, setPenaltyTotal] = useState(0);
  const [snoozeCount, setSnoozeCount] = useState(0);
  const [status, setStatus] = useState("Connecting...");
  const wsRef = useRef<WebSocket | null>(null);
  const turnIdRef = useRef(0);

  useEffect(() => {
    startSession();
    return () => stopSession();
  }, []);

  const startSession = async () => {
    const ws = new WebSocket(BACKEND_URL);
    wsRef.current = ws;

    ws.onopen = async () => {
      setConnected(true);
      setStatus("Connected - AI is waking you up!");

      // Connect the playback pipeline at 24kHz (Gemini's output rate)
      Pipeline.connect({
        sampleRate: 24000,
        bufferDurationMs: 100,
      });

      // Start streaming microphone at 16kHz mono PCM (Gemini's input rate)
      const { subscription } = await ExpoPlayAudioStream.startMicrophone({
        sampleRate: 16000,
        channels: 1,
        encoding: "PCM_S16LE",
        intervalMs: 100,
      });

      subscription?.remove;
      // Subscribe to audio events and send each chunk over the WebSocket
      ExpoPlayAudioStream.subscribeToAudioEvents((event) => {
        if (event.data && ws.readyState === WebSocket.OPEN) {
          ws.send(
            Uint8Array.from(atob(event.data), (c) => c.charCodeAt(0))
          );
        }
      });
    };

    ws.onmessage = (msg) => {
      if (typeof msg.data === "string") {
        // Text messages are JSON events (penalty, alertness)
        const event = JSON.parse(msg.data);
        if (event.type === "penalty") {
          setPenaltyTotal(event.data.total_today);
          setSnoozeCount(event.data.snooze_count);
        } else if (event.type === "alertness" && event.data.status === "awake") {
          setStatus("You are awake! Great job!");
          setTimeout(() => navigation.goBack(), 3000);
        }
      } else {
        // Binary messages are audio from the AI - push to playback pipeline
        turnIdRef.current += 1;
        Pipeline.pushAudioSync({
          audio: btoa(String.fromCharCode(...new Uint8Array(msg.data))),
          turnId: String(turnIdRef.current),
          isFirstChunk: true,
          isLastChunk: false,
        });
      }
    };

    ws.onerror = () => setStatus("Connection error. Retrying...");
    ws.onclose = () => {
      setConnected(false);
      setStatus("Disconnected");
    };
  };

  const stopSession = () => {
    ExpoPlayAudioStream.stopMicrophone();
    Pipeline.disconnect();
    wsRef.current?.close();
  };

  const handleSnooze = () => {
    setStatus("Snoozing... penalty incoming!");
  };

  return (
    <View style={styles.container}>
      <Text style={styles.emoji}>{"\u23F0"}</Text>
      <Text style={styles.status}>{status}</Text>
      {penaltyTotal > 0 && (
        <View style={styles.penaltyBox}>
          <Text style={styles.penaltyText}>
            Penalty: ${penaltyTotal}
          </Text>
          <Text style={styles.snoozeText}>
            Snoozes: {snoozeCount}
          </Text>
        </View>
      )}
      <TouchableOpacity style={styles.stopButton} onPress={stopSession}>
        <Text style={styles.stopText}>I'm Awake!</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: "#0f0f23" },
  emoji: { fontSize: 80, marginBottom: 20 },
  status: { fontSize: 18, color: "#fff", textAlign: "center", marginHorizontal: 20 },
  penaltyBox: { backgroundColor: "#e94560", padding: 20, borderRadius: 10, marginTop: 20 },
  penaltyText: { fontSize: 24, fontWeight: "bold", color: "#fff" },
  snoozeText: { fontSize: 16, color: "#ffccd5", marginTop: 5 },
  stopButton: { backgroundColor: "#16c79a", paddingHorizontal: 40, paddingVertical: 15, borderRadius: 10, marginTop: 30 },
  stopText: { color: "#fff", fontSize: 18, fontWeight: "bold" },
});

What does this code do?

  • startSession opens a WebSocket to your Cloud Run backend. Once connected, it starts the microphone at 16kHz (the rate Gemini expects for input) and connects the playback pipeline at 24kHz (Gemini's output rate).
  • subscribeToAudioEvents fires every 100ms with a chunk of base64-encoded PCM audio from your mic. The code decodes it to bytes and sends it over the WebSocket.
  • When a binary message arrives from the server, it is audio from the AI. Pipeline.pushAudioSync feeds it into a jitter buffer that handles playback timing so the voice sounds smooth.
  • Text messages are JSON events from the backend's penalty and alertness functions. The screen updates the penalty counter in real time as the AI calls those tools.
  • Save src/screens/WakeUpScreen.tsx.

Don't forget to update BACKEND_URL

The BACKEND_URL constant at the top of the file must point to your actual Cloud Run service URL from the previous step. Make sure it starts with wss:// (not https://) because this is a WebSocket connection.

Help me find my Cloud Run service URL

Now create the penalty ledger screen. This is a simple display that fetches penalty history from your backend and shows the running total.

  • Create a new file at src/screens/PenaltyScreen.tsx and add the following code:
import React, { useEffect, useState } from "react";
import { View, Text, FlatList, StyleSheet } from "react-native";

// Replace with your actual Cloud Run service URL
const BACKEND_URL = "https://[[CLOUD_RUN_DOMAIN="your-cloud-run-service-url.run.app"]]";

export default function PenaltyScreen() {
  const [penalties, setPenalties] = useState<any[]>([]);
  const [total, setTotal] = useState(0);

  useEffect(() => {
    fetchPenalties();
  }, []);

  // Fetch the penalty ledger from the backend
  const fetchPenalties = async () => {
    try {
      const response = await fetch(
        `${BACKEND_URL}/penalties?user_id=default_user`
      );
      const data = await response.json();
      setPenalties(data.entries || []);
      setTotal(data.total || 0);
    } catch (error) {
      console.error("Failed to fetch penalties:", error);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.totalLabel}>Total Owed</Text>
      <Text style={styles.totalAmount}>${total}</Text>
      <FlatList
        data={penalties}
        keyExtractor={(item, index) => index.toString()}
        renderItem={({ item }) => (
          <View style={styles.entry}>
            <Text style={styles.entryDate}>{item.date}</Text>
            <Text style={styles.entryAmount}>
              ${item.amount} (snooze #{item.snooze_number})
            </Text>
          </View>
        )}
        ListEmptyComponent={
          <Text style={styles.empty}>No penalties yet. Keep it that way!</Text>
        }
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: "#1a1a2e", padding: 20 },
  totalLabel: { fontSize: 16, color: "#aaa", textAlign: "center" },
  totalAmount: { fontSize: 48, fontWeight: "bold", color: "#e94560", textAlign: "center", marginBottom: 30 },
  entry: { backgroundColor: "#16213e", padding: 15, borderRadius: 8, marginBottom: 10 },
  entryDate: { color: "#aaa", fontSize: 14 },
  entryAmount: { color: "#fff", fontSize: 18, fontWeight: "bold", marginTop: 5 },
  empty: { color: "#4a4a4a", textAlign: "center", marginTop: 50, fontSize: 16 },
});

What does this code do?

  • fetchPenalties calls your backend's /penalties endpoint and retrieves the full penalty ledger for the user.
  • FlatList renders each penalty entry as a card showing the date, amount, and snooze number. If there are no penalties, it shows an encouraging message.
  • Save src/screens/PenaltyScreen.tsx.
  • Confirm your files exist by running:
ls src/screens

You should see AlarmScreen.tsx, WakeUpScreen.tsx, and PenaltyScreen.tsx listed in the output.

Missing a screen file?

Double check that you created the files inside src/screens/ and not in the project root. The imports in App.tsx expect the path ./src/screens/ exactly.

Help me fix file path issues in my Expo project

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved: App.tsx, AlarmScreen.tsx, WakeUpScreen.tsx, and PenaltyScreen.tsx.

ⓧ I'd like to double check the full code

import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import AlarmScreen from "./src/screens/AlarmScreen";
import WakeUpScreen from "./src/screens/WakeUpScreen";
import PenaltyScreen from "./src/screens/PenaltyScreen";

const Stack = createNativeStackNavigator();

export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator initialRouteName="Alarm">
        <Stack.Screen
          name="Alarm"
          component={AlarmScreen}
          options={{ title: "Wake Up Agent" }}
        />
        <Stack.Screen
          name="WakeUp"
          component={WakeUpScreen}
          options={{ headerShown: false }}
        />
        <Stack.Screen
          name="Penalty"
          component={PenaltyScreen}
          options={{ title: "Penalty Ledger" }}
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
}
import React, { useState } from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import DateTimePicker from "@react-native-community/datetimepicker";
import * as Notifications from "expo-notifications";

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

export default function AlarmScreen({ navigation }: any) {
  const [alarmTime, setAlarmTime] = useState(new Date());
  const [alarmSet, setAlarmSet] = useState(false);

  const scheduleAlarm = async () => {
    const { status } = await Notifications.requestPermissionsAsync();
    if (status !== "granted") {
      alert("Permission needed for alarm notifications");
      return;
    }

    const trigger = new Date(alarmTime);
    if (trigger <= new Date()) {
      trigger.setDate(trigger.getDate() + 1);
    }

    await Notifications.scheduleNotificationAsync({
      content: {
        title: "WAKE UP!",
        body: "Your AI alarm is calling. Answer or pay.",
        sound: "default",
        data: { screen: "WakeUp" },
      },
      trigger: { date: trigger },
    });

    setAlarmSet(true);
  };

  React.useEffect(() => {
    const subscription = Notifications.addNotificationResponseReceivedListener(
      () => {
        navigation.navigate("WakeUp");
      }
    );
    return () => subscription.remove();
  }, [navigation]);

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Set Your Alarm</Text>
      <DateTimePicker
        value={alarmTime}
        mode="time"
        display="spinner"
        onChange={(_, date) => date && setAlarmTime(date)}
      />
      <TouchableOpacity style={styles.button} onPress={scheduleAlarm}>
        <Text style={styles.buttonText}>
          {alarmSet ? "Alarm Set!" : "Set Alarm"}
        </Text>
      </TouchableOpacity>
      <TouchableOpacity
        style={styles.secondaryButton}
        onPress={() => navigation.navigate("WakeUp")}
      >
        <Text style={styles.secondaryText}>Test Wake Up Now</Text>
      </TouchableOpacity>
      <TouchableOpacity
        style={styles.secondaryButton}
        onPress={() => navigation.navigate("Penalty")}
      >
        <Text style={styles.secondaryText}>View Penalty Ledger</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: "center", alignItems: "center", padding: 20, backgroundColor: "#1a1a2e" },
  title: { fontSize: 28, fontWeight: "bold", color: "#fff", marginBottom: 30 },
  button: { backgroundColor: "#e94560", paddingHorizontal: 40, paddingVertical: 15, borderRadius: 10, marginTop: 20 },
  buttonText: { color: "#fff", fontSize: 18, fontWeight: "bold" },
  secondaryButton: { marginTop: 15, padding: 10 },
  secondaryText: { color: "#aaa", fontSize: 16 },
});
import React, { useEffect, useState, useRef } from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import ExpoPlayAudioStream, { Pipeline } from "@edkimmel/expo-audio-stream";

const BACKEND_URL = "wss://your-cloud-run-url.run.app/ws/alarm?user_id=default_user";

export default function WakeUpScreen({ navigation }: any) {
  const [connected, setConnected] = useState(false);
  const [penaltyTotal, setPenaltyTotal] = useState(0);
  const [snoozeCount, setSnoozeCount] = useState(0);
  const [status, setStatus] = useState("Connecting...");
  const wsRef = useRef<WebSocket | null>(null);
  const turnIdRef = useRef(0);

  useEffect(() => {
    startSession();
    return () => stopSession();
  }, []);

  const startSession = async () => {
    const ws = new WebSocket(BACKEND_URL);
    wsRef.current = ws;

    ws.onopen = async () => {
      setConnected(true);
      setStatus("Connected - AI is waking you up!");

      Pipeline.connect({
        sampleRate: 24000,
        bufferDurationMs: 100,
      });

      const { subscription } = await ExpoPlayAudioStream.startMicrophone({
        sampleRate: 16000,
        channels: 1,
        encoding: "PCM_S16LE",
        intervalMs: 100,
      });

      subscription?.remove;
      ExpoPlayAudioStream.subscribeToAudioEvents((event) => {
        if (event.data && ws.readyState === WebSocket.OPEN) {
          ws.send(
            Uint8Array.from(atob(event.data), (c) => c.charCodeAt(0))
          );
        }
      });
    };

    ws.onmessage = (msg) => {
      if (typeof msg.data === "string") {
        const event = JSON.parse(msg.data);
        if (event.type === "penalty") {
          setPenaltyTotal(event.data.total_today);
          setSnoozeCount(event.data.snooze_count);
        } else if (event.type === "alertness" && event.data.status === "awake") {
          setStatus("You are awake! Great job!");
          setTimeout(() => navigation.goBack(), 3000);
        }
      } else {
        turnIdRef.current += 1;
        Pipeline.pushAudioSync({
          audio: btoa(String.fromCharCode(...new Uint8Array(msg.data))),
          turnId: String(turnIdRef.current),
          isFirstChunk: true,
          isLastChunk: false,
        });
      }
    };

    ws.onerror = () => setStatus("Connection error. Retrying...");
    ws.onclose = () => {
      setConnected(false);
      setStatus("Disconnected");
    };
  };

  const stopSession = () => {
    ExpoPlayAudioStream.stopMicrophone();
    Pipeline.disconnect();
    wsRef.current?.close();
  };

  const handleSnooze = () => {
    setStatus("Snoozing... penalty incoming!");
  };

  return (
    <View style={styles.container}>
      <Text style={styles.emoji}>⏰</Text>
      <Text style={styles.status}>{status}</Text>
      {penaltyTotal > 0 && (
        <View style={styles.penaltyBox}>
          <Text style={styles.penaltyText}>
            Penalty: ${penaltyTotal}
          </Text>
          <Text style={styles.snoozeText}>
            Snoozes: {snoozeCount}
          </Text>
        </View>
      )}
      <TouchableOpacity style={styles.stopButton} onPress={stopSession}>
        <Text style={styles.stopText}>I'm Awake!</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: "#0f0f23" },
  emoji: { fontSize: 80, marginBottom: 20 },
  status: { fontSize: 18, color: "#fff", textAlign: "center", marginHorizontal: 20 },
  penaltyBox: { backgroundColor: "#e94560", padding: 20, borderRadius: 10, marginTop: 20 },
  penaltyText: { fontSize: 24, fontWeight: "bold", color: "#fff" },
  snoozeText: { fontSize: 16, color: "#ffccd5", marginTop: 5 },
  stopButton: { backgroundColor: "#16c79a", paddingHorizontal: 40, paddingVertical: 15, borderRadius: 10, marginTop: 30 },
  stopText: { color: "#fff", fontSize: 18, fontWeight: "bold" },
});
import React, { useEffect, useState } from "react";
import { View, Text, FlatList, StyleSheet } from "react-native";

const BACKEND_URL = "https://your-cloud-run-url.run.app";

export default function PenaltyScreen() {
  const [penalties, setPenalties] = useState<any[]>([]);
  const [total, setTotal] = useState(0);

  useEffect(() => {
    fetchPenalties();
  }, []);

  const fetchPenalties = async () => {
    try {
      const response = await fetch(
        `${BACKEND_URL}/penalties?user_id=default_user`
      );
      const data = await response.json();
      setPenalties(data.entries || []);
      setTotal(data.total || 0);
    } catch (error) {
      console.error("Failed to fetch penalties:", error);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.totalLabel}>Total Owed</Text>
      <Text style={styles.totalAmount}>${total}</Text>
      <FlatList
        data={penalties}
        keyExtractor={(item, index) => index.toString()}
        renderItem={({ item }) => (
          <View style={styles.entry}>
            <Text style={styles.entryDate}>{item.date}</Text>
            <Text style={styles.entryAmount}>
              ${item.amount} (snooze #{item.snooze_number})
            </Text>
          </View>
        )}
        ListEmptyComponent={
          <Text style={styles.empty}>No penalties yet. Keep it that way!</Text>
        }
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: "#1a1a2e", padding: 20 },
  totalLabel: { fontSize: 16, color: "#aaa", textAlign: "center" },
  totalAmount: { fontSize: 48, fontWeight: "bold", color: "#e94560", textAlign: "center", marginBottom: 30 },
  entry: { backgroundColor: "#16213e", padding: 15, borderRadius: 8, marginBottom: 10 },
  entryDate: { color: "#aaa", fontSize: 14 },
  entryAmount: { color: "#fff", fontSize: 18, fontWeight: "bold", marginTop: 5 },
  empty: { color: "#4a4a4a", textAlign: "center", marginTop: 50, fontSize: 16 },
});

Why @edkimmel/expo-audio-stream instead of expo-av?

Expo's built-in audio libraries (expo-av and expo-audio) only support recording audio to a file. They cannot stream raw PCM chunks in real time.

For a voice AI conversation, you need to send audio chunks every 100ms while simultaneously playing back audio from the AI. The @edkimmel/expo-audio-stream library was built for exactly this use case. It provides real-time microphone streaming plus a jitter-buffered playback pipeline designed for WebSocket backends.

Your mobile app has all its screens built. Next, you will wire the alarm trigger to the voice conversation and test the entire flow end-to-end on a real Android device.

Connect Everything and Test End-to-End

You have a deployed backend on Cloud Run and a fully built mobile app with alarm scheduling, voice streaming, and a penalty dashboard. The only thing left is connecting these two halves together with your real service URL.

In this step, you will point your app at the live backend, build a real APK, install it on your phone, and run the full wake-up flow from alarm to AI conversation to penalty logging.

In this step, get ready to:
  • Wire your Cloud Run URL into the mobile app so the alarm trigger connects to the live backend.
  • Verify the penalty dashboard fetches real data from Firestore via your deployed endpoint.
  • Build an APK with EAS Build, install it on your Android phone, and run a full end-to-end test.
Update the backend URL in WakeUpScreen

Your WakeUpScreen.tsx still has a placeholder URL. You need to replace it with your actual Cloud Run service URL so the WebSocket connection reaches your deployed backend.

  • First, grab your Cloud Run service URL by running this command in your terminal:
gcloud run services describe alarm-agent --region us-central1 --format 'value(status.url)'

You should see a URL like https://alarm-agent-xxxxxxxx-uc.a.run.app in the output.

  • Open src/screens/WakeUpScreen.tsx in your editor.
  • Find this line near the top of the file:
const BACKEND_URL = "wss://your-cloud-run-url.run.app/ws/alarm?user_id=default_user";
  • Replace your-cloud-run-url.run.app with your actual Cloud Run hostname (without https://). The line should now look like this:
const BACKEND_URL = "wss://[[CLOUD_RUN_HOST="alarm-agent-xxxxxxxx-uc.a.run.app"]]/ws/alarm?user_id=default_user";

Why wss:// instead of https://?

WebSocket connections use the wss:// protocol (the secure variant of ws://). Cloud Run provides TLS termination automatically, so your WebSocket traffic is encrypted end-to-end.

How does the alarm trigger wire to this screen?

In AlarmScreen.tsx, the addNotificationResponseReceivedListener fires when the user taps the alarm notification. It calls navigation.navigate("WakeUp"), which opens the WakeUpScreen.

The WakeUpScreen's useEffect immediately calls startSession(), which opens the WebSocket, starts the microphone, and begins streaming audio to your Cloud Run backend. The AI starts talking within seconds.

  • Save src/screens/WakeUpScreen.tsx.
Update the backend URL in PenaltyScreen

The penalty dashboard also needs your real Cloud Run URL so it can fetch ledger data from Firestore via the backend's /penalties endpoint.

  • Open src/screens/PenaltyScreen.tsx in your editor.
  • Find this line near the top:
const BACKEND_URL = "https://your-cloud-run-url.run.app";
  • Replace your-cloud-run-url.run.app with the same Cloud Run hostname you used above. This time keep https:// (regular HTTP, not WebSocket):
const BACKEND_URL = "https://[[CLOUD_RUN_HOST="alarm-agent-xxxxxxxx-uc.a.run.app"]]";

What does the penalty dashboard do?

When PenaltyScreen mounts, it calls fetchPenalties() which hits your backend's /penalties?user_id=default_user endpoint. The backend queries Firestore for all penalty entries and returns them as JSON.

The screen then renders the total owed at the top and a scrollable list of each individual snooze penalty with its date and amount.

  • Save src/screens/PenaltyScreen.tsx.

Not sure which URL to use?

Run gcloud run services list to see all your Cloud Run services and their URLs. Look for the alarm-agent service. The URL column shows the full https:// address.

help me find my Cloud Run service URL for the alarm-agent backend

Build the APK and test on your phone

With both URLs pointing at your live backend, it is time to build a real Android APK and test the complete alarm-to-conversation flow on your phone.

  • Install the EAS CLI globally by running this command:
npm install -g eas-cli
  • Log in to your Expo account by running:
eas login

This will prompt you for your Expo username and password. If you do not have an Expo account, create one at expo.dev first.

  • Configure your project for EAS Build by running:
eas build:configure

This creates an eas.json file in your project with default build profiles.

  • Start the APK build by running this command:
eas build --platform android --profile preview

What does this command do?

The --profile preview flag builds an APK file (instead of an AAB for the Play Store). This is what you need to sideload directly onto your phone for testing.

The build runs on Expo's cloud servers. It typically takes 10-15 minutes to complete. You will see a URL in the terminal output where you can monitor progress.

  • Wait for the build to finish. When complete, EAS prints a download URL for your APK file.
  • Open the download URL on your Android phone's browser to download the APK.
  • Tap the downloaded APK file to install it. Your phone may ask you to enable Install from unknown sources in Settings. Allow it.

Build failing?

Make sure you saved both WakeUpScreen.tsx and PenaltyScreen.tsx before running the build. A syntax error in your URL string (missing quote, extra space) is the most common cause.

Check the build logs at the URL EAS provides. If you see a dependency error, run npx expo install --fix and rebuild.

help me debug my EAS Build failure for the wake-up-agent app

Now run the full end-to-end test.

  • Open the Wake Up Agent app on your phone.
  • Use the time picker to set an alarm for 2 minutes from now.
  • Tap Set Alarm.
  • The app will request notification permission. Tap Allow.
  • Lock your phone and wait for the alarm notification to appear.
  • Tap the notification. The app opens the WakeUp screen and connects to your Cloud Run backend.

Within a few seconds, you should hear the AI start talking to you. It will ask you an alertness question. Try answering it, then try saying "snooze" to see the penalty system in action.

What should I see during the test?

  • The status text changes to "Connected - AI is waking you up!" when the WebSocket connects.
  • You hear the AI speaking through your phone speaker within 2-3 seconds.
  • If you say "snooze", a red penalty box appears showing the dollar amount and snooze count.
  • If you answer the alertness question correctly, the status changes to "You are awake! Great job!" and the screen dismisses after 3 seconds.
  • After the conversation ends, go back to the main screen and tap View Penalty Ledger.

You should see your penalty entries listed with dates, amounts, and snooze numbers. If you snoozed during the test, your total owed will be displayed at the top.

No audio or connection error on the WakeUp screen?

Make sure your Cloud Run service is running. Hit https://your-cloud-run-url.run.app/health in a browser. You should see {"status":"ok"}.

Check that you used wss:// (not https://) in WakeUpScreen.tsx. Also confirm your phone has microphone permission enabled for the app in Android Settings.

help me debug why my phone cannot connect to Cloud Run WebSocket

Quick test without waiting

If you do not want to wait 2 minutes, tap the Test Wake Up Now button on the main screen. This skips the alarm scheduling and opens the WakeUp screen directly, connecting to the backend immediately.

Your voice AI alarm clock is fully operational. Every piece is connected: the alarm fires, your phone streams audio to Cloud Run, Gemini talks back, penalties log to Firestore, and you can see your debt piling up. Next up, the Secret Mission takes this even further with escalating penalty tiers and automated daily summaries.

Secret mission

Escalating Tiers and Daily Summary

Right now, your penalty system resets every day. A serial snoozer could hit $20 on Monday and start fresh on Tuesday with zero consequences. Let's make the penalties compound across days and send yourself a daily reckoning.

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 uses Google Cloud services that all fall within the free tier under normal usage, so ongoing costs are effectively $0.

Cost warning

Cloud Run scales to zero and costs nothing when idle. Firestore covers 1 GiB storage and 50,000 reads per day for free. Artifact Registry includes 500 MB of storage at no charge.

If you use the alarm heavily or store many penalty entries, you could eventually exceed these limits. Monitor usage in the Google Cloud Console billing dashboard.

Resources you used:

  • Cloud Run service (scales to zero, free tier covers ~2M requests/month).
  • Firestore database (free tier covers 1 GiB storage, 50K reads/day).
  • Artifact Registry container image (free tier covers 500 MB).
  • Local Python backend project files.
  • Local Expo mobile app project files.

Keep everything running

No action needed. Choose this if you are still actively using the alarm daily or plan to keep iterating on the project.

  • Cloud Run costs $0 when idle because it scales to zero between alarms.
  • Firestore costs $0 under normal alarm usage (well within the free tier).
  • Your Expo app stays installed on your phone and continues to work.

Pause - I'll come back to this later

Shut down the Cloud Run service to free up your project quota. Your code stays on your machine, and you can redeploy anytime.

  • Delete the Cloud Run service by running:
gcloud run services delete alarm-agent
  • Keep your local backend and mobile app files intact for later.
  • Firestore data stays available at no cost (leave it for your penalty history).
  • When you are ready to resume, redeploy with gcloud run deploy using the same Dockerfile.

Delete - I don't want to use this again

Remove all cloud resources and start fresh. This is irreversible for your penalty history.

  • Delete the Cloud Run service:
gcloud run services delete alarm-agent
  • Delete the Firestore database from the Google Cloud Console. Navigate to Firestore, select your database, and click Delete.
  • Delete the container image from Artifact Registry:
gcloud artifacts docker images delete YOUR-REGION-docker.pkg.dev/YOUR-PROJECT/cloud-run-source-deploy/alarm-agent
  • Delete the local backend folder and Expo app folder from your Desktop.
  • Uninstall the app from your Android phone.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional voice AI alarm clock that holds you accountable with real financial consequences for snoozing.

You learned how to:

  • Build a real-time bidirectional audio streaming backend with FastAPI and the Gemini Live API, deployed as a serverless WebSocket service on Cloud Run that scales to zero between alarms.
  • Implement function calling to give the AI agent the ability to track and escalate penalties, persisted to Firestore for durability across sessions.
  • Create an Expo mobile app with native audio streaming, alarm scheduling, and a penalty dashboard that connects to your cloud backend for real-time voice conversations.
  • Secret Mission: Added escalating penalty tiers that double on consecutive snooze days and automated daily summary push notifications.

Ready to quiz yourself?