Pocket Action Agent

Build an Android PWA task agent that uses Gemini tools to manage local tasks.

Introduction

30 Second Summary

A phone assistant becomes useful when it can complete the task you asked for. The result also needs to stay there when you return.

In this project, you will build a TypeScript AI agent that turns everyday requests into real changes on a task board. The finished Progressive Web App launches from your Android home screen with Gemini access protected by Firebase AI Logic plus Firebase App Check.

What You'll Build

You ask Pocket Action Agent to add two study tasks, then watch both cards appear while the agent reports the updated list.

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

  • A tool-using task agent that converts natural-language requests into visible changes on your task board.
  • A persistent task list that the agent can read before responding and that keeps its tasks after a reload.
  • A secured Android home-screen app installed from Google Chrome that opens in a standalone window.
  • Secret Mission: Add a destructive clear tool that cannot remove tasks until you approve a confirmation prompt.

Are there any prerequisites?

You need a Windows 10 or 11 computer, an Android phone with Chrome, and a Google account. You should also be comfortable reading basic JavaScript or TypeScript.

Before We Start

Before any hands-on work, lock in the purpose of Pocket Action Agent. You are committing to a phone-based task board where Gemini can carry out approved local task operations.

Set Up the Windows Toolchain

Pocket Action Agent needs a pinned local toolchain so version drift does not derail the agent work later.

You will use Visual Studio Code to edit every project file. Node.js provides the runtime for your development tools.

A pinned Vite server gives you the first visible page. TypeScript checks the project before each production build.

In this step, get ready to:
  • Install Visual Studio Code using the recommended Windows User setup.
  • Install Node.js v24.22.1 LTS with npm.
  • Launch the Pocket Action Agent starter shell from its pinned project.
Install and verify the Windows tools

Visual Studio Code's Windows User setup installs without administrator permissions. It also adds the editor command to your system path after the terminal restarts.

  • Visit the Visual Studio Code download page.
  • Select the User Installer that matches your Windows computer.
  • Run the downloaded installer.
  • Complete the installer using its default selections.
  • Press the Windows key to open Windows search.
  • Type Visual Studio Code and press Enter.
  • Select View from the top menu.
  • Select Terminal.

Node.js v24.22.1 LTS satisfies Vite's runtime requirement. The version check below identifies which installation path you need.

  • Check the installed Node.js version by running this command:
node --version

What does this check do?

The command asks the active terminal which Node.js runtime it can access. Its result reveals whether the required runtime is ready.

✔️ I see the required Node.js version

Your terminal shows v24.22.1. Your Node.js runtime is ready for the pinned Vite project.

ⓧ I see an older version

The current runtime is below the project's v24.22.1 LTS pin. Upgrade it before installing the project packages.

  • Download the Node.js v24.22.1 LTS Windows installer.
  • Run the downloaded installer.
  • Complete the installer using its default selections.
  • Close Visual Studio Code.
  • Press the Windows key to open Windows search.
  • Type Visual Studio Code and press Enter.
  • Select View from the top menu.
  • Select Terminal.
  • Confirm that the upgraded runtime is active by running this command:
node --version

What should I see?

The reopened terminal reloads the updated system path. You should now see v24.22.1.

Still seeing the older version?

Restart Windows so every application reloads the updated system path.

If the older version remains active, help me find which Node.js installation Windows is using.

ⓧ Command not found

Windows cannot find a Node.js runtime yet. The official Windows installer adds Node.js plus npm to your system path.

  • Download the Node.js v24.22.1 LTS Windows installer.
  • Run the downloaded installer.
  • Complete the installer using its default selections.
  • Close Visual Studio Code.
  • Press the Windows key to open Windows search.
  • Type Visual Studio Code and press Enter.
  • Select View from the top menu.
  • Select Terminal.
  • Confirm that Windows can access Node.js by running this command:
node --version

What should I see?

The new terminal reads the path added by the installer. You should see v24.22.1.

Node.js still missing?

Confirm that the installer finished before reopening Visual Studio Code.

If the command still cannot be found, help me make Node.js available in my Windows terminal.

npm comes with the Node.js Windows installer. This next check proves the package manager is available in the same terminal.

  • Check npm by running this command:
npm --version

What does this check prove?

A version number confirms that npm is available. You can now install the project's pinned packages.

That is the first toolchain hurdle cleared. Your editor can now use the exact Node.js runtime this project expects.

Create the pinned TypeScript project

The project folder keeps every file in one predictable place. Its configuration pins Firebase JavaScript SDK 13.1.0, TypeScript 7.0.2 plus Vite 8.3.5.

  • Create the pocket-action-agent folder on your Desktop by running these PowerShell commands:
Set-Location -Path ([environment]::GetFolderPath("Desktop"))
New-Item -Path "pocket-action-agent" -ItemType Directory
Set-Location -Path "pocket-action-agent"

What do these commands do?

  • The first command moves the terminal to your Windows Desktop folder.
  • The second command creates the pocket-action-agent folder.
  • The final command moves the terminal into that new folder.
  • Open the current project folder in Visual Studio Code by running this command:
code .

What does this command do?

The dot represents the terminal's current folder. Visual Studio Code opens pocket-action-agent as the active workspace.

  • Switch to the Visual Studio Code window showing pocket-action-agent in the Explorer.
  • Select View from the top menu.
  • Select Terminal.

The integrated terminal starts inside the folder shown in the Explorer. Commands you run here now affect the Pocket Action Agent project.

  • Select New File... in the Explorer.
  • Enter package.json as the file name.
  • Paste this pinned package configuration into package.json.
{
  "name": "pocket-action-agent",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "firebase": "13.1.0"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "vite": "8.3.5"
  }
}

What does this configuration do?

  • The scripts define the development server plus the production build.
  • The dependency pin installs the exact Firebase JavaScript SDK used later.
  • The development dependencies pin the TypeScript compiler plus Vite.
  • Save package.json with Ctrl+S.

You should see package.json in the Explorer without an unsaved-change dot.

Seeing JSON errors?

Compare every comma plus quotation mark with the code above. JSON rejects missing commas or trailing commas.

For help locating the typo, help me fix my package.json syntax.

  • Select New File... in the Explorer.
  • Enter tsconfig.json as the file name.
  • Paste this compiler configuration into tsconfig.json.
{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,
    "moduleResolution": "Bundler",
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "strict": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}

Why use this TypeScript configuration?

  • The browser libraries provide types for page elements plus browser APIs.
  • Strict checking catches unsafe values before the production build.
  • The Vite client types describe features provided by the development server.
  • Save tsconfig.json with Ctrl+S.

You should now see both configuration files in the Explorer.

  • Install the pinned project dependencies by running this command:
npm install

What does this install do?

npm reads package.json before downloading each pinned package. It stores the installed packages in node_modules.

You should see package installation output. The terminal prompt returns when the dependencies are ready.

Dependency installation failed?

Confirm that the terminal path ends with pocket-action-agent. Confirm that package.json is saved.

If npm still fails, help me troubleshoot the Pocket Action Agent dependency install.

  • Select New File... in the Explorer.
  • Enter .gitignore as the file name.
  • Paste these generated-file exclusions into .gitignore.
node_modules/
dist/
.firebase/
firebase-debug.log*

What does this file exclude?

The entries exclude installed packages plus generated build output. They also exclude local Firebase files plus debug logs.

  • Save .gitignore with Ctrl+S.

The Explorer now shows the saved .gitignore file beside the project configuration.

The Firebase CLI connects this local folder to Firebase services in a later step. Installing the pinned release now prevents command differences during deployment.

  • Install Firebase CLI 15.34.1 globally by running this command:
npm install -g firebase-tools@15.34.1

What does this install do?

The global install makes the Firebase command available outside a single project. The version pin keeps later Hosting commands consistent.

Firebase CLI installation failed?

Keep the terminal open until npm returns to the prompt. Check your internet connection if package downloads stop.

If the install reports a permissions or path problem, help me install Firebase CLI globally on Windows.

  • Confirm the Firebase CLI version by running this command:
firebase --version

What should I see?

You should see 15.34.1. That output proves the global Firebase CLI pin is active.

Build and run the starter shell

The first page gives you an immediate test of the complete local toolchain. Its dark mobile shell becomes the task board in the next step.

  • Select New File... in the Explorer.
  • Enter index.html as the file name.
  • Paste this page structure into index.html.
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="theme-color" content="#111827" />
    <title>Pocket Action Agent</title>
  </head>
  <body>
    <main class="app-shell">
      <header>
        <p class="eyebrow">Tool-using AI on your phone</p>
        <h1>Pocket Action Agent</h1>
        <p>Ask for a task change and watch the board update.</p>
      </header>
    </main>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

What does this page contain?

  • The viewport setting lets the layout match a phone screen.
  • The app shell contains the project name plus its purpose.
  • The module script loads the TypeScript entry point from src/main.ts.
  • Save index.html with Ctrl+S.

You should see index.html in the Explorer beside the configuration files.

  • Select the new-folder icon in the Explorer.
  • Enter src as the folder name.
  • Select the src folder in the Explorer.
  • Select New File....
  • Enter main.ts as the file name.
  • Paste this stylesheet import into src/main.ts.
import "./style.css";

What does this entry point do?

Vite loads this TypeScript module from index.html. The import pulls the page styles into the development build.

  • Save src/main.ts with Ctrl+S.

The Explorer now shows main.ts inside the src folder.

  • Select the src folder in the Explorer.
  • Select New File....
  • Enter style.css as the file name.
  • Paste these base page styles into src/style.css.
:root {
  color: #e5e7eb;
  background: #030712;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top, #1f2937 0, transparent 40%),
    #030712;
}

What do these styles control?

  • The root colors establish the dark theme plus readable text.
  • The box-sizing rule keeps element dimensions predictable.
  • The body fills the screen with a layered dark background.
  • Save src/style.css with Ctrl+S.

Before you start the server, what page title do you expect the browser to show?

  • Start the Vite development server by running this command:
npm run dev

What does this command start?

npm runs the project's Vite development script. The terminal prints the local address after the server is ready.

  • Press the Windows key to open Windows search.
  • Type Google Chrome and press Enter.
  • Enter http://localhost:5173 in the address bar.

You should see the Pocket Action Agent heading on a dark background. The green accent and centered spacing arrive in the next style chunk.

Local page not loading?

Keep the terminal running while Chrome loads the local address. Check the terminal for a file name if Vite reports a compile error.

If the page still does not load, help me debug my Vite development server.

The shell now runs through Vite. A final layout layer makes the local page match the mobile-first starting point.

  • Return to src/style.css in Visual Studio Code.
  • Paste this layout code below the closing brace of the body rule.
.app-shell {
  width: min(100% - 2rem, 680px);
  margin: 0 auto;
  padding: 2rem 0 4rem;
}

header {
  margin-bottom: 1.5rem;
}

h1,
h2,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 0.5rem;
  font-size: clamp(2rem, 8vw, 3.5rem);
  line-height: 1;
}

.eyebrow {
  color: #86efac;
}

What does this layout add?

  • The app shell stays narrow enough to read comfortably on desktop.
  • The heading scales between phone plus desktop sizes.
  • The eyebrow text uses green to signal the agent theme.
  • Save src/style.css with Ctrl+S.

Before you refresh Chrome, which parts of the shell do you expect to look different?

  • Return to Chrome.
  • Refresh http://localhost:5173.

You should see a centered Pocket Action Agent shell with a large heading. You should also see green text reading "Tool-using AI on your phone".

Your local toolchain is working from editor to browser. Vite is serving the exact starter shell that the task board builds on.

Keep the server running

Leave this terminal running when you move to the next step. Vite keeps serving the app while you add the task board.

✔️ Awesome, I've got everything!

Great. Confirm that every file is saved before you continue.

ⓧ I'd like to double check the full code

Compare each project file with the complete versions below.

node_modules/
dist/
.firebase/
firebase-debug.log*
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="theme-color" content="#111827" />
    <title>Pocket Action Agent</title>
  </head>
  <body>
    <main class="app-shell">
      <header>
        <p class="eyebrow">Tool-using AI on your phone</p>
        <h1>Pocket Action Agent</h1>
        <p>Ask for a task change and watch the board update.</p>
      </header>
    </main>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
{
  "name": "pocket-action-agent",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "firebase": "13.1.0"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "vite": "8.3.5"
  }
}
import "./style.css";
:root {
  color: #e5e7eb;
  background: #030712;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top, #1f2937 0, transparent 40%),
    #030712;
}

.app-shell {
  width: min(100% - 2rem, 680px);
  margin: 0 auto;
  padding: 2rem 0 4rem;
}

header {
  margin-bottom: 1.5rem;
}

h1,
h2,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 0.5rem;
  font-size: clamp(2rem, 8vw, 3.5rem);
  line-height: 1;
}

.eyebrow {
  color: #86efac;
}
{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,
    "moduleResolution": "Bundler",
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "strict": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}

Your pinned Windows toolchain is ready. Next, you will turn this shell into a task board that survives browser reloads.

Build the Local Task Board

Your Pocket Action Agent shell is running in the browser. Now you can turn that shell into a working task board.

A useful agent needs state that it can read or change. This board creates that state with localStorage. Tasks stay in the browser after a reload.

In this step, get ready to:
  • Store typed tasks in the browser.
  • Render saved tasks on a live task board.
  • Confirm that a task survives a browser reload.
Create persistent task storage

The storage module gives every task the same shape. It also keeps browser reads separate from browser writes.

  • Select the src folder in the VS Code Explorer sidebar.
  • Click the New File icon at the top of the sidebar.
  • Enter storage.ts as the file name.
  • Press Enter to create the file.
  • Add the task type plus the storage readers by pasting this code into src/storage.ts:
export type Task = {
  id: string;
  title: string;
  done: boolean;
  createdAt: string;
};

const STORAGE_KEY = "pocket-action-agent.tasks";

function readTasks(): Task[] {
  const saved = localStorage.getItem(STORAGE_KEY);
  if (!saved) return [];

  try {
    return JSON.parse(saved) as Task[];
  } catch {
    return [];
  }
}

function writeTasks(tasks: Task[]): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}

export function listTasks(): Task[] {
  return readTasks();
}

How does storage work?

  • The Task type defines the fields that every task contains.
  • STORAGE_KEY gives the task list one consistent browser storage location.
  • readTasks() converts saved text back into task objects.
  • writeTasks() converts task objects into text for browser storage.
  • listTasks() exposes the current task list to the rest of the app.
  • Save src/storage.ts.
  • Open the Problems panel from the VS Code View menu.
  • Confirm that the panel reports no TypeScript errors for src/storage.ts.

Seeing a storage error?

Check that every brace in readTasks() has a matching closing brace. Confirm that Task[] uses square brackets immediately after the type name.

Help me fix errors in my storage module.

Reading tasks gives the board its current state. The next function creates a task before saving the updated list.

  • Place your cursor after the closing brace of listTasks().
  • Add the task creation function by pasting this code:
export function addTask(title: string): Task {
  const cleanTitle = title.trim();
  if (!cleanTitle) throw new Error("A task title is required.");

  const task: Task = {
    id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
    title: cleanTitle,
    done: false,
    createdAt: new Date().toISOString()
  };

  const tasks = readTasks();
  tasks.push(task);
  writeTasks(tasks);
  return task;
}

What does addTask do?

  • cleanTitle removes extra spaces from the submitted title.
  • The empty-title check prevents blank task cards.
  • The task object records a unique ID plus its creation time.
  • tasks.push(task) adds the new task to the current list.
  • writeTasks(tasks) saves the updated list.
  • Save src/storage.ts.
  • Confirm that the Problems panel still reports no errors.

Is addTask showing an error?

Check that addTask() sits below listTasks(). Verify that the template literal around the task ID uses backticks.

Help me debug my addTask function.

Build and test the task board

The page needs a form for new titles. It also needs a list where saved task cards can appear.

  • Select index.html in the VS Code Explorer sidebar.
  • Select all existing content in index.html.
  • Replace the selection with this page structure:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="theme-color" content="#111827" />
    <title>Pocket Action Agent</title>
  </head>
  <body>
    <main class="app-shell">
      <header>
        <p class="eyebrow">Tool-using AI on your phone</p>
        <h1>Pocket Action Agent</h1>
        <p>Ask for a task change and watch the board update.</p>
      </header>
      <section class="panel" aria-labelledby="tasks-title">
        <div class="panel-heading">
          <h2 id="tasks-title">Tasks</h2>
          <span id="task-count" class="count">0</span>
        </div>
        <form id="task-form">
          <label for="task-input">Add a task</label>
          <div class="composer">
            <input id="task-input" required />
            <button type="submit">Add</button>
          </div>
        </form>
        <ul id="task-list" class="task-list"></ul>
      </section>
    </main>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

How is the board structured?

  • task-count displays the number of saved tasks.
  • task-form captures each submitted title.
  • task-input holds the title typed by the learner.
  • task-list becomes the container for rendered task cards.
  • Save index.html.
  • Return to Google Chrome.
  • Refresh http://localhost:5173.

You'll see a Tasks panel with a count of 0. The panel includes a task field plus an Add button.

Missing the task form?

Confirm that you saved index.html. Check that the task section sits inside <main class="app-shell">.

Help me find why my task form is missing.

The renderer turns stored task objects into visible list items. It also keeps the count synchronized with the stored list.

  • Select src/main.ts in the VS Code Explorer sidebar.
  • Select all existing content in src/main.ts.
  • Replace the selection with the imports plus the renderer below:
import "./style.css";
import { addTask, listTasks } from "./storage";

const form = document.querySelector<HTMLFormElement>("#task-form");
const input = document.querySelector<HTMLInputElement>("#task-input");
const taskList = document.querySelector<HTMLUListElement>("#task-list");
const taskCount = document.querySelector<HTMLSpanElement>("#task-count");

if (!form || !input || !taskList || !taskCount) {
  throw new Error("Required page elements are missing.");
}

function renderTasks(): void {
  const tasks = listTasks();
  taskCount.textContent = String(tasks.length);
  taskList.replaceChildren();

  for (const task of tasks) {
    const item = document.createElement("li");
    item.className = "task-card";
    item.textContent = task.title;
    taskList.append(item);
  }
}

What does the renderer do?

  • The typed selectors connect the script to the matching page elements.
  • The element check stops the script when required board elements are missing.
  • renderTasks() reads the latest task list from storage.
  • replaceChildren() clears the previous cards before rendering the current list.
  • Each loop creates one task-card list item.
  • Save src/main.ts.
  • Confirm that the browser remains loaded with the task count set to 0.

Seeing a missing elements error?

Compare the four selectors in src/main.ts with the IDs in index.html. Every selector must include the same spelling plus the leading #.

Help me match my task board selectors.

The form handler connects the input to addTask(). A successful submission saves the title before rendering the updated board.

  • Place your cursor after the closing brace of renderTasks().
  • Add the form handler plus the first render by pasting this code:
form.addEventListener("submit", (event) => {
  event.preventDefault();
  addTask(input.value);
  input.value = "";
  renderTasks();
});

renderTasks();

How does submission work?

  • preventDefault() keeps the form submission on the current page.
  • addTask(input.value) saves the typed title.
  • The empty string clears the input after a successful save.
  • renderTasks() refreshes the count plus the visible cards.
  • The final render displays tasks that were already stored before the page loaded.
  • Save src/main.ts.
  • Enter Review chapter three in the task field.
  • Click Add.

You'll see a card labelled Review chapter three. The task count changes to 1.

Does the Add button do nothing?

Check that the form handler sits below renderTasks(). Confirm that renderTasks(); appears after the event listener.

Help me debug my task form submission.

The board works now. Styling makes each state easier to scan on a narrow screen.

  • Select the existing .app-shell block in src/style.css.
  • Replace that block with the typography rule plus the original shell rule below:
button,
input {
  font: inherit;
}

.app-shell {
  width: min(100% - 2rem, 680px);
  margin: 0 auto;
  padding: 2rem 0 4rem;
}

Why inherit the font?

Form controls can use browser-specific fonts. The shared rule makes the input plus button match the rest of the page.

  • Save src/style.css.
  • Refresh the browser.

The task input plus button now use the same typeface as the page.

  • Select the existing .eyebrow block in src/style.css.
  • Replace that block with these heading plus status rules:
h2 {
  margin-bottom: 1rem;
  font-size: 1.1rem;
}

.eyebrow,
.status {
  color: #86efac;
}

What changes here?

The h2 rule gives the Tasks heading a compact size. The shared color rule preserves the green eyebrow style.

  • Save src/style.css.
  • Refresh the browser.

The Tasks heading now has tighter spacing plus a smaller size.

The panel and composer rules group the board into a clear card. They also keep the input beside its action button on wider screens.

  • Place your cursor after the .status rule.
  • Add the panel plus composer styles by pasting this code:
.panel {
  margin-top: 1rem;
  padding: 1rem;
  border: 1px solid #374151;
  border-radius: 1rem;
  background: rgb(17 24 39 / 88%);
  box-shadow: 0 18px 50px rgb(0 0 0 / 25%);
}

.composer {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.75rem;
  margin-top: 0.5rem;
}

How does the layout change?

The panel gets a rounded border plus a dark background. The composer uses a grid so the input takes the available width.

  • Save src/style.css.
  • Refresh the browser.

You'll see the task board inside a rounded dark panel. The field plus button now share one row.

Is the panel still unstyled?

Confirm that both selectors begin with a full stop. Check that the new rules sit outside the closing brace of .status.

Help me fix my panel styles.

The controls need stronger contrast against the panel. These rules give the field plus button their final colors.

  • Place your cursor after the closing brace of .composer.
  • Add the input plus button styles by pasting this code:
input {
  width: 100%;
  padding: 0.85rem 1rem;
  color: #f9fafb;
  border: 1px solid #4b5563;
  border-radius: 0.75rem;
  background: #111827;
}

button {
  padding: 0.85rem 1rem;
  color: #052e16;
  font-weight: 700;
  border: 0;
  border-radius: 0.75rem;
  background: #86efac;
  cursor: pointer;
}

Why style the controls separately?

The input uses a dark surface that matches the panel. The button uses the page's green accent so the primary action stands out.

  • Save src/style.css.
  • Refresh the browser.

You'll see a padded dark input beside a green Add button.

The final styles align the count badge. They also turn each saved title into a distinct task card.

  • Place your cursor after the closing brace of the button rule.
  • Add the board list styles by pasting this code:
.panel-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.count {
  display: grid;
  width: 2rem;
  height: 2rem;
  place-items: center;
  border-radius: 999px;
  background: #1f2937;
}

.task-list {
  display: grid;
  gap: 0.75rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.task-card {
  padding: 0.9rem 1rem;
  border-left: 4px solid #22c55e;
  border-radius: 0.75rem;
  background: #1f2937;
}

How do the task styles help?

  • panel-heading places the heading opposite the count.
  • count creates a circular badge.
  • task-list removes the browser's default list formatting.
  • task-card gives each task its own surface plus a green edge.
  • Save src/style.css.
  • Refresh the browser.

You'll see Review chapter three inside a rounded task card. The count appears inside a circular badge.

Does the task still look like plain text?

Confirm that the renderer assigns task-card to each list item. Check that the CSS selector uses the same spelling.

Help me connect my task card styles.

✔️ Awesome, I've got everything!

Your board can create a task plus display the saved result. Save every changed file before the persistence check.

ⓧ I'd like to double check the full code

Compare the four files changed in this step with the complete versions below.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="theme-color" content="#111827" />
    <title>Pocket Action Agent</title>
  </head>
  <body>
    <main class="app-shell">
      <header>
        <p class="eyebrow">Tool-using AI on your phone</p>
        <h1>Pocket Action Agent</h1>
        <p>Ask for a task change and watch the board update.</p>
      </header>
      <section class="panel" aria-labelledby="tasks-title">
        <div class="panel-heading">
          <h2 id="tasks-title">Tasks</h2>
          <span id="task-count" class="count">0</span>
        </div>
        <form id="task-form">
          <label for="task-input">Add a task</label>
          <div class="composer">
            <input id="task-input" required />
            <button type="submit">Add</button>
          </div>
        </form>
        <ul id="task-list" class="task-list"></ul>
      </section>
    </main>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

What should match?

This page contains the task count plus the manual task form. Its IDs match the selectors used by src/main.ts.

export type Task = {
  id: string;
  title: string;
  done: boolean;
  createdAt: string;
};

const STORAGE_KEY = "pocket-action-agent.tasks";

function readTasks(): Task[] {
  const saved = localStorage.getItem(STORAGE_KEY);
  if (!saved) return [];

  try {
    return JSON.parse(saved) as Task[];
  } catch {
    return [];
  }
}

function writeTasks(tasks: Task[]): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}

export function listTasks(): Task[] {
  return readTasks();
}

export function addTask(title: string): Task {
  const cleanTitle = title.trim();
  if (!cleanTitle) throw new Error("A task title is required.");

  const task: Task = {
    id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
    title: cleanTitle,
    done: false,
    createdAt: new Date().toISOString()
  };

  const tasks = readTasks();
  tasks.push(task);
  writeTasks(tasks);
  return task;
}

What should match?

This module defines the task shape plus the storage functions. It exports the two functions used by the board.

import "./style.css";
import { addTask, listTasks } from "./storage";

const form = document.querySelector<HTMLFormElement>("#task-form");
const input = document.querySelector<HTMLInputElement>("#task-input");
const taskList = document.querySelector<HTMLUListElement>("#task-list");
const taskCount = document.querySelector<HTMLSpanElement>("#task-count");

if (!form || !input || !taskList || !taskCount) {
  throw new Error("Required page elements are missing.");
}

function renderTasks(): void {
  const tasks = listTasks();
  taskCount.textContent = String(tasks.length);
  taskList.replaceChildren();

  for (const task of tasks) {
    const item = document.createElement("li");
    item.className = "task-card";
    item.textContent = task.title;
    taskList.append(item);
  }
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  addTask(input.value);
  input.value = "";
  renderTasks();
});

renderTasks();

What should match?

This script renders stored tasks plus handles new submissions. The final renderTasks() call restores saved cards when the page loads.

:root {
  color: #e5e7eb;
  background: #030712;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top, #1f2937 0, transparent 40%),
    #030712;
}

button,
input {
  font: inherit;
}

.app-shell {
  width: min(100% - 2rem, 680px);
  margin: 0 auto;
  padding: 2rem 0 4rem;
}

header {
  margin-bottom: 1.5rem;
}

h1,
h2,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 0.5rem;
  font-size: clamp(2rem, 8vw, 3.5rem);
  line-height: 1;
}

h2 {
  margin-bottom: 1rem;
  font-size: 1.1rem;
}

.eyebrow,
.status {
  color: #86efac;
}

.panel {
  margin-top: 1rem;
  padding: 1rem;
  border: 1px solid #374151;
  border-radius: 1rem;
  background: rgb(17 24 39 / 88%);
  box-shadow: 0 18px 50px rgb(0 0 0 / 25%);
}

.composer {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.75rem;
  margin-top: 0.5rem;
}

input {
  width: 100%;
  padding: 0.85rem 1rem;
  color: #f9fafb;
  border: 1px solid #4b5563;
  border-radius: 0.75rem;
  background: #111827;
}

button {
  padding: 0.85rem 1rem;
  color: #052e16;
  font-weight: 700;
  border: 0;
  border-radius: 0.75rem;
  background: #86efac;
  cursor: pointer;
}

.panel-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.count {
  display: grid;
  width: 2rem;
  height: 2rem;
  place-items: center;
  border-radius: 999px;
  background: #1f2937;
}

.task-list {
  display: grid;
  gap: 0.75rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.task-card {
  padding: 0.9rem 1rem;
  border-left: 4px solid #22c55e;
  border-radius: 0.75rem;
  background: #1f2937;
}

What should match?

This stylesheet contains the starter shell rules plus every board style added in this step.

Before you reload the page, do you expect the saved task card to disappear or remain?

  • Reload http://localhost:5173 in Chrome.

You'll still see Review chapter three on the board. The count remains 1 because the page restored the task from browser storage.

That's your first working product: the task board now changes state plus remembers it. Next, you'll connect a secured model so natural-language requests can reach the app.

Connect a Secure AI Chatbot

Your local task board now stores real tasks across browser reloads. The next goal is to connect that board to a secured AI chat experience.

A model can talk about adding tasks without changing anything. This step deliberately exposes that gap by connecting Firebase AI Logic and Gemini before the model receives any task tools.

In this step, get ready to:
  • Create a Spark-plan Firebase project with Hosting and Firebase AI Logic.
  • Protect model requests with Firebase App Check and reCAPTCHA Enterprise.
  • Connect the chat form and observe the chatbot limitation.
Create and connect your Firebase project

The browser needs a managed route to the model. Firebase AI Logic provides that route without requiring you to build a separate proxy.

This creates cloud resources on the Spark plan. No payment information is required for the no-cost path used in this project.

Why Firebase AI Logic?

Firebase AI Logic is designed for direct web clients. It routes requests through a Firebase-managed service and integrates with Firebase App Check.

That keeps this project focused on the chatbot behavior while Firebase handles the connection path.

  • Sign in to Firebase Console with your Google account.
  • Start a new project from the project selector.
  • Enter a unique project name based on pocket-action-agent.
  • Keep the project on the Spark plan.
  • Complete the project creation process.

The Firebase project is the cloud home for the model connection and the Hosting site.

  • Switch back to the second terminal in Visual Studio Code.
  • Sign the Firebase CLI into your Google account by running this command:
firebase login

What does this command do?

The command opens a browser-based authentication flow. Approving that flow lets the Firebase CLI access the projects attached to your Google account.

The browser returns you to the terminal after authentication succeeds.

  • Confirm that the new project is available to the CLI by running:
firebase projects:list

What should you see?

The command lists the Firebase projects available to your signed-in account. Find the project you just created in that output.

Cannot see your project?

Check that the Google account in your browser matches the account used to create the project.

If the accounts differ, repeat the login flow with the correct account.

Ask for help with the Firebase CLI login

Prepare for the Hosting wizard

The Hosting initializer asks you to select a Firebase project and choose a public directory. Select the existing project you just created.

Use dist as the public directory. Enable the single-page app configuration and preserve the existing project files if the wizard offers to replace them.

  • Initialize Firebase Hosting from the linked pocket-action-agent folder by running:
firebase init hosting

What does this command create?

The initializer links your local folder to the selected Firebase project. It also creates the Hosting configuration used during deployment.

Setting the public directory to dist matches the folder produced by the Vite build.

Your local project is now linked to Firebase Hosting. The remaining console setup gives the Web app access to the model.

  • Return to your new project in Firebase Console.
  • Select AI Services from the console navigation.
  • Select AI Logic.
  • Select Get started.
  • Choose Gemini Developer API as the model provider.
  • Register a Web app for the project.
  • Keep the generated Web app configuration available for the code setup.

That is the cloud connection established. Your Web app can now initialize Firebase AI Logic through its project configuration.

Protect the model connection with App Check

A public web page can be copied or called by automated clients. Firebase App Check adds an attestation check so Firebase AI Logic can reject requests that do not come through your registered app.

How App Check protects the app

App Check uses reCAPTCHA Enterprise to assess requests from the deployed Web app. Firebase AI Logic checks the resulting token before accepting a model request.

Local development uses a registered debug token because localhost is intentionally excluded from the production key.

  • Start the reCAPTCHA Enterprise key setup from the Web app registration flow.
  • Choose Fraud Defense for the key configuration.
  • Create a Web score-based key.
  • Leave Use checkbox challenge unselected.
  • Add the generated web.app Hosting domain to the production key.
  • Keep localhost out of the production key.

The production key now recognizes the Firebase Hosting domain without treating local development as a production origin.

  • Return to Firebase Console.
  • Select Security from the console navigation.
  • Select App Check.
  • Select the Apps tab.
  • Register the Firebase Web app with the reCAPTCHA Enterprise site key.
  • Confirm that enforcement is enabled for Firebase AI Logic.

The console-side protection is ready. The next file initializes the same provider inside your Web app.

  • Create src/firebase.ts from the Visual Studio Code Explorer.
  • Add the Firebase imports and Web app initialization with this code:
import { initializeApp } from "firebase/app";
import {
  initializeAppCheck,
  ReCaptchaEnterpriseProvider
} from "firebase/app-check";
import { getAI, GoogleAIBackend } from "firebase/ai";

const firebaseConfig = {
  apiKey: "your-firebase-web-api-key",
  authDomain: "your-project-id.firebaseapp.com",
  projectId: "your-project-id",
  storageBucket: "your-project-id.firebasestorage.app",
  messagingSenderId: "your-messaging-sender-id",
  appId: "your-firebase-app-id"
};

const app = initializeApp(firebaseConfig);

What does this code initialize?

The imports bring Firebase app setup and AI services into one module. The firebaseConfig object identifies your registered Web app.

The app instance becomes the shared foundation for App Check and Firebase AI Logic.

  • Replace the six placeholder strings in firebaseConfig with the matching values from your Web app configuration.
  • Add the App Check debug guard and AI initialization below app with this code:
if (import.meta.env.DEV) {
  const debugSelf = self as typeof self & {
    FIREBASE_APPCHECK_DEBUG_TOKEN?: boolean;
  };
  debugSelf.FIREBASE_APPCHECK_DEBUG_TOKEN = true;
}

initializeAppCheck(app, {
  provider: new ReCaptchaEnterpriseProvider(
    "your-recaptcha-enterprise-site-key"
  ),
  isTokenAutoRefreshEnabled: true
});

export const ai = getAI(app, { backend: new GoogleAIBackend() });

How is this connection secured?

The development guard asks App Check to print a local debug token only while Vite is in development mode. Production builds remove code guarded by import.meta.env.DEV.

The provider uses your reCAPTCHA Enterprise site key. The exported ai client selects the Gemini Developer API backend.

  • Replace your-recaptcha-enterprise-site-key with the site key registered for the Web app.
  • Save src/firebase.ts.
  • Check the Firebase module with a production build by running:
npm run build

What does the build check prove?

The build runs the TypeScript type checker before Vite creates the production files. A completed build confirms that the Firebase imports and App Check setup compile together.

You should see the Vite build complete and create the dist output folder.

Build failing in firebase.ts?

Check that every import matches the code shown above. Confirm that both closing braces remain in the import.meta.env.DEV block.

Check that your Web app values remain inside double quotes.

Ask for help with the Firebase module

Connect the chat and expose its limitation

The model connection needs one small module that owns the chat session. This first version sends text to Gemini and returns the response without offering any task-board operations.

  • Create src/agent.ts from the Visual Studio Code Explorer.
  • Add the chat model and runAgent() function with this code:
import { getGenerativeModel } from "firebase/ai";
import { ai } from "./firebase";

const model = getGenerativeModel(ai, {
  model: "gemini-3.8-flash"
});

const chat = model.startChat();

export async function runAgent(prompt: string): Promise<string> {
  const result = await chat.sendMessage(prompt);
  return result.response.text();
}

What does the chat module do?

The model uses gemini-3.8-flash through the secured ai client. The shared chat session preserves the conversation between requests.

The runAgent() function sends the prompt and returns only the generated response text.

  • Save src/agent.ts.
  • Confirm the new module compiles by running:
npm run build

What should the build confirm?

A successful build confirms that the model identifier and chat API compile with the installed Firebase JavaScript SDK.

The UI still needs a chat panel before you can send the first secured request.

  • In index.html find this task panel:
<section class="panel" aria-labelledby="tasks-title">
  <div class="panel-heading">
    <h2 id="tasks-title">Tasks</h2>
    <span id="task-count" class="count">0</span>
  </div>
  <form id="task-form">
    <label for="task-input">Add a task</label>
    <div class="composer">
      <input id="task-input" required />
      <button type="submit">Add</button>
    </div>
  </form>
  <ul id="task-list" class="task-list"></ul>
</section>

What are you replacing?

This is the manual task-entry panel from the previous step. The replacement moves task requests into an agent form while preserving the live task list.

  • Replace that panel with the agent panel and task list shown below:
<section class="panel" aria-labelledby="agent-title">
  <h2 id="agent-title">Agent</h2>
  <form id="agent-form">
    <label for="agent-input">What should I do?</label>
    <div class="composer">
      <input id="agent-input" name="prompt" autocomplete="off" required />
      <button type="submit">Run</button>
    </div>
  </form>
  <p id="agent-status" class="status" aria-live="polite">Ready for a task request.</p>
</section>
<section class="panel" aria-labelledby="tasks-title">
  <div class="panel-heading">
    <h2 id="tasks-title">Tasks</h2>
    <span id="task-count" class="count">0</span>
  </div>
  <ul id="task-list" class="task-list"></ul>
</section>

How does the new panel work?

The agent form captures a natural-language request. The live status region displays Gemini's response after the request finishes.

The task list remains on the same screen so you can compare the model's words with the board's actual state.

  • Replace the existing contents of src/main.ts with this initial UI setup:
import "./style.css";
import { runAgent } from "./agent";
import { listTasks } from "./storage";

const form = document.querySelector<HTMLFormElement>("#agent-form");
const input = document.querySelector<HTMLInputElement>("#agent-input");
const status = document.querySelector<HTMLParagraphElement>("#agent-status");
const taskList = document.querySelector<HTMLUListElement>("#task-list");
const taskCount = document.querySelector<HTMLSpanElement>("#task-count");

if (!form || !input || !status || !taskList || !taskCount) {
  throw new Error("Required page elements are missing.");
}

function renderTasks(): void {
  const tasks = listTasks();
  taskCount.textContent = String(tasks.length);
  taskList.replaceChildren();

  for (const task of tasks) {
    const item = document.createElement("li");
    item.className = "task-card";
    item.textContent = task.title;
    taskList.append(item);
  }
}

What does the UI setup do?

The DOM references point to the new agent form and the existing task list. The guard stops execution if the HTML and TypeScript identifiers drift apart.

The existing renderTasks() logic still reads tasks from browser storage.

  • Save index.html.
  • Save src/main.ts.
  • Return to http://localhost:5173 to check the new page structure.

You should see an Agent panel above the task board. Its status reads Ready for a task request..

Do not see the Agent panel?

Confirm that the form uses agent-form and the input uses agent-input in both files.

Check the Vite terminal for a missing-element error after saving the files.

Ask for help matching the UI identifiers

The new status area needs a visible disabled state and consistent spacing before the request handler goes live.

  • In src/style.css find this button rule:
button {
  padding: 0.85rem 1rem;
  color: #052e16;
  font-weight: 700;
  border: 0;
  border-radius: 0.75rem;
  background: #86efac;
  cursor: pointer;
}

What will this style change?

The current rule styles only the active button. The replacement adds visual feedback for disabled controls and reserves space for the agent response.

  • Replace that rule with this expanded group:
button {
  padding: 0.85rem 1rem;
  color: #052e16;
  font-weight: 700;
  border: 0;
  border-radius: 0.75rem;
  background: #86efac;
  cursor: pointer;
}

button:disabled,
input:disabled {
  opacity: 0.6;
}

.status {
  min-height: 1.5rem;
  margin: 1rem 0 0;
}

What does the expanded group add?

Disabled controls become visibly muted. The status element keeps enough height for a response without collapsing the panel.

  • Find the existing .task-card rule near the bottom of src/style.css.
.task-card {
  padding: 0.9rem 1rem;
  border-left: 4px solid #22c55e;
  border-radius: 0.75rem;
  background: #1f2937;
}

What will the final style group cover?

The current selector covers populated task cards. The replacement also defines the empty state and the narrow-screen composer layout.

  • Replace that rule with these final task and mobile styles:
.task-card,
.empty-state {
  padding: 0.9rem 1rem;
  border-radius: 0.75rem;
}

.task-card {
  border-left: 4px solid #22c55e;
  background: #1f2937;
}

.empty-state {
  color: #9ca3af;
  border: 1px dashed #4b5563;
}

@media (max-width: 520px) {
  .composer {
    grid-template-columns: 1fr;
  }
}

How does the mobile layout respond?

The shared spacing keeps task states aligned. On screens up to 520 pixels wide, the input and button stack into one column.

  • Save src/style.css.
  • Refresh http://localhost:5173 to view the styled Agent panel.

You should see the green status text beneath the agent form. Narrowing the browser stacks the input above the Run button.

  • Add the request handler below renderTasks() in src/main.ts with this code:
form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const prompt = input.value.trim();
  if (!prompt) return;

  status.textContent = await runAgent(prompt);
  input.value = "";
  renderTasks();
});

renderTasks();

What happens when you select Run?

The handler sends the trimmed prompt to runAgent() and writes Gemini's reply into the live status area.

It rerenders tasks after every reply. The current agent has no task operations to call, so the renderer reads the same stored data.

  • Save src/main.ts.
  • Refresh http://localhost:5173 to initialize App Check.
  • Open Chrome DevTools for the local page.
  • Select the Console panel.
  • Copy the App Check debug token printed in the console.

The debug token grants local development access. Treat it like a credential and keep it out of screenshots or project files.

  • Return to Firebase Console.
  • Select Security from the console navigation.
  • Select App Check.
  • Select the Apps tab.
  • Open Manage debug tokens for the Web app.
  • Register the copied debug token.
  • Close Chrome DevTools to remove the token from view.
  • Refresh the local app to use the registered token.

The local app can now make an attested request while production enforcement remains active.

Before you send the first request, predict whether Gemini can change the board with only a chat connection.

  • Enter Add revise chapter three to my tasks in the agent input.
  • Select Run.

You should see Gemini reply in the green status area. The task count and task cards remain exactly as they were before the request.

The unchanged board is intentional

Gemini received your words and produced a response. It had no operation that could call addTask() or inspect listTasks().

You have now seen the boundary between a chatbot response and an action performed against real application state.

No Gemini reply?

Confirm that the App Check debug token from the current browser was registered for the correct Firebase Web app.

Check that src/firebase.ts contains your own Web app values and reCAPTCHA Enterprise site key.

Ask for help with the secured model request

✔️ Awesome, I've got everything!

Great. Your secured chatbot now replies while the persistent task board remains unchanged.

ⓧ I'd like to double check the full code

Compare these files with your project. Keep your own Firebase Web app values and reCAPTCHA Enterprise site key in src/firebase.ts.

Your .gitignore should contain:

node_modules/
dist/
.firebase/
firebase-debug.log*

Why keep these entries?

These entries keep installed packages, generated builds, Firebase local metadata, and debug logs out of source control.

Your index.html should contain:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="theme-color" content="#111827" />
    <title>Pocket Action Agent</title>
  </head>
  <body>
    <main class="app-shell">
      <header>
        <p class="eyebrow">Tool-using AI on your phone</p>
        <h1>Pocket Action Agent</h1>
        <p>Ask for a task change and watch the board update.</p>
      </header>
      <section class="panel" aria-labelledby="agent-title">
        <h2 id="agent-title">Agent</h2>
        <form id="agent-form">
          <label for="agent-input">What should I do?</label>
          <div class="composer">
            <input id="agent-input" name="prompt" autocomplete="off" required />
            <button type="submit">Run</button>
          </div>
        </form>
        <p id="agent-status" class="status" aria-live="polite">Ready for a task request.</p>
      </section>
      <section class="panel" aria-labelledby="tasks-title">
        <div class="panel-heading">
          <h2 id="tasks-title">Tasks</h2>
          <span id="task-count" class="count">0</span>
        </div>
        <ul id="task-list" class="task-list"></ul>
      </section>
    </main>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

What does the page contain?

The page contains the agent form, live response status, and persistent task list in one mobile-first shell.

Your package.json should contain:

{
  "name": "pocket-action-agent",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "firebase": "13.1.0"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "vite": "8.3.5"
  }
}

Why are these packages required?

Firebase supplies the AI Logic and App Check APIs. TypeScript validates the source before Vite builds or serves the app.

Your src/agent.ts should contain:

import { getGenerativeModel } from "firebase/ai";
import { ai } from "./firebase";

const model = getGenerativeModel(ai, {
  model: "gemini-3.8-flash"
});

const chat = model.startChat();

export async function runAgent(prompt: string): Promise<string> {
  const result = await chat.sendMessage(prompt);
  return result.response.text();
}

What does the agent module provide?

This module creates the Gemini chat session and exposes the response text through runAgent().

Your src/firebase.ts should follow this structure:

import { initializeApp } from "firebase/app";
import {
  initializeAppCheck,
  ReCaptchaEnterpriseProvider
} from "firebase/app-check";
import { getAI, GoogleAIBackend } from "firebase/ai";

const firebaseConfig = {
  apiKey: "your-firebase-web-api-key",
  authDomain: "your-project-id.firebaseapp.com",
  projectId: "your-project-id",
  storageBucket: "your-project-id.firebasestorage.app",
  messagingSenderId: "your-messaging-sender-id",
  appId: "your-firebase-app-id"
};

const app = initializeApp(firebaseConfig);

if (import.meta.env.DEV) {
  const debugSelf = self as typeof self & {
    FIREBASE_APPCHECK_DEBUG_TOKEN?: boolean;
  };
  debugSelf.FIREBASE_APPCHECK_DEBUG_TOKEN = true;
}

initializeAppCheck(app, {
  provider: new ReCaptchaEnterpriseProvider(
    "your-recaptcha-enterprise-site-key"
  ),
  isTokenAutoRefreshEnabled: true
});

export const ai = getAI(app, { backend: new GoogleAIBackend() });

What does the Firebase module provide?

This module initializes the registered Web app and App Check provider. It exports the configured Firebase AI Logic client.

Your src/main.ts should contain:

import "./style.css";
import { runAgent } from "./agent";
import { listTasks } from "./storage";

const form = document.querySelector<HTMLFormElement>("#agent-form");
const input = document.querySelector<HTMLInputElement>("#agent-input");
const status = document.querySelector<HTMLParagraphElement>("#agent-status");
const taskList = document.querySelector<HTMLUListElement>("#task-list");
const taskCount = document.querySelector<HTMLSpanElement>("#task-count");

if (!form || !input || !status || !taskList || !taskCount) {
  throw new Error("Required page elements are missing.");
}

function renderTasks(): void {
  const tasks = listTasks();
  taskCount.textContent = String(tasks.length);
  taskList.replaceChildren();

  for (const task of tasks) {
    const item = document.createElement("li");
    item.className = "task-card";
    item.textContent = task.title;
    taskList.append(item);
  }
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const prompt = input.value.trim();
  if (!prompt) return;

  status.textContent = await runAgent(prompt);
  input.value = "";
  renderTasks();
});

renderTasks();

What does the main module coordinate?

This module sends form prompts to the chat session. It also renders the tasks already stored in the browser.

Your unchanged src/storage.ts should contain:

export type Task = {
  id: string;
  title: string;
  done: boolean;
  createdAt: string;
};

const STORAGE_KEY = "pocket-action-agent.tasks";

function readTasks(): Task[] {
  const saved = localStorage.getItem(STORAGE_KEY);
  if (!saved) return [];

  try {
    return JSON.parse(saved) as Task[];
  } catch {
    return [];
  }
}

function writeTasks(tasks: Task[]): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}

export function listTasks(): Task[] {
  return readTasks();
}

export function addTask(title: string): Task {
  const cleanTitle = title.trim();
  if (!cleanTitle) throw new Error("A task title is required.");

  const task: Task = {
    id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
    title: cleanTitle,
    done: false,
    createdAt: new Date().toISOString()
  };

  const tasks = readTasks();
  tasks.push(task);
  writeTasks(tasks);
  return task;
}

Why does storage remain unchanged?

The browser still owns the task data through local storage. The chatbot has no path to these functions in this step.

Your src/style.css should contain:

:root {
  color: #e5e7eb;
  background: #030712;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top, #1f2937 0, transparent 40%),
    #030712;
}

button,
input {
  font: inherit;
}

.app-shell {
  width: min(100% - 2rem, 680px);
  margin: 0 auto;
  padding: 2rem 0 4rem;
}

header {
  margin-bottom: 1.5rem;
}

h1,
h2,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 0.5rem;
  font-size: clamp(2rem, 8vw, 3.5rem);
  line-height: 1;
}

h2 {
  margin-bottom: 1rem;
  font-size: 1.1rem;
}

.eyebrow,
.status {
  color: #86efac;
}

.panel {
  margin-top: 1rem;
  padding: 1rem;
  border: 1px solid #374151;
  border-radius: 1rem;
  background: rgb(17 24 39 / 88%);
  box-shadow: 0 18px 50px rgb(0 0 0 / 25%);
}

.composer {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.75rem;
  margin-top: 0.5rem;
}

input {
  width: 100%;
  padding: 0.85rem 1rem;
  color: #f9fafb;
  border: 1px solid #4b5563;
  border-radius: 0.75rem;
  background: #111827;
}

button {
  padding: 0.85rem 1rem;
  color: #052e16;
  font-weight: 700;
  border: 0;
  border-radius: 0.75rem;
  background: #86efac;
  cursor: pointer;
}

button:disabled,
input:disabled {
  opacity: 0.6;
}

.status {
  min-height: 1.5rem;
  margin: 1rem 0 0;
}

.panel-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.count {
  display: grid;
  width: 2rem;
  height: 2rem;
  place-items: center;
  border-radius: 999px;
  background: #1f2937;
}

.task-list {
  display: grid;
  gap: 0.75rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.task-card,
.empty-state {
  padding: 0.9rem 1rem;
  border-radius: 0.75rem;
}

.task-card {
  border-left: 4px solid #22c55e;
  background: #1f2937;
}

.empty-state {
  color: #9ca3af;
  border: 1px dashed #4b5563;
}

@media (max-width: 520px) {
  .composer {
    grid-template-columns: 1fr;
  }
}

What does the stylesheet control?

The stylesheet creates the mobile-first panels, form controls, status area, task cards, and narrow-screen layout.

Your tsconfig.json should contain:

{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,
    "moduleResolution": "Bundler",
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "strict": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}

Why keep this TypeScript configuration?

These options provide browser types and strict checking while leaving production output to Vite.

Your chatbot is secured and responding through Firebase AI Logic. Next, you will give it real tools so its decisions can change the task board.

Give the Agent Tools

Your secured Firebase AI Logic chatbot can interpret a request. The previous step proved that its response still leaves the task board unchanged.

This step adds function calling so Gemini can request real task operations. A typed loop executes each request and returns the result to Gemini before the final response reaches the screen.

In this step, get ready to:
  • Describe the available task operations with typed function declarations.
  • Execute every model-selected function through a multi-turn response loop.
  • Rerender the board and verify a request that uses multiple tools.
Declare the agent's tools

A function declaration gives Gemini the name and purpose of an operation. Its parameter schema defines the arguments Gemini can supply.

Declarations are contracts

The model reads each declaration when deciding how to respond. Your browser remains responsible for executing the matching local function.

This separation keeps Gemini focused on selecting operations. Your TypeScript code controls what those operations can change.

  • In src/agent.ts, replace the existing Firebase AI import with the expanded import below:
import {
  getGenerativeModel,
  Schema,
  type FunctionDeclarationsTool,
  type FunctionResponsePart
} from "firebase/ai";
import { ai } from "./firebase";
import { addTask, listTasks } from "./storage";

What do these imports provide?

  • The Schema import describes the arguments accepted by each tool.
  • The FunctionDeclarationsTool type checks the collection of declarations.
  • The FunctionResponsePart type checks the results sent back to Gemini.
  • The addTask and listTasks imports connect the agent to the local task board.
  • Save src/agent.ts.
  • Return to the browser at http://localhost:5173.
  • Refresh the page to confirm the Pocket Action Agent shell still loads.

Does the page show an import error?

Check that every imported name uses the same capitalization shown above. Confirm that the storage path starts with ./storage.

Make sure the closing brace is followed by from "firebase/ai".

Ask for help with the agent imports.

  • Add the tool declarations above the existing model constant by copying this code:
const taskTools: FunctionDeclarationsTool = {
  functionDeclarations: [
    {
      name: "addTask",
      description: "Add one task to the user's local task board.",
      parameters: Schema.object({
        properties: {
          title: Schema.string({
            description: "The concise task title to add."
          })
        }
      })
    },
    {
      name: "listTasks",
      description: "Read every task currently on the user's local task board."
    }
  ]
};

What does this code do?

  • The addTask declaration tells Gemini that one concise title is required.
  • The listTasks declaration needs no parameters because it reads the complete local list.
  • The declarations describe capabilities. They do not run addTask() or listTasks().
  • Save src/agent.ts.
  • Switch back to the running Vite terminal.
  • Confirm the latest update finishes without a TypeScript compilation error.

Does the declaration fail to compile?

Check that parameters belongs only to the addTask declaration. Confirm that every opening brace has a matching closing brace.

Ask for help with the tool schema.

  • Replace the existing model constant with this configured version:
const model = getGenerativeModel(ai, {
  model: "gemini-3.8-flash",
  systemInstruction:
    "You are Pocket Action Agent. Use addTask whenever the user asks to create a task. Use listTasks whenever the user asks what is on the board or when the current list is needed. After tools run, briefly report what changed.",
  tools: [taskTools]
});

How does the model use the tools?

The tools property makes both declarations available during the conversation. The system instruction tells Gemini when each operation applies.

Gemini can now return a structured function request. The next substep gives that request a controlled execution path.

  • Save src/agent.ts.
  • Refresh the local app.
  • Confirm the Agent panel and Tasks panel still render.

Does the model configuration fail?

Confirm that taskTools is declared above model. Check that the model identifier remains gemini-3.8-flash.

Ask for help with the model configuration.

Execute the requested tools

A model-selected function call contains a function name and its arguments. The dispatcher maps that request to the local operation you permit.

  • Add executeTool() between chat and runAgent() by copying this code:
async function executeTool(
  name: string,
  args: Record<string, unknown>
): Promise<object> {
  if (name === "addTask") {
    const title = typeof args.title === "string" ? args.title : "";
    try {
      return { task: addTask(title) };
    } catch (error) {
      return {
        error: error instanceof Error ? error.message : "Task creation failed."
      };
    }
  }

  if (name === "listTasks") {
    return { tasks: listTasks() };
  }

  return { error: `Unknown tool: ${name}` };
}

What does the dispatcher control?

  • The name checks limit execution to the two declared task operations.
  • The title check converts an unexpected argument into an empty string that addTask() can reject safely.
  • Every path returns an object because a function response must contain structured result data.
  • The unknown-tool result prevents an unrecognized name from calling arbitrary browser code.
  • Save src/agent.ts.
  • Return to the local app.
  • Refresh the page to confirm both panels still load.

Does executeTool fail to compile?

Check that executeTool() sits outside the model configuration. Confirm that its closing brace appears before runAgent().

Ask for help with the dispatcher.

Why use a manual response loop?

The explicit loop exposes each part of the agent cycle. Gemini requests an operation. Your app executes it. Gemini receives the result before composing its next response.

This approach gives your code a clear place to validate arguments and limit repeated tool rounds. Those controls matter whenever a model can change application state.

  • Replace the existing runAgent() function with this multi-turn loop:
export async function runAgent(prompt: string): Promise<string> {
  let result = await chat.sendMessage(prompt);

  for (let round = 0; round < 4; round += 1) {
    const calls = result.response.functionCalls() ?? [];
    if (calls.length === 0) return result.response.text();

    const responses: FunctionResponsePart[] = await Promise.all(
      calls.map(async (call) => ({
        type: "functionResponse" as const,
        functionResponse: {
          name: call.name,
          response: await executeTool(
            call.name,
            call.args as Record<string, unknown>
          )
        }
      }))
    );

    result = await chat.sendMessage(responses);
  }

  return "I stopped after four tool rounds to prevent an accidental loop.";
}

How does the loop work?

  • The first sendMessage() passes the learner's request to Gemini.
  • The functionCalls() reader collects every operation requested in the current response.
  • The typed responses array records each function name with the object returned by executeTool().
  • The next sendMessage() gives those results back to Gemini so it can continue or summarize.
  • The four-round limit stops an accidental sequence from continuing indefinitely.
  • Save src/agent.ts.

Before you test the loop, do you think reading the board requires a visible change to its cards?

  • Enter List my tasks in the agent input.
  • Click Run.

You'll see Gemini summarize the current task list. The cards remain unchanged because listTasks only reads local state.

Does the agent return no summary?

Check the browser console for a compilation or request failure. Confirm that the loop sends responses back through the existing chat session.

Ask for help with the function-response loop.

Rerender the board and test multiple tools

The agent loop can now change local storage. The interface needs clear empty-state feedback and request-state feedback so each tool result is visible.

  • In src/main.ts, add this empty-state check after taskList.replaceChildren():
  if (tasks.length === 0) {
    const empty = document.createElement("li");
    empty.className = "empty-state";
    empty.textContent = "No tasks yet. Ask the agent to add one.";
    taskList.append(empty);
    return;
  }

What does the empty state show?

The renderer creates one explanatory list item when storage contains no tasks. The early return prevents the task-card loop from doing extra work.

  • Save src/main.ts.
  • Confirm the running development terminal processes the saved change without a compilation error.

Does the task list disappear?

Confirm that the new block appears before the existing for loop. Check that the early return remains inside the empty-state condition.

Ask for help with the renderer.

  • Replace the existing form submission listener in src/main.ts with this version:
form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const prompt = input.value.trim();
  if (!prompt) return;

  input.disabled = true;
  status.textContent = "Agent is deciding which tools to use...";

  try {
    status.textContent = await runAgent(prompt);
    input.value = "";
    renderTasks();
  } catch (error) {
    status.textContent =
      error instanceof Error ? error.message : "The agent request failed.";
  } finally {
    input.disabled = false;
    input.focus();
  }
});

What changes during a request?

  • The input becomes disabled while the model and tool loop are running.
  • The status text tells you that the agent is selecting operations.
  • The try block rerenders the task board after a successful response.
  • The catch block keeps request failures visible inside the app.
  • The finally block restores the input for the next request.
  • Save src/main.ts.
  • Refresh the local app.
  • Confirm the agent input becomes available for a new request.

Does the input stay disabled?

Check that the finally block sits after the catch block. Confirm that input.disabled = false remains inside it.

Ask for help with the submission listener.

  • In index.html, replace the existing head section with this version:
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="theme-color" content="#111827" />
    <link rel="manifest" href="/manifest.webmanifest" />
    <link rel="icon" href="/icons/icon-192.svg" />
    <title>Pocket Action Agent</title>
  </head>

What does the head identify?

The metadata keeps the existing mobile viewport and theme colour. The two link elements identify the app manifest and icon used by the browser.

  • Replace the existing Agent section in index.html with this version:
      <section class="panel" aria-labelledby="agent-title">
        <h2 id="agent-title">Agent</h2>
        <form id="agent-form">
          <label for="agent-input">What should I do?</label>
          <div class="composer">
            <input
              id="agent-input"
              name="prompt"
              autocomplete="off"
              placeholder="Add review chapter three, then list my tasks"
              required
            />
            <button type="submit">Run</button>
          </div>
        </form>
        <p id="agent-status" class="status" aria-live="polite">
          Ready for a task request.
        </p>
      </section>

What does the prompt cue add?

The placeholder demonstrates that one request can combine a task change with a task-list read. The existing element IDs keep the TypeScript event listener connected.

  • Save index.html.
  • Refresh the local page.
  • Confirm the agent input shows the multi-action placeholder.

Is the input missing its placeholder?

Confirm that the placeholder attribute belongs to the input with the agent-input ID. Check that the opening input tag closes with />.

Ask for help with the Agent section.

Before you submit the final request, how many tool calls do you think Gemini needs to add two tasks and read the resulting list?

  • Enter Add review chapter three, add practice flashcards, then list my tasks in the agent input.
  • Click Run.

You'll see cards for review chapter three and practice flashcards. The agent response summarizes the resulting task list.

That's the key transformation complete. Your model can now select local tools and report the state produced by those tools.

Are the cards missing?

Check whether the status area contains a request error. Confirm that runAgent() calls executeTool() for every entry returned by functionCalls().

Confirm that renderTasks() runs after the agent response. Reloading the page should preserve any task that reached local storage.

Ask for help tracing the multi-tool request.

✔️ Awesome, I've got everything!

Great work. Save every changed file before moving on.

Your agent can now add tasks, read the current board, and summarize the tool results in one conversation.

ⓧ I'd like to double check the full code

Compare your complete src/agent.ts file with this reference:

import {
  getGenerativeModel,
  Schema,
  type FunctionDeclarationsTool,
  type FunctionResponsePart
} from "firebase/ai";
import { ai } from "./firebase";
import { addTask, listTasks } from "./storage";

const taskTools: FunctionDeclarationsTool = {
  functionDeclarations: [
    {
      name: "addTask",
      description: "Add one task to the user's local task board.",
      parameters: Schema.object({
        properties: {
          title: Schema.string({
            description: "The concise task title to add."
          })
        }
      })
    },
    {
      name: "listTasks",
      description: "Read every task currently on the user's local task board."
    }
  ]
};

const model = getGenerativeModel(ai, {
  model: "gemini-3.8-flash",
  systemInstruction:
    "You are Pocket Action Agent. Use addTask whenever the user asks to create a task. Use listTasks whenever the user asks what is on the board or when the current list is needed. After tools run, briefly report what changed.",
  tools: [taskTools]
});

const chat = model.startChat();

async function executeTool(
  name: string,
  args: Record<string, unknown>
): Promise<object> {
  if (name === "addTask") {
    const title = typeof args.title === "string" ? args.title : "";
    try {
      return { task: addTask(title) };
    } catch (error) {
      return {
        error: error instanceof Error ? error.message : "Task creation failed."
      };
    }
  }

  if (name === "listTasks") {
    return { tasks: listTasks() };
  }

  return { error: `Unknown tool: ${name}` };
}

export async function runAgent(prompt: string): Promise<string> {
  let result = await chat.sendMessage(prompt);

  for (let round = 0; round < 4; round += 1) {
    const calls = result.response.functionCalls() ?? [];
    if (calls.length === 0) return result.response.text();

    const responses: FunctionResponsePart[] = await Promise.all(
      calls.map(async (call) => ({
        type: "functionResponse" as const,
        functionResponse: {
          name: call.name,
          response: await executeTool(
            call.name,
            call.args as Record<string, unknown>
          )
        }
      }))
    );

    result = await chat.sendMessage(responses);
  }

  return "I stopped after four tool rounds to prevent an accidental loop.";
}

Agent file checkpoint

This reference combines the declarations, dispatcher, model configuration, and multi-turn loop in their final order.

Compare your complete src/main.ts file with this reference:

import "./style.css";
import { runAgent } from "./agent";
import { listTasks } from "./storage";

const form = document.querySelector<HTMLFormElement>("#agent-form");
const input = document.querySelector<HTMLInputElement>("#agent-input");
const status = document.querySelector<HTMLParagraphElement>("#agent-status");
const taskList = document.querySelector<HTMLUListElement>("#task-list");
const taskCount = document.querySelector<HTMLSpanElement>("#task-count");

if (!form || !input || !status || !taskList || !taskCount) {
  throw new Error("Required page elements are missing.");
}

function renderTasks(): void {
  const tasks = listTasks();
  taskCount.textContent = String(tasks.length);
  taskList.replaceChildren();

  if (tasks.length === 0) {
    const empty = document.createElement("li");
    empty.className = "empty-state";
    empty.textContent = "No tasks yet. Ask the agent to add one.";
    taskList.append(empty);
    return;
  }

  for (const task of tasks) {
    const item = document.createElement("li");
    item.className = "task-card";
    item.textContent = task.title;
    taskList.append(item);
  }
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const prompt = input.value.trim();
  if (!prompt) return;

  input.disabled = true;
  status.textContent = "Agent is deciding which tools to use...";

  try {
    status.textContent = await runAgent(prompt);
    input.value = "";
    renderTasks();
  } catch (error) {
    status.textContent =
      error instanceof Error ? error.message : "The agent request failed.";
  } finally {
    input.disabled = false;
    input.focus();
  }
});

renderTasks();

Interface file checkpoint

This reference rerenders stored tasks after each successful agent request. It also restores the input after success or failure.

Compare your complete index.html file with this reference:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="theme-color" content="#111827" />
    <link rel="manifest" href="/manifest.webmanifest" />
    <link rel="icon" href="/icons/icon-192.svg" />
    <title>Pocket Action Agent</title>
  </head>
  <body>
    <main class="app-shell">
      <header>
        <p class="eyebrow">Tool-using AI on your phone</p>
        <h1>Pocket Action Agent</h1>
        <p>Ask for a task change and watch the board update.</p>
      </header>

      <section class="panel" aria-labelledby="agent-title">
        <h2 id="agent-title">Agent</h2>
        <form id="agent-form">
          <label for="agent-input">What should I do?</label>
          <div class="composer">
            <input
              id="agent-input"
              name="prompt"
              autocomplete="off"
              placeholder="Add review chapter three, then list my tasks"
              required
            />
            <button type="submit">Run</button>
          </div>
        </form>
        <p id="agent-status" class="status" aria-live="polite">
          Ready for a task request.
        </p>
      </section>

      <section class="panel" aria-labelledby="tasks-title">
        <div class="panel-heading">
          <h2 id="tasks-title">Tasks</h2>
          <span id="task-count" class="count">0</span>
        </div>
        <ul id="task-list" class="task-list"></ul>
      </section>
    </main>

    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Page file checkpoint

This reference preserves the element IDs used by src/main.ts and includes the multi-action prompt cue.

Your Pocket Action Agent now turns natural-language requests into persistent task-board actions. Next, you'll package that working agent as an installable Android PWA and deploy it over HTTPS.

Deploy and Install the Android PWA

Your agent can now turn a request into real task-board changes. The final step takes that working experience beyond your Windows browser.

A localhost page stays tied to your computer. A Progressive Web App packages the interface for an Android launch surface. Firebase Hosting provides the secure public address that your phone needs.

In this step, get ready to:
  • Add the manifest, icons, and service worker required by the installable app.
  • Build the app and deploy it to Firebase Hosting over HTTPS.
  • Install Pocket Agent from Android Chrome and verify task persistence.
Add the PWA files

A web app manifest describes how Pocket Agent should look when Android installs it. The icon files give the installed app its launch image.

Why a PWA for Android?

A PWA lets you reuse the TypeScript app that already works. It can launch from the Android home screen in its own window.

This keeps your attention on the agent workflow. A native build would add another language plus a separate build system.

  • Create a public folder inside pocket-action-agent with the New Folder control in the VS Code Explorer sidebar.
  • Create an icons folder inside public with the same control.

You should now see public/icons in the Explorer tree.

  • Create public/manifest.webmanifest with the New File control in the Explorer sidebar.
  • Add the install metadata by copying this code into the file:
{
  "name": "Pocket Action Agent",
  "short_name": "Pocket Agent",
  "id": "/",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "background_color": "#030712",
  "theme_color": "#111827",
  "prefer_related_applications": false,
  "icons": [
    {
      "src": "/icons/icon-192.svg",
      "type": "image/svg+xml",
      "sizes": "192x192",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512.svg",
      "type": "image/svg+xml",
      "sizes": "512x512",
      "purpose": "maskable"
    }
  ]
}

What does this manifest do?

  • The name fields control the full app name and the shorter Android label.
  • The start address and scope keep the installed experience inside your hosted app.
  • The standalone display mode removes the normal browser controls from the installed window.
  • The icon entries tell Android which launch images are available.
  • Save public/manifest.webmanifest.
  • Return to the local app from earlier in your browser.
  • Open http://localhost:5173/manifest.webmanifest to confirm the browser displays the manifest JSON.

Manifest not loading?

Check that manifest.webmanifest sits directly inside public. A nested location changes the address that Vite serves.

Make sure the local Vite app from earlier is still available. Help me find why my PWA manifest is not loading.

The first icon provides the regular Android launch image. Its rounded square surrounds the green task-completion mark.

  • Create public/icons/icon-192.svg with the New File control in the Explorer sidebar.
  • Add the smaller launch icon by copying this code into the file:
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
  <rect width="192" height="192" rx="42" fill="#111827"/>
  <circle cx="96" cy="96" r="58" fill="#22c55e"/>
  <path d="M62 98l22 22 47-51" fill="none" stroke="#052e16" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

What does this icon draw?

The SVG draws a dark rounded tile with a green circle. The final path draws the check mark that identifies Pocket Agent.

  • Save public/icons/icon-192.svg.
  • Open http://localhost:5173/icons/icon-192.svg to confirm you see the green check icon on a dark tile.

Smaller icon missing?

Confirm that the file name is exactly icon-192.svg. Check that it sits inside public/icons.

Help me debug the missing smaller PWA icon.

The second icon gives Android a larger source image. Its full square background also supports a masked launch icon.

  • Create public/icons/icon-512.svg with the New File control in the Explorer sidebar.
  • Add the larger launch icon by copying this code into the file:
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
  <rect width="512" height="512" fill="#111827"/>
  <circle cx="256" cy="256" r="154" fill="#22c55e"/>
  <path d="M166 261l59 59 126-136" fill="none" stroke="#052e16" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Why add a second icon?

Android can use the larger SVG when it creates the installed app icon. The square background leaves room for the device to apply its preferred icon shape.

  • Save public/icons/icon-512.svg.
  • Open http://localhost:5173/icons/icon-512.svg to confirm you see the larger green check icon.

Larger icon missing?

Check that icon-512.svg is beside icon-192.svg inside public/icons.

Help me debug the missing larger PWA icon.

A service worker runs separately from the page. It maintains an app-shell cache so Pocket Agent can recover its interface when a network request fails.

  • Create public/serviceworker.js with the New File control in the Explorer sidebar.
  • Add the cache list and install handler by copying this code into the file:
const CACHE_NAME = "pocket-action-agent-v1";
const APP_SHELL = [
  "/",
  "/manifest.webmanifest",
  "/icons/icon-192.svg",
  "/icons/icon-512.svg"
];

self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
});

What does the install handler do?

  • The cache name identifies this version of the stored app shell.
  • The app-shell list contains the page plus its PWA files.
  • The install handler stores those files before installation finishes.
  • Save public/serviceworker.js.
  • Open http://localhost:5173/serviceworker.js to confirm the browser displays the cache list and install handler.

Service worker file missing?

Confirm that serviceworker.js sits directly inside public. The root location lets it control the whole app.

Help me find why the service worker file is unavailable.

A new service worker version can leave an older cache behind. The activation handler removes cache versions that no longer match the current name.

  • Return to public/serviceworker.js in the VS Code editor.
  • Add the activation handler below the install handler by copying this code:
self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches
      .keys()
      .then((keys) =>
        Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
      )
  );
});

What does the activation handler do?

The handler reads every cache key. It deletes entries whose names differ from the current Pocket Agent cache.

  • Save public/serviceworker.js.
  • Refresh http://localhost:5173/serviceworker.js to confirm the activation handler now appears below the install handler.

Activation handler not visible?

Check that you added the handler below the closing characters of the install handler. Save the file before refreshing its browser address.

Help me place the service worker activation handler correctly.

The fetch handler tries the network first. It stores successful responses for later use. A failed request falls back to the matching cached file or the app shell.

  • Return to public/serviceworker.js in the VS Code editor.
  • Add the fetch handler below the activation handler by copying this code:
self.addEventListener("fetch", (event) => {
  const requestUrl = new URL(event.request.url);
  if (event.request.method !== "GET" || requestUrl.origin !== self.location.origin) {
    return;
  }

  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const copy = response.clone();
        void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
        return response;
      })
      .catch(async () => (await caches.match(event.request)) || caches.match("/"))
  );
});

What does the fetch handler protect?

  • The request checks keep non-reading requests and third-party addresses outside this caching flow.
  • A successful network response is copied into the current cache.
  • A failed network request falls back to cached content.
  • Save public/serviceworker.js.
  • Refresh http://localhost:5173/serviceworker.js to confirm the fetch handler now appears at the bottom.

Fetch handler not visible?

Confirm that the fetch handler sits after the complete activation handler. Compare every closing parenthesis and brace with the reference code.

Help me debug the service worker fetch handler.

The browser only runs the service worker after the page registers it. The registration belongs at the end of src/main.ts so the task board renders first.

  • Return to src/main.ts in the editor.
  • Find the final renderTasks(); line.
  • Add the registration below that line by copying this code:
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    void navigator.serviceWorker.register("/serviceworker.js");
  });
}

What does the registration do?

The feature check protects browsers without service worker support. A supported browser registers serviceworker.js after the page finishes loading.

  • Save src/main.ts.
  • Refresh http://localhost:5173 to load the new registration code.
  • Use Chrome developer tools to confirm a service worker controls the local app.

Service worker not registered?

Check that the registration address matches /serviceworker.js. Confirm that opening the same address directly displays the completed worker file.

Help me debug the service worker registration.

✔️ Awesome, I've got everything!

Great. Save every project file before creating the production build.

ⓧ I'd like to double check the full code

Compare your cumulative project files with these references. The PWA files and service worker registration should match exactly.

node_modules/
dist/
.firebase/
firebase-debug.log*
{
  "hosting": {
    "public": "dist",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="theme-color" content="#111827" />
    <link rel="manifest" href="/manifest.webmanifest" />
    <link rel="icon" href="/icons/icon-192.svg" />
    <title>Pocket Action Agent</title>
  </head>
  <body>
    <main class="app-shell">
      <header>
        <p class="eyebrow">Tool-using AI on your phone</p>
        <h1>Pocket Action Agent</h1>
        <p>Ask for a task change and watch the board update.</p>
      </header>

      <section class="panel" aria-labelledby="agent-title">
        <h2 id="agent-title">Agent</h2>
        <form id="agent-form">
          <label for="agent-input">What should I do?</label>
          <div class="composer">
            <input
              id="agent-input"
              name="prompt"
              autocomplete="off"
              placeholder="Add review chapter three, then list my tasks"
              required
            />
            <button type="submit">Run</button>
          </div>
        </form>
        <p id="agent-status" class="status" aria-live="polite">
          Ready for a task request.
        </p>
      </section>

      <section class="panel" aria-labelledby="tasks-title">
        <div class="panel-heading">
          <h2 id="tasks-title">Tasks</h2>
          <span id="task-count" class="count">0</span>
        </div>
        <ul id="task-list" class="task-list"></ul>
      </section>
    </main>

    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
{
  "name": "pocket-action-agent",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "firebase": "13.1.0"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "vite": "8.3.5"
  }
}
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
  <rect width="192" height="192" rx="42" fill="#111827"/>
  <circle cx="96" cy="96" r="58" fill="#22c55e"/>
  <path d="M62 98l22 22 47-51" fill="none" stroke="#052e16" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
  <rect width="512" height="512" fill="#111827"/>
  <circle cx="256" cy="256" r="154" fill="#22c55e"/>
  <path d="M166 261l59 59 126-136" fill="none" stroke="#052e16" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{
  "name": "Pocket Action Agent",
  "short_name": "Pocket Agent",
  "id": "/",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "background_color": "#030712",
  "theme_color": "#111827",
  "prefer_related_applications": false,
  "icons": [
    {
      "src": "/icons/icon-192.svg",
      "type": "image/svg+xml",
      "sizes": "192x192",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512.svg",
      "type": "image/svg+xml",
      "sizes": "512x512",
      "purpose": "maskable"
    }
  ]
}
const CACHE_NAME = "pocket-action-agent-v1";
const APP_SHELL = [
  "/",
  "/manifest.webmanifest",
  "/icons/icon-192.svg",
  "/icons/icon-512.svg"
];

self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches
      .keys()
      .then((keys) =>
        Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
      )
  );
});

self.addEventListener("fetch", (event) => {
  const requestUrl = new URL(event.request.url);
  if (event.request.method !== "GET" || requestUrl.origin !== self.location.origin) {
    return;
  }

  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const copy = response.clone();
        void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
        return response;
      })
      .catch(async () => (await caches.match(event.request)) || caches.match("/"))
  );
});
import {
  getGenerativeModel,
  Schema,
  type FunctionDeclarationsTool,
  type FunctionResponsePart
} from "firebase/ai";
import { ai } from "./firebase";
import { addTask, listTasks } from "./storage";

const taskTools: FunctionDeclarationsTool = {
  functionDeclarations: [
    {
      name: "addTask",
      description: "Add one task to the user's local task board.",
      parameters: Schema.object({
        properties: {
          title: Schema.string({
            description: "The concise task title to add."
          })
        }
      })
    },
    {
      name: "listTasks",
      description: "Read every task currently on the user's local task board."
    }
  ]
};

const model = getGenerativeModel(ai, {
  model: "gemini-3.8-flash",
  systemInstruction:
    "You are Pocket Action Agent. Use addTask whenever the user asks to create a task. Use listTasks whenever the user asks what is on the board or when the current list is needed. After tools run, briefly report what changed.",
  tools: [taskTools]
});

const chat = model.startChat();

async function executeTool(
  name: string,
  args: Record<string, unknown>
): Promise<object> {
  if (name === "addTask") {
    const title = typeof args.title === "string" ? args.title : "";
    try {
      return { task: addTask(title) };
    } catch (error) {
      return {
        error: error instanceof Error ? error.message : "Task creation failed."
      };
    }
  }

  if (name === "listTasks") {
    return { tasks: listTasks() };
  }

  return { error: `Unknown tool: ${name}` };
}

export async function runAgent(prompt: string): Promise<string> {
  let result = await chat.sendMessage(prompt);

  for (let round = 0; round < 4; round += 1) {
    const calls = result.response.functionCalls() ?? [];
    if (calls.length === 0) return result.response.text();

    const responses: FunctionResponsePart[] = await Promise.all(
      calls.map(async (call) => ({
        type: "functionResponse" as const,
        functionResponse: {
          name: call.name,
          response: await executeTool(
            call.name,
            call.args as Record<string, unknown>
          )
        }
      }))
    );

    result = await chat.sendMessage(responses);
  }

  return "I stopped after four tool rounds to prevent an accidental loop.";
}
import { initializeApp } from "firebase/app";
import {
  initializeAppCheck,
  ReCaptchaEnterpriseProvider
} from "firebase/app-check";
import { getAI, GoogleAIBackend } from "firebase/ai";

const firebaseConfig = {
  apiKey: "your-firebase-web-api-key",
  authDomain: "your-project-id.firebaseapp.com",
  projectId: "your-project-id",
  storageBucket: "your-project-id.firebasestorage.app",
  messagingSenderId: "your-messaging-sender-id",
  appId: "your-firebase-app-id"
};

const app = initializeApp(firebaseConfig);

if (import.meta.env.DEV) {
  const debugSelf = self as typeof self & {
    FIREBASE_APPCHECK_DEBUG_TOKEN?: boolean;
  };
  debugSelf.FIREBASE_APPCHECK_DEBUG_TOKEN = true;
}

initializeAppCheck(app, {
  provider: new ReCaptchaEnterpriseProvider(
    "your-recaptcha-enterprise-site-key"
  ),
  isTokenAutoRefreshEnabled: true
});

export const ai = getAI(app, { backend: new GoogleAIBackend() });
import "./style.css";
import { runAgent } from "./agent";
import { listTasks } from "./storage";

const form = document.querySelector<HTMLFormElement>("#agent-form");
const input = document.querySelector<HTMLInputElement>("#agent-input");
const status = document.querySelector<HTMLParagraphElement>("#agent-status");
const taskList = document.querySelector<HTMLUListElement>("#task-list");
const taskCount = document.querySelector<HTMLSpanElement>("#task-count");

if (!form || !input || !status || !taskList || !taskCount) {
  throw new Error("Required page elements are missing.");
}

function renderTasks(): void {
  const tasks = listTasks();
  taskCount.textContent = String(tasks.length);
  taskList.replaceChildren();

  if (tasks.length === 0) {
    const empty = document.createElement("li");
    empty.className = "empty-state";
    empty.textContent = "No tasks yet. Ask the agent to add one.";
    taskList.append(empty);
    return;
  }

  for (const task of tasks) {
    const item = document.createElement("li");
    item.className = "task-card";
    item.textContent = task.title;
    taskList.append(item);
  }
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const prompt = input.value.trim();
  if (!prompt) return;

  input.disabled = true;
  status.textContent = "Agent is deciding which tools to use...";

  try {
    status.textContent = await runAgent(prompt);
    input.value = "";
    renderTasks();
  } catch (error) {
    status.textContent =
      error instanceof Error ? error.message : "The agent request failed.";
  } finally {
    input.disabled = false;
    input.focus();
  }
});

renderTasks();

if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    void navigator.serviceWorker.register("/serviceworker.js");
  });
}
export type Task = {
  id: string;
  title: string;
  done: boolean;
  createdAt: string;
};

const STORAGE_KEY = "pocket-action-agent.tasks";

function readTasks(): Task[] {
  const saved = localStorage.getItem(STORAGE_KEY);
  if (!saved) return [];

  try {
    return JSON.parse(saved) as Task[];
  } catch {
    return [];
  }
}

function writeTasks(tasks: Task[]): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}

export function listTasks(): Task[] {
  return readTasks();
}

export function addTask(title: string): Task {
  const cleanTitle = title.trim();
  if (!cleanTitle) throw new Error("A task title is required.");

  const task: Task = {
    id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
    title: cleanTitle,
    done: false,
    createdAt: new Date().toISOString()
  };

  const tasks = readTasks();
  tasks.push(task);
  writeTasks(tasks);
  return task;
}
:root {
  color: #e5e7eb;
  background: #030712;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top, #1f2937 0, transparent 40%),
    #030712;
}

button,
input {
  font: inherit;
}

.app-shell {
  width: min(100% - 2rem, 680px);
  margin: 0 auto;
  padding: 2rem 0 4rem;
}

header {
  margin-bottom: 1.5rem;
}

h1,
h2,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 0.5rem;
  font-size: clamp(2rem, 8vw, 3.5rem);
  line-height: 1;
}

h2 {
  margin-bottom: 1rem;
  font-size: 1.1rem;
}

.eyebrow,
.status {
  color: #86efac;
}

.panel {
  margin-top: 1rem;
  padding: 1rem;
  border: 1px solid #374151;
  border-radius: 1rem;
  background: rgb(17 24 39 / 88%);
  box-shadow: 0 18px 50px rgb(0 0 0 / 25%);
}

.composer {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.75rem;
  margin-top: 0.5rem;
}

input {
  width: 100%;
  padding: 0.85rem 1rem;
  color: #f9fafb;
  border: 1px solid #4b5563;
  border-radius: 0.75rem;
  background: #111827;
}

button {
  padding: 0.85rem 1rem;
  color: #052e16;
  font-weight: 700;
  border: 0;
  border-radius: 0.75rem;
  background: #86efac;
  cursor: pointer;
}

button:disabled,
input:disabled {
  opacity: 0.6;
}

.status {
  min-height: 1.5rem;
  margin: 1rem 0 0;
}

.panel-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.count {
  display: grid;
  width: 2rem;
  height: 2rem;
  place-items: center;
  border-radius: 999px;
  background: #1f2937;
}

.task-list {
  display: grid;
  gap: 0.75rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.task-card,
.empty-state {
  padding: 0.9rem 1rem;
  border-radius: 0.75rem;
}

.task-card {
  border-left: 4px solid #22c55e;
  background: #1f2937;
}

.empty-state {
  color: #9ca3af;
  border: 1px dashed #4b5563;
}

@media (max-width: 520px) {
  .composer {
    grid-template-columns: 1fr;
  }
}
{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,
    "moduleResolution": "Bundler",
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "strict": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}
Build and deploy the app

The development server transforms files while you work. A production build asks Vite to generate the static files that Firebase Hosting publishes from dist.

Before you build, do you expect the PWA files from public to appear in the deployment output?

  • Return to the VS Code terminal for pocket-action-agent.
  • Create the production deployment build by running:
npm run build

What does this build do?

The build script runs the TypeScript check first. Vite then writes the optimized application and public assets into dist.

You should see a successful build summary. You should also see the generated dist folder in the Explorer sidebar.

Build failed?

Read the first reported file path in the terminal. A failure at this point usually points to a typing mistake in src/main.ts or invalid syntax in one of the new files.

Help me fix the production build failure.

Before you deploy, do you expect Firebase Hosting to return a local address or a public secure address?

  • Deploy the contents of dist to the linked Firebase Hosting site by running:
firebase deploy --only hosting

What does this deployment do?

The command reads the existing firebase.json configuration. It uploads the built files from dist to your linked Hosting site.

Firebase Hosting serves the result over HTTPS. That secure address satisfies a core PWA installation requirement.

You should see a successful deployment result followed by the generated Hosting URL.

  • Copy the HTTPS Hosting URL from the terminal into your generated PROJECT_ID.web.app URL.
  • Open your generated PROJECT_ID.web.app URL in your Windows browser.
  • Confirm the hosted Pocket Action Agent displays the Agent panel and Tasks panel.

Deployment or hosted page failed?

Confirm that the terminal is inside pocket-action-agent. Check that the successful build created dist before the deployment command ran.

Help me troubleshoot the Firebase Hosting deployment.

Install and verify Pocket Agent

The hosted app now has a secure address plus the required installation metadata. Google Chrome for Android can use those files to place Pocket Agent on your home screen.

  • Open Google Chrome on your Android phone.
  • Enter your generated PROJECT_ID.web.app URL in the Chrome address bar.
  • Load the hosted Pocket Action Agent.
  • Open the Chrome menu.
  • Select Install if that action is available.
  • Select Add to Home Screen if Chrome shows that action instead.
  • Confirm the installation prompt.

Pocket Agent should now appear on your Android home screen or app launcher.

Install action missing?

Confirm that you opened the generated HTTPS Hosting URL. Reload the page after the manifest and service worker finish loading.

Help me find why Android Chrome does not offer PWA installation.

  • Return to the Android home screen.
  • Tap the Pocket Agent icon.
  • Confirm the app launches in a separate window without the normal Chrome address bar.
  • Enter Add review chapter three to my tasks in the agent input.
  • Tap Run.
  • Wait for the agent response.
  • Confirm a task card named Review chapter three appears on the board.

Before you reopen the installed app, do you expect the task card to remain in its local task list?

  • Close the standalone Pocket Agent window.
  • Tap the Pocket Agent home-screen icon again.
  • Confirm the Review chapter three task card still appears.

Task missing after reopening?

Confirm that you reopened Pocket Agent from the installed icon. Tasks live in the Android Chrome profile through local browser storage.

Clearing site data removes the locally stored task list. Help me diagnose why the installed PWA lost its task.

That completes the mobile loop. Your secured agent now launches from Android and keeps its local task board across app sessions.

Secret mission

Add a Safe Clear Tool

Your agent can create tasks, but destructive tools need a human checkpoint. Add a clear tool that preserves every task until the user explicitly approves deletion.

Clean Up Your Resources

Clean Up Your Resources

Your Firebase resources use no-cost quotas on the Spark plan. They do not silently move to paid usage.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Cloud: One Firebase project on the Spark plan.
  • Cloud: One registered Web app using Firebase AI Logic.
  • Cloud: Enforced Firebase App Check protection for the Web app.
  • Cloud: One reCAPTCHA Enterprise score-based key.
  • Cloud: One Firebase Hosting site serving the rebuilt dist directory over HTTPS.
  • Local: The pocket-action-agent project folder.
  • Local: The installed Pocket Agent PWA on your Android phone.
  • Local: Task data stored in your Windows browser profile.
  • Local: Task data stored in your Android Google Chrome profile.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep demonstrating Pocket Agent.

  • Leave the Firebase project on the Spark plan.
  • Keep the Firebase Hosting site available over HTTPS.
  • Keep the pocket-action-agent folder for future changes.
  • Keep Pocket Agent installed on your Android phone.
  • Keep each browser profile intact so its local task data remains available.

Pause - I'll come back to this later

Pause public access while keeping the Firebase project configuration. Your local project files remain ready for a later rebuild.

  • Return to the terminal panel linked to the Firebase project from earlier.
  • Pause the Firebase Hosting site by running this command:
firebase hosting:disable

What Does This Command Do?

The firebase CLI stops the Hosting site from serving traffic. It keeps the linked project configuration available for a future deployment.

You now have a reversible pause. The Hosting URLs show Site Not Found while access is disabled.

Hosting Still Available?

Confirm that the terminal is still linked to the Firebase project from earlier. Refresh the Hosting URL after the command finishes.

Ask for help diagnosing the pause command.

Delete - I don't want to use this again

Remove the local data before deleting cloud access. Task data belongs to the browser profile on each device.

  • Return to the deployed Pocket Agent in your Windows browser.
  • Enter Clear all tasks in the agent input.
  • Select Run.
  • Approve the browser confirmation.
  • Return to the installed Pocket Agent on your Android phone.
  • Enter Clear all tasks in the agent input.
  • Select Run.
  • Approve the browser confirmation.

That handles the local task data. Both profiles now show an empty board.

The Android installation has its own launcher entry. Removing it clears Pocket Agent from your phone's launch surfaces.

  • Return to the Android home screen.
  • Touch and hold the Pocket Agent icon.
  • Choose your phone's app-removal option.
  • Confirm the removal.

Pocket Agent no longer appears on your Android home screen.

Deleting the Firebase project is permanent. The local task data is already cleared before this cloud teardown.

  • Return to the Firebase Console project from earlier.
  • Go to the project's settings page.
  • Choose the project deletion option.
  • Complete the confirmation shown by Firebase.

What Gets Removed With the Project?

  • The registered Firebase Web app is removed.
  • The Firebase AI Logic configuration is removed.
  • The Firebase App Check enforcement is removed.
  • The reCAPTCHA Enterprise score-based key is removed.
  • The Firebase Hosting site is removed.

The final local resource is the source folder. Removing it clears the project files from your Windows computer.

  • Return to the terminal panel whose current directory is pocket-action-agent.
  • Move to the parent folder before removing the project folder by running these commands:
cd ..
rmdir pocket-action-agent -Recurse -Force
ls

What Do These Commands Do?

  • The first command moves the terminal outside the project folder.
  • The second command removes the complete pocket-action-agent folder.
  • The final command lists the items that remain in the parent folder.

You should no longer see pocket-action-agent in the terminal output.

Folder Still Present?

Close any editor window using files from pocket-action-agent. Return to the parent folder before trying the removal again.

Ask for help removing the folder.

Nice Work!

Nice Work!

You made it! Pocket Action Agent now turns natural-language requests into real task-board changes from an Android home-screen app.

You've learned how to:

  • Build a persistent task board that stores tasks in localStorage. Your tasks remain available after a browser reload.
  • Create a tool-using agent with function calling. The model can select addTask or listTasks. Your function-response loop executes each request against the live board.
  • Secure model access through Firebase AI Logic with Firebase App Check backed by reCAPTCHA Enterprise. Deploy the finished PWA over HTTPS with Firebase Hosting. Install it from Chrome on Android.
  • Secret Mission: Added a human approval gate that protects clearAllTasks. Cancellation preserves the board. Approval clears every task.

Ready to quiz yourself?