Antigravity × Firebase Genkit: Building Production AI Apps—From Flow Design to Deployment
Field notes from building production AI apps with Firebase Genkit and Antigravity: type-safe flow design, RAG pipelines, streaming, schema changes on deployed flows, and deployment—all with working code.
Have you ever spent hours debating whether to call the Gemini API directly or use Genkit? I certainly have.
Firebase Genkit is the AI application framework Google released in 2024. It's not simply a wrapper around the Gemini API—it's designed to cover the entire lifecycle of AI features, from local development, debugging, and testing through to production deployment and monitoring. And once you actually use it alongside Antigravity, you'll find the combination works even better than expected.
This guide walks through the complete process of designing, implementing, and deploying a Genkit app while making the most of Antigravity's AI autocomplete—drawing from real experience building production apps with this stack.
The Problems Firebase Genkit Solves
Calling the Gemini API directly is perfectly valid for simple text generation. But once you start building production-grade AI features, you quickly run into common pain points.
Debugging flows is hard. Tracking which prompt produced which response, or pinpointing where a tool call failed, requires building your own logging infrastructure. Tests are difficult to write. Processes that involve LLM calls are nearly impossible to unit test without mocking. Switching models is tedious. When you want to use a lightweight model in development and a high-accuracy model in production, you often end up rewriting large swaths of code.
Genkit addresses all of this through an abstraction layer called "flows." A flow is a unit that groups together LLM calls, external API calls, and tool executions—and it comes with execution tracing, typed input/output definitions, and a local debug UI out of the box.
Why does it pair well with Antigravity? Genkit is TypeScript-first and ships with rich type information. Antigravity's tab completion accurately fills in flow options, provider configurations, and Zod schemas. The time spent on flow definition boilerplate drops significantly.
Create a Genkit instance in src/genkit.ts. Sharing this file across your app centralizes configuration.
// src/genkit.tsimport { genkit } from "genkit";import { googleAI } from "@genkit-ai/googleai";// Always read the API key from an environment variable—never hardcode itconst GOOGLE_API_KEY = process.env.GOOGLE_AI_API_KEY;if (!GOOGLE_API_KEY) { throw new Error("GOOGLE_AI_API_KEY environment variable is required");}export const ai = genkit({ plugins: [ googleAI({ apiKey: GOOGLE_API_KEY }), ], promptDir: "./prompts",});// Define models as constants for easy swapping laterexport const MODELS = { fast: "googleai/gemini-2.0-flash", balanced: "googleai/gemini-2.5-pro", local: "googleai/gemma-4-9b-it", // For local testing} as const;
Manage .env files through dotenv and verify .env is in .gitignore before committing. Asking the Antigravity agent to check .gitignore becomes a useful habit that guards against secret leaks.
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you've been stuck on Genkit flow design, you'll walk away able to build type-safe AI pipelines today—leveraging Antigravity's autocomplete to cut boilerplate dramatically
✦Firestore Vector Search integration, chunking strategy, and error handling for RAG pipelines are all demystified with working, production-ready code
✦You'll acquire a clear mental model for designing retry logic, streaming, and cost control in production—knowledge you can apply directly to your own products
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The central concept in Genkit is the "flow"—a type-safe async processing unit with Zod-defined input and output schemas.
Simple Text Generation Flow
// src/flows/summarize.tsimport { z } from "genkit";import { ai, MODELS } from "../genkit";const SummarizeInput = z.object({ text: z.string().min(1).max(10000).describe("Text to summarize"), language: z.enum(["ja", "en"]).default("en").describe("Output language"), maxLength: z.number().int().min(50).max(500).default(200).describe("Max character count"),});const SummarizeOutput = z.object({ summary: z.string().describe("Generated summary"), keyPoints: z.array(z.string()).describe("Key takeaways"), wordCount: z.number().describe("Character count of original text"),});export const summarizeFlow = ai.defineFlow( { name: "summarizeFlow", inputSchema: SummarizeInput, outputSchema: SummarizeOutput, }, async (input) => { const { output } = await ai.generate({ model: MODELS.fast, output: { schema: SummarizeOutput }, prompt: `Summarize the following text in ${input.language === "ja" ? "Japanese" : "English"}.Keep the summary under ${input.maxLength} characters and extract up to 3 key points.Text:${input.text}`, }); if (!output) { throw new Error("LLM returned an empty response"); } return { ...output, wordCount: input.text.length }; });
To run the flow, start Genkit's development server.
# Launch the local debug UI at http://localhost:4000npx genkit start -- npx ts-node src/index.ts
Opening http://localhost:4000 gives you a development UI where you can run flows from a GUI. It also records traces of every LLM call, which makes debugging far easier. This UI was the first thing that made me think, "okay, this is genuinely better."
Entry Point
// src/index.tsimport { summarizeFlow } from "./flows/summarize";// Export flows so Genkit can discover themexport { summarizeFlow };if (process.env.NODE_ENV === "development") { console.log("🚀 Genkit development server starting...");}
Structured Output and Tool Integration
In real products, you often need more than simple text generation—you want data in a specific format, or you want to call external APIs and incorporate their results.
Defining Tools (Function Calling)
Tool definitions in Genkit combine a Zod schema with a handler function.
Here's an example of a shopping assistant that maintains conversational context.
// src/flows/shopping-assistant.tsimport { z } from "genkit";import { ai, MODELS } from "../genkit";import { searchProductsTool } from "../tools/search";const AssistantInput = z.object({ message: z.string().describe("User message"), conversationHistory: z .array(z.object({ role: z.enum(["user", "model"]), content: z.string() })) .default([]) .describe("Prior conversation history"),});const AssistantOutput = z.object({ reply: z.string(), recommendedProducts: z .array(z.object({ id: z.string(), name: z.string(), price: z.number() })) .default([]), requiresHumanSupport: z.boolean().default(false),});export const shoppingAssistantFlow = ai.defineFlow( { name: "shoppingAssistantFlow", inputSchema: AssistantInput, outputSchema: AssistantOutput, }, async (input) => { const history = input.conversationHistory.map((msg) => ({ role: msg.role, content: [{ text: msg.content }], })); const { output } = await ai.generate({ model: MODELS.balanced, tools: [searchProductsTool], output: { schema: AssistantOutput }, messages: [ ...history, { role: "user" as const, content: [{ text: input.message }] }, ], system: `You are a shopping assistant. Use the product search tool to suggest relevant items.Respond in a friendly, helpful tone.Set requiresHumanSupport to true for questions you cannot handle (complaints, legal issues, etc.).`, }); if (!output) { throw new Error("Assistant returned an empty response"); } return output; });
With tool-using flows, the LLM autonomously decides which tools to call and when. Genkit handles the tool call loop automatically, so you don't need to manually re-feed tool results back into the LLM.
Building a RAG Pipeline
RAG (Retrieval-Augmented Generation) retrieves relevant information from an external database and passes it to the LLM. Pairing Genkit with Firestore Vector Search yields a scalable RAG pipeline.
// src/rag/indexer.tsimport { z } from "genkit";import { ai } from "../genkit";import { getFirestore, FieldValue } from "firebase-admin/firestore";import { initializeApp, getApps } from "firebase-admin/app";if (!getApps().length) { initializeApp();}const db = getFirestore();// Split text into overlapping chunksfunction chunkText(text: string, chunkSize = 800, overlap = 100): string[] { const chunks: string[] = []; let start = 0; while (start < text.length) { const end = Math.min(start + chunkSize, text.length); chunks.push(text.slice(start, end)); start = end - overlap; if (start >= text.length) break; } return chunks;}export const indexDocumentFlow = ai.defineFlow( { name: "indexDocumentFlow", inputSchema: z.object({ documentId: z.string(), content: z.string(), metadata: z.record(z.string()).default({}), }), outputSchema: z.object({ chunksIndexed: z.number(), status: z.string(), }), }, async (input) => { const chunks = chunkText(input.content); let chunksIndexed = 0; for (const chunk of chunks) { try { const embeddingResult = await ai.embed({ embedder: "googleai/text-embedding-004", content: chunk, }); await db.collection("document_chunks").add({ documentId: input.documentId, content: chunk, embedding: FieldValue.vector(embeddingResult.embedding), metadata: input.metadata, createdAt: FieldValue.serverTimestamp(), }); chunksIndexed++; } catch (error) { // Log chunk-level errors and continue processing console.error(`Chunk ${chunksIndexed} indexing failed:`, error); if (chunksIndexed === 0) throw error; // Fail fast if nothing indexed yet } } return { chunksIndexed, status: chunksIndexed > 0 ? "success" : "failed" }; });
Retrieval and Generation Flow
// src/rag/query.tsimport { z } from "genkit";import { ai, MODELS } from "../genkit";import { getFirestore, FieldValue } from "firebase-admin/firestore";const db = getFirestore();export const ragQueryFlow = ai.defineFlow( { name: "ragQueryFlow", inputSchema: z.object({ question: z.string().min(1), topK: z.number().int().min(1).max(10).default(3), }), outputSchema: z.object({ answer: z.string(), sources: z.array( z.object({ documentId: z.string(), excerpt: z.string(), relevanceScore: z.number(), }) ), }), }, async (input) => { const queryEmbedding = await ai.embed({ embedder: "googleai/text-embedding-004", content: input.question, }); const vectorQuery = db .collection("document_chunks") .findNearest("embedding", FieldValue.vector(queryEmbedding.embedding), { limit: input.topK, distanceMeasure: "COSINE", }); const snapshot = await vectorQuery.get(); const sources = snapshot.docs.map((doc) => ({ documentId: doc.data().documentId as string, excerpt: doc.data().content as string, relevanceScore: 1 - (doc.data().distance ?? 0), })); if (sources.length === 0) { return { answer: "No relevant documents were found.", sources: [] }; } const context = sources .map((s, i) => `[Source ${i + 1}] ${s.excerpt}`) .join("\n\n"); const { output } = await ai.generate({ model: MODELS.balanced, output: { schema: z.object({ answer: z.string() }) }, prompt: `Answer the question accurately and concisely based only on the provided sources.If the answer isn't in the sources, say "That information was not found."Sources:${context}Question: ${input.question}`, }); return { answer: output?.answer ?? "Could not generate an answer", sources }; });
Chunk size design directly impacts RAG accuracy. In practice, the right size depends on the nature of your documents—structured technical docs often benefit from smaller chunks (400–600 chars), while narrative prose can handle larger ones (800–1200 chars). Adding overlap prevents context from being severed at chunk boundaries.
Implementing Streaming Responses
For chat interfaces where you want to display responses incrementally, streaming is essential. Genkit's streaming flows are designed to pair naturally with Server-Sent Events (SSE).
Handling LLM errors and rate limits gracefully is among the most important production concerns. Genkit has built-in retry behavior, but fine-grained control requires custom implementation.
Here are three issues I've run into when using Genkit in production.
① Zod schema and LLM output don't match
Even with output.schema specified, the LLM may return output that doesn't match—especially when z.enum() restricts the values.
// ❌ Problematic: enum is too strictconst OutputSchema = z.object({ category: z.enum(["tech", "lifestyle", "food"]), // Fails with ZodError if the LLM returns "Technology"});// ✅ Better: normalize with transformconst OutputSchema = z.object({ category: z.string().transform((val) => { const normalized = val.toLowerCase(); if (normalized.includes("tech")) return "tech"; if (normalized.includes("life")) return "lifestyle"; if (normalized.includes("food")) return "food"; return "tech"; // sensible default }),});
② Memory leaks from streaming flows
When an error occurs during for await...of stream consumption, the stream can be left open if not properly closed.
// ❌ Problematic: stream may leak on errorconst { stream } = await ai.generateStream({ ... });for await (const chunk of stream) { process(chunk); // An exception here leaves the stream open}// ✅ Correct: use try-finally to guarantee stream closureconst { stream, response } = await ai.generateStream({ ... });try { for await (const chunk of stream) { process(chunk); }} catch (error) { console.error("Stream processing error:", error); throw error;} finally { await response.catch(() => {}); // Silently close the stream}
③ Circular dependency errors between flows
When multiple flows import each other, Node.js raises a circular dependency error. Separate shared tools and utilities into dedicated files.
// ❌ Problematic: flow-a.ts imports flow-b.ts, and vice versa// ✅ Correct structure:// src/tools/shared-tools.ts ← All shared tools go here// src/flows/flow-a.ts ← Imports only from shared-tools// src/flows/flow-b.ts ← Imports only from shared-tools
Deploying to Firebase App Hosting
Genkit apps deploy directly to Firebase App Hosting (which is backed by Cloud Run).
# apphosting.yamlrunConfig: cpu: 1 memoryMiB: 512 concurrency: 80 minInstances: 0 # Start at zero for cost savings maxInstances: 10env: - variable: GOOGLE_AI_API_KEY secret: google-ai-api-key # Managed in Firebase Secret Manager - variable: NODE_ENV value: production
Deploying is a single command: firebase deploy --only hosting. App Hosting automatically builds the Cloud Run container and deploys it.
Development Workflow in Antigravity
Setting up custom rules for the project pays dividends when working in Antigravity.
# .antigravity/rules.md## Genkit Flow Development Rules- Always define Zod schemas for both input and output- Use the withRetry utility for error handling- Select models from the MODELS constant (don't hardcode model strings)- Always use try-finally in streaming flows
With these rules in place, the Antigravity agent automatically applies these patterns when generating new flows. Asking it to "write a new flow" produces complete code including type definitions and error handling.
Cost Management
Genkit makes it easy to assign different models to different flows, which is the most effective lever for controlling costs. My approach: conversational flows that need fast responses use gemini-2.0-flash; batch processing that demands high accuracy uses gemini-2.5-pro. Designing with cost in mind from the start also makes future model swaps much less disruptive.
Because Genkit pins flow input and output with Zod, changing a schema breaks existing clients the moment you ship it. I learned this by adding confidence as a required output field to a summarization flow. The server deployed cleanly and the local dev UI behaved perfectly—but requests from older app builds kept returning 500.
The reason is straightforward: changing output.schema rewrites the response contract, not the server's intake. As long as the flow name stays the same, clients keep calling it assuming the old shape.
These days I sort changes by type before touching anything.
Type of change
Backward compatible?
What to do
Add an optional output field
Yes
Ship it directly
Add a required output field
No
Ship as optional first, make it required after clients update
Add a required input field
No
Accept it with .default() so old requests still work
Rename or remove a field
No
Split into a separate flow name and run both for a while
That last row takes the most work in practice. Maintaining two implementations guarantees you'll eventually fix only one of them, so I keep one implementation with two contracts.
// src/flows/summarize.ts — splitting the contract without breaking old clientsimport { z } from "genkit";import { ai } from "../genkit";import { MODELS } from "../config/models";const SummaryV1 = z.object({ summary: z.string(), keywords: z.array(z.string()),});// confidence was added in v2; the v1 contract never exposes itconst SummaryV2 = SummaryV1.extend({ confidence: z.number().min(0).max(1),});const InputSchema = z.object({ text: z.string().min(1), maxWords: z.number().int().positive().default(120),});// The only place the actual work livesasync function summarize(input: z.infer<typeof InputSchema>) { const { output } = await ai.generate({ model: MODELS.fast, prompt: `Summarize the following in ${input.maxWords} words or fewer, and self-report your confidence from 0 to 1.\n\n${input.text}`, output: { schema: SummaryV2 }, }); if (!output) { throw new Error("summarize: the model returned no structured output"); } return output;}// v2: the new contractexport const summarizeFlowV2 = ai.defineFlow( { name: "summarizeFlowV2", inputSchema: InputSchema, outputSchema: SummaryV2 }, summarize,);// v1: for older clients. Same implementation, old response shapeexport const summarizeFlow = ai.defineFlow( { name: "summarizeFlow", inputSchema: InputSchema, outputSchema: SummaryV1 }, async (input) => { const { confidence: _unused, ...legacy } = await summarize(input); return legacy; },);
The payoff is that v1 traces record the same generation path as v2. You can wait until traces show genuinely zero v1 traffic before deleting summarizeFlow, instead of removing it on a hunch. Deleting on "nobody uses this anymore" is how you find out about the device that only wakes up twice a month.
One line in the Antigravity config helps here too. Adding "never edit outputSchema on a deployed flow—add a new flow name instead" to .antigravity/rules.md stopped the agent from proposing schema-overwriting diffs entirely. Rules files turn out to be a good place for prohibitions, not just conventions.
What Running This Solo Actually Taught Me
After about four months of running Genkit in my own product, a few things landed differently than I expected.
First, the local dev UI is a tool for trying things, not for watching them. The trace view in genkit start is indispensable while you're writing a flow, but it only records runs from the process that's currently alive. It's the wrong tool for reading back what happened in production overnight. I ended up wrapping each flow's entry and exit in spans and shipping them out. My notes on span granularity and cardinality live in Assembling Observability with Antigravity × OpenTelemetry.
Second, splitting models per flow paid off more in billing readability than in raw savings. When everything ran on one model, the month-end number told me nothing about what was expensive. Once each flow had its own model constant and its name showed up in traces, the breakdown became legible: summarization was cheap, RAG reranking was roughly sixty percent of the total. Knowing where the money goes is what makes the caching-versus-cheaper-model decision possible at all. For flows with stable reference text, Gemini API context caching is the lever I reach for.
Working as a solo indie developer means there's no design review to catch this class of mistake before it reaches production—you collect the bill yourself, later. The thing I most regret is packing too much into a single flow. My early RAG implementation put retrieval, reranking, and answer generation inside one defineFlow. It worked—but when it was slow, I had no idea which part was slow. Opening the trace showed one large opaque box. Splitting it into three flows called from a parent turned the latency breakdown into the trace hierarchy itself. Decide flow boundaries by "what do I want to measure separately," not "what do I want to reuse." Your future self will thank you.
Finally, an honest note on where Antigravity's autocomplete helps. For Zod schemas and flow-definition boilerplate, a few characters usually produce exactly the shape I intended. Prompt bodies and RAG chunk boundaries are a different story—left to autocomplete, they drift toward the generic. Those are the parts I still decide by hand while looking at my own data. Handing the scaffolding to the machine and moving that time into the judgment calls is, in the end, what this combination is good for.
Wrapping Up
Firebase Genkit reduces both development and operational costs compared to calling the Gemini API directly—specifically by hiding the complexity of working with LLMs while delivering the observability and type safety that production requires. Antigravity's autocomplete, which accurately infers flow type information, further speeds up initial setup.
The first thing to try is the local development UI (genkit start). Running your flows from a browser and watching LLM inputs, outputs, and traces in real time tends to dissolve the assumption that "debugging AI features is painful."
I hope some of this saves you the detours it took me to find. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.