ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-04-29Advanced

Composing Event-Driven AI Workflows with Antigravity and Inngest — A Production-Ready Implementation Guide

A hands-on production guide to wiring Antigravity's AI agents with Inngest. Cover idempotency, concurrency control, human-in-the-loop, retry classification, and observability with copy-pasteable code.

antigravity404inngestai-workflow4durable-execution2typescript26background-jobs

Have you ever spent three hours generating something with an AI agent, only to watch the final API call throw an exception and torch the entire run? I have. The first time I built a "summarize 600 customer support emails with Gemini and post to Slack" job, I shrugged and reached for a Vercel Edge Function. About 480 emails in, the OpenAI rate limiter slapped me, and the function had no idea where it had been — so it started from email number one.

The single most painful moment in event-driven AI work isn't the failure itself. It's not knowing how far you got. This article walks through the exact stack I run in production now: Antigravity for editor-side authoring, Inngest as the durable execution layer, and Gemini for the model. I considered Temporal and building a 24-hour autonomous AI agent with Trigger.dev, and I'll explain why I landed where I did.

Why Inngest for AI workflows

Let me be honest first: there's no objectively right answer for job runtimes. Temporal is feature-rich and battle-tested at scale, but you pay for that with operational complexity. BullMQ is lightweight, but you write retry semantics and concurrency yourself. Trigger.dev is philosophically very close to Inngest and a strong alternative. The choice always reflects your team size, infra preferences, and operational appetite.

That said, three things keep pulling me back to Inngest for indie and small-to-mid scale work. First, the function-as-workflow model: the TypeScript function you write is the workflow, with each step automatically checkpointed and retryable. Second, the runtime fits Cloudflare Workers, Vercel, and Next.js Route Handlers with almost no setup ceremony. Third, the AI-shaped problems — idempotency keys to prevent duplicate charges, concurrency to dance around rate limits, waitForEvent for human approval — are one-liners.

If you want the conceptual foundation before diving into specifics, Antigravity x Durable Execution: design patterns for resilient long-running AI tasks covers the mental model in depth.

Bootstrapping the project from Antigravity

The stack I'll use here is Next.js 16 (App Router) plus Inngest plus the Gemini API. From inside Antigravity, I usually open a fresh terminal pane and ask the Manager Surface to run the scaffolding while I plan the directory structure in the editor.

# Run from Antigravity's terminal
npx create-next-app@latest ai-workflows --typescript --app --tailwind
cd ai-workflows
npm install inngest @google/genai zod
npm install -D @types/node tsx

@google/genai is the official Gemini TypeScript SDK; zod is what Inngest uses to derive type-safe event schemas. Once the scaffold is up, I reopen the project in Antigravity and ask the AI side panel to "create the Inngest client and a Next.js Route Handler under src/inngest/." It almost always produces something close to working — but I never paste it verbatim. Inngest renames APIs across versions occasionally, so cross-check against the current docs every time.

The minimum client looks like this:

// src/inngest/client.ts
import { Inngest } from "inngest";
import { z } from "zod";
 
// Defining schemas up front gives you typed event.data inside functions
const eventSchemas = {
  "support/inquiry.received": {
    data: z.object({
      inquiryId: z.string().uuid(),
      userId: z.string(),
      body: z.string().min(1).max(8000),
      locale: z.enum(["ja", "en"]).default("en"),
    }),
  },
  "support/reply.approved": {
    data: z.object({
      inquiryId: z.string().uuid(),
      approvedBy: z.string(),
    }),
  },
} as const;
 
export const inngest = new Inngest({
  id: "antigravity-support-bot",
  // In production, pass the signing key via environment variable
  schemas: eventSchemas,
});

Two habits worth picking up: name events as domain/action, and always lock the data shape with z.object. Skip the second one and you'll lose half an hour debugging "why is body undefined" in the Inngest dashboard. Ask me how I know.

Writing your first event-driven function

Let's build the smallest realistic example: receive a support inquiry, summarize it with Gemini, and post the summary to Slack. When asking Antigravity's AI to scaffold this, I explicitly request "testable shape with dependencies as parameters" — that gives me cleaner code I can later swap in unit tests.

// src/inngest/functions/handle-inquiry.ts
import { inngest } from "../client";
import { GoogleGenAI } from "@google/genai";
import { NonRetriableError } from "inngest";
 
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
 
export const handleInquiry = inngest.createFunction(
  {
    id: "handle-inquiry",
    // Coalesce rapid duplicate inquiries from the same user
    debounce: { period: "5s", key: "event.data.userId" },
    retries: 3,
  },
  { event: "support/inquiry.received" },
  async ({ event, step, logger }) => {
    const { inquiryId, body, locale } = event.data;
 
    // 1. Summarize with the model — checkpoint it, the call is expensive
    const summary = await step.run("summarize-with-gemini", async () => {
      const resp = await ai.models.generateContent({
        model: "gemini-2.5-flash",
        contents: `Summarize this support inquiry in ${
          locale === "ja" ? "Japanese" : "English"
        } in 80 characters or fewer:\n\n${body}`,
      });
      const text = resp.text?.trim();
      if (!text) {
        // Empty responses won't fix themselves on retry
        throw new NonRetriableError("Gemini returned empty response");
      }
      return text;
    });
 
    // 2. Save the draft (idempotent on inquiryId)
    await step.run("save-draft-reply", async () => {
      await fetch(`${process.env.APP_URL}/api/internal/save-draft`, {
        method: "POST",
        headers: { "content-type": "application/json", "x-idempotency-key": inquiryId },
        body: JSON.stringify({ inquiryId, summary }),
      });
    });
 
    // 3. Notify Slack
    await step.run("notify-slack", async () => {
      await fetch(process.env.SLACK_WEBHOOK_URL!, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          text: `📩 New inquiry summary: ${summary}\nApprove with /approve ${inquiryId}`,
        }),
      });
    });
 
    logger.info({ inquiryId }, "inquiry summarized and notified");
    return { inquiryId, summary };
  }
);

Three details matter here. First, every external side effect lives in its own step.run. If Slack rejects the webhook and we retry, we don't re-bill for a Gemini call we already paid for — the cached result is replayed instead. With Gemini pricing what it is, that single change pays for itself in week one. Second, debounce collapses bursts from the same user into one logical event. Third, NonRetriableError is your way of telling Inngest "this isn't a transient failure" — no point burning retries on broken contracts.

step.run and idempotency — the design that stops billing accidents

step.run semantics are the thing to internalize. Once a step succeeds, its output is persisted; later steps can fail and retry without re-running the successful one. That's exactly what you want around money.

A previous mistake of mine: I called Stripe directly inside a webhook handler that AI-generated content was triggering. Stripe retried the webhook, my handler had no idempotency, and I double-charged a customer. Routing through Inngest with step.run("charge-once", ...) and an idempotency key built from event.data.checkoutSessionId made that whole class of bug disappear.

The trick to designing idempotency keys is asking, "what makes this step logically identical?" "Bill user A for October" is uniquely keyed by userId + period. "Send email" needs a message ID, otherwise two distinct emails with the same body would dedupe incorrectly. Idempotent error handling pairs well with the patterns in building robust error handling with Antigravity and Effect-TS.

Concurrency and throttle to tame AI rate limits

Rate limits are where naive AI workflows fall apart. Gemini, OpenAI, and Anthropic all enforce RPM ceilings even on paid tiers. Inngest lets you compose concurrency and throttle declaratively, so you don't reinvent a queue in Redis.

Here's the canonical "generate monthly reports for everyone" job. Fire 5,000 events at once with no controls and your AI provider returns 429 in milliseconds. With these guards, the same job becomes trivial:

// src/inngest/functions/generate-monthly-report.ts
import { inngest } from "../client";
 
export const generateMonthlyReport = inngest.createFunction(
  {
    id: "generate-monthly-report",
    // Cap parallelism per tenant so noisy customers don't starve quiet ones
    concurrency: {
      limit: 8,
      key: "event.data.tenantId",
    },
    // 60 calls per minute per tenant (matches a typical Gemini quota)
    throttle: {
      limit: 60,
      period: "1m",
      key: "event.data.tenantId",
    },
    retries: 5,
  },
  { event: "report/generate.requested" },
  async ({ event, step }) => {
    const { userId, month } = event.data;
 
    const sections = await step.run("collect-data", async () => {
      return await collectUserActivity(userId, month);
    });
 
    const draft = await step.run("ai-write-report", async () => {
      return await composeReportWithGemini(sections);
    });
 
    await step.run("publish-report", async () => {
      await publishToUser(userId, draft);
    });
 
    return { userId, month, draftId: draft.id };
  }
);
 
// Implementations elided for brevity
async function collectUserActivity(userId: string, month: string) { /* ... */ }
async function composeReportWithGemini(sections: unknown) { /* ... */ }
async function publishToUser(userId: string, draft: unknown) { /* ... */ }

The concurrency.key set to a tenant ID means tenant A's burst can't choke tenant B's jobs — that whole "noisy neighbor" failure mode is gone in two lines. Try implementing the same thing with a self-hosted Redis queue and you'll spend a day on key design alone.

If your scale demands self-hosting and deeper introspection, running Antigravity on a Temporal-based workflow engine in production is the path I'd point you toward. My rough rule: Inngest below a few thousand active users, Temporal when you have a platform team that can run a Worker fleet.

step.waitForEvent for human-in-the-loop

Some workflows are too consequential for full automation. AI-drafted support replies, for instance, often want a human eyeball before they go out. Inngest's step.waitForEvent makes the "wait for approval" pattern read like normal code.

// src/inngest/functions/auto-reply-with-approval.ts
import { inngest } from "../client";
 
export const autoReplyWithApproval = inngest.createFunction(
  { id: "auto-reply-with-approval", retries: 2 },
  { event: "support/inquiry.received" },
  async ({ event, step }) => {
    const { inquiryId } = event.data;
 
    const draft = await step.run("generate-draft", async () => {
      return await generateDraftWithGemini(event.data.body);
    });
 
    // Wait up to 24 hours for an approval event matching this inquiry
    const approval = await step.waitForEvent("wait-for-approval", {
      event: "support/reply.approved",
      timeout: "24h",
      if: `event.data.inquiryId == "${inquiryId}"`,
    });
 
    if (!approval) {
      // Timed out — escalate to a human inbox
      await step.run("escalate", async () => {
        await sendEscalationEmail(inquiryId, draft);
      });
      return { status: "escalated", inquiryId };
    }
 
    await step.run("send-reply", async () => {
      await sendEmail({
        inquiryId,
        body: draft.body,
        approvedBy: approval.data.approvedBy,
      });
    });
 
    return { status: "sent", inquiryId, approvedBy: approval.data.approvedBy };
  }
);
 
async function generateDraftWithGemini(body: string) { /* ... */ return { body: "" }; }
async function sendEscalationEmail(inquiryId: string, draft: { body: string }) { /* ... */ }
async function sendEmail(args: { inquiryId: string; body: string; approvedBy: string }) { /* ... */ }

Notice there's no setTimeout, no polling loop, nothing keeping a Lambda warm for 24 hours. Inngest persists the wait and resumes the function the moment the approval event lands. Locally, I run inngest dev, then send approval events from a Slack /approve slash command stub — perfect for end-to-end testing.

Retry strategy and error classification

"Retry until it succeeds" sounds confident in a design doc and falls apart in production. There are three error classes worth distinguishing. First, transient errors that retries fix (network blips, 429s, transient 5xx). Second, permanent errors retries can't fix (400 Bad Request, validation failures, missing entitlements). Third, dangerous errors where retrying might cause data loss or double-billing.

Inngest exposes NonRetriableError for the second class and RetryAfterError to honor a server's explicit backoff request:

// src/inngest/functions/safe-charge.ts
import { inngest } from "../client";
import { NonRetriableError, RetryAfterError } from "inngest";
 
export const safeCharge = inngest.createFunction(
  { id: "safe-charge", retries: 4 },
  { event: "billing/charge.requested" },
  async ({ event, step }) => {
    await step.run("charge-stripe", async () => {
      const res = await fetch("https://api.stripe.com/v1/charges", {
        method: "POST",
        headers: {
          authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
          // Always pass an idempotency key
          "idempotency-key": event.data.checkoutSessionId,
        },
        body: new URLSearchParams({ amount: String(event.data.amount), currency: "usd" }),
      });
 
      if (res.status === 429) {
        const retryAfter = Number(res.headers.get("retry-after") ?? "10");
        throw new RetryAfterError("rate limited by Stripe", `${retryAfter}s`);
      }
      if (res.status === 400) {
        // 400 won't recover on retry — bail loudly
        throw new NonRetriableError(`Stripe rejected: ${await res.text()}`);
      }
      if (!res.ok) {
        // 5xx — let the default retry/backoff do its thing
        throw new Error(`Stripe error: ${res.status}`);
      }
      return await res.json();
    });
  }
);

The second argument to RetryAfterError accepts a duration string, so passing whatever the server returned in Retry-After is the easiest correct behavior. I leave exponential backoff to Inngest's defaults and only override when the upstream gives me a hint.

Observability — Dashboard versus OpenTelemetry

The Inngest Dashboard handles per-function success rates, latencies, and failure logs out of the box. It's genuinely good. But for cross-function tracing — "where did this user's inquiry go between receipt and reply?" — I add OpenTelemetry.

Concretely: I issue a traceId when an inquiry arrives and pass it through event.data on every internal event. Inside each step.run I open a span with @opentelemetry/api. That gives Datadog, Grafana, or Honeycomb a single timeline that spans receipt, AI summarization, approval wait, and outbound delivery.

For the wiring details, building an OpenTelemetry trace pipeline for Antigravity AI agents walks through the integration end to end. Pair it with this article and you'll have both the runtime and the observability layer covered.

Pitfalls I wish someone had told me about

Five lessons from running this in production. They're not in the docs because they're operational, not API.

First, don't pack long blocking work into a single step.run. Inngest enforces a step time limit, so "wait 60 seconds for an external API" should become "kick the API and wait for a callback event." That second shape composes; the first eventually times out.

Second, every value returned from step.run must be JSON-serializable. Returning a Date will quietly become a string after a replay, and your downstream comparison will silently break. My team's rule: return ISO strings or numbers, never raw Date objects.

Third, forgetting to start the Inngest dev server is the most common "why isn't anything happening" gotcha. Add npx inngest-cli@latest dev to your package.json scripts so it's one command. Antigravity's AI will sometimes guess wrong about the dev command across versions — verify against current docs.

Fourth, signing keys belong in your platform's secret store, never in .env.local committed to git. Without a signing key, anyone can trigger your functions with arbitrary events. This is the kind of mistake that becomes a public post-mortem.

Fifth, debounce and concurrency interact in non-obvious ways. debounce coalesces inputs; concurrency shapes execution. Using both together is fine, but verify on the dashboard that the order of operations matches your mental model. I learned this the hard way with a billing job that briefly looked like it was dropping invoices.

Wrap-up — your smallest first step

Thanks for reading this far. Inngest's documentation is unusually good, and Antigravity's AI assistant — once it has the right context — is excellent at converting a non-idempotent step into an idempotent one. But the design judgment still has to be yours. The AI will happily generate code that "runs" while being subtly unsafe at scale.

If I had to give you one concrete first step: pick the single most consequential side effect in your codebase that you're afraid to retry, and move it behind an Inngest function this week. AI-generated invoice delivery, batched newsletter sends, welcome flows on signup — anything where "what if this runs twice?" makes you nervous. Once you can replay a production failure from the dashboard with confidence, the operational anxiety of running AI in your product changes character entirely.

When you're ready to compare runtimes against another strong Durable Execution choice, read building a 24-hour autonomous AI agent with Trigger.dev next. Same problem space, different design center, and the contrast sharpens your judgment about which tool fits your scenario.

A closer look at the alternatives

I want to give the trade-offs more space, because the choice of job runtime sets a ceiling on what you can build over the next year of your product.

Temporal is a remarkable system. If you've worked at a company that ran Cadence or Temporal at scale, you already know the appeal: a real saga model, signal/query semantics, full versioning of in-flight workflows, deterministic replay for debugging, and the ability to model multi-month business processes without breaking a sweat. The catch is that you also bring along a Worker fleet, a Postgres or Cassandra cluster (usually managed by Temporal Cloud or your platform team), and the operational discipline that comes with all of it. For an indie developer or a four-person team, that overhead crowds out the work you actually wanted to do.

BullMQ is the other end of the spectrum. It's a Redis-backed queue with workers that you run yourself. The library is excellent, the throughput is fine, and you can ship something in an afternoon. The expensive part is the parts BullMQ doesn't have: cross-step idempotency, durable wait-for-event, declarative concurrency keyed on tenant ID, observability for branched workflows. Each one of those is a project. Each one is a project where the bug surface tends to bite you in production at the worst time.

Trigger.dev sits philosophically next to Inngest. Same Function-as-Workflow shape, similar developer experience, both have managed and self-hostable options. I've shipped real workflows on both and would happily recommend either depending on team taste and which feature roadmap items matter to you. If your needs revolve around long-running tasks with rich UI for monitoring, Trigger.dev for autonomous 24-hour AI agents is the comparison piece worth reading carefully. The point isn't that one wins — it's that "we already use this kind of runtime" is a much better answer than "we wrote a queue ourselves."

Testing event-driven functions locally

A frequent question I get is "how do I unit-test these things?" The pattern that works best for me is to keep step.run callbacks small enough that they're testable as plain functions, and to mock the Inngest runtime only for integration tests.

// src/inngest/functions/__tests__/handle-inquiry.test.ts
import { describe, it, expect, vi } from "vitest";
import { handleInquiry } from "../handle-inquiry";
 
// Inngest exposes a step mock for unit testing
function createStepMock() {
  return {
    run: vi.fn((_id: string, fn: () => Promise<unknown>) => fn()),
    waitForEvent: vi.fn(),
  };
}
 
describe("handleInquiry", () => {
  it("summarizes and notifies for a valid inquiry", async () => {
    const step = createStepMock();
    const event = {
      data: {
        inquiryId: "00000000-0000-0000-0000-000000000001",
        userId: "user_1",
        body: "App keeps crashing on launch",
        locale: "en" as const,
      },
    };
    const logger = { info: vi.fn() } as unknown as Console;
 
    // The function expects an Inngest context — pass our mocks
    const result = await (handleInquiry as unknown as {
      fn: (ctx: unknown) => Promise<{ inquiryId: string; summary: string }>;
    }).fn({ event, step, logger });
 
    expect(result.inquiryId).toBe(event.data.inquiryId);
    expect(typeof result.summary).toBe("string");
    expect(step.run).toHaveBeenCalledWith("summarize-with-gemini", expect.any(Function));
    expect(step.run).toHaveBeenCalledWith("save-draft-reply", expect.any(Function));
    expect(step.run).toHaveBeenCalledWith("notify-slack", expect.any(Function));
  });
});

The shape of this test isn't beautiful — Inngest's TypeScript ergonomics around extracting the inner handler still require a small cast — but it's honest. You're verifying the orchestration: "the function called the right steps, in the right order, with the right ids." Pair it with a separate suite of unit tests for the actual side-effect functions (generateDraftWithGemini, sendEmail, etc.) and you've got coverage that matches the architecture.

For end-to-end testing I rely on inngest dev plus a script that emits real events with inngest.send(). Antigravity's AI is excellent at writing those scripts when you tell it "emit five inquiries with mixed locales and assert the resulting summaries appear in Slack." The thing the AI will not do well, in my experience, is design the test fixtures themselves. That's still your job.

Designing event names so future-you can read them

A tiny detail that pays back forever: name events as domain/action.tense. support/inquiry.received, billing/charge.requested, report/generation.completed. The verb tense matters. requested says "someone asked us to do this and we haven't yet." completed says "we did it." Mix them up and your dashboard turns into a soup of nouns where nobody can tell what's a command and what's a notification.

Once you have ten functions, you'll wish you'd standardized this earlier. I keep an EVENTS.md in every repo that lists each event, its schema, who emits it, and who consumes it. The list never lies — when it's wrong, the system is wrong. Antigravity is great at scanning the codebase and producing the first version of this document; I just clean it up.

When not to reach for Inngest

It's worth being explicit about cases where I wouldn't bother with Inngest. If your AI workload is a single synchronous request that returns in under a few seconds — a chat reply, an inline completion — Inngest is overkill. Just call the model from your route handler. The complexity of an event-driven runtime only pays off when failures are expensive, retries need careful semantics, or you have multi-step orchestration.

Similarly, if you're moving large binary data — say, multi-gigabyte video assets — through your workflow, you don't want to put bytes in events. Use object storage as the source of truth and pass references in events. Inngest events are JSON, and the runtime is happiest with payloads in the kilobyte range.

The third "don't" case: real-time, low-latency interactive experiences. If a user is staring at a loading spinner waiting for output, the event-driven path adds milliseconds you don't have. For live AI chat, run inference in the request path and reserve Inngest for the durable side jobs (saving conversations, generating titles, computing recommendations).

What an end-to-end production run feels like

To make all of this concrete, here's the actual sequence I see in our dashboard for a typical support inquiry on a busy day. A customer submits a ticket through our web form. The Next.js Route Handler validates the input, persists the inquiry to Postgres, and calls inngest.send("support/inquiry.received", { ... }). The handler returns to the user in roughly 80 milliseconds. The user sees their confirmation immediately.

A few hundred milliseconds later, handleInquiry picks up the event. It runs the Gemini summary, persists the draft, posts the summary to Slack with an approve link. A teammate clicks approve in Slack — that triggers a small Next.js endpoint that calls inngest.send("support/reply.approved", { inquiryId, approvedBy }). autoReplyWithApproval, which has been blocked on waitForEvent since the inquiry arrived, wakes up, sends the reply email, and writes a final status event.

If anywhere in that pipeline a step fails — Slack outage, Gemini quota burst, email provider blip — Inngest retries the failed step alone, replays the cached results from earlier successful steps, and the user gets their reply with no human intervention. We added pingdom-style external monitoring in the first month and have not had to use it. The dashboard tells us first.

Closing thought

The biggest mindset shift Inngest forced on me wasn't technical. It was learning to think about my AI code in two timeframes: the synchronous part the user sees, which has to be fast and correct, and the durable background, which has to be resumable and correct. Once you internalize that split, you stop writing 600-line Edge Functions that do everything inline and start designing systems that gracefully survive their own failures.

Antigravity makes the editor side of that work easier — its agents are remarkably good at refactoring "do everything in one place" code into clean step-by-step functions. Inngest makes the runtime side reliable. Together they give a small team the kind of operational guarantees that used to require a platform team. Take one risky workflow this week and move it behind a function. The first time you replay a production failure with one button click, you'll wonder how you ever shipped without it.

A note on cost

One question I get from readers thinking about adopting durable execution: "won't this make my infrastructure bill explode?" In my experience, no — and often the opposite. The Gemini calls I avoid by not retrying successful steps add up fast. On a workflow that fans out to thousands of users, even a 5% transient failure rate without Inngest means 5% of every step downstream gets re-executed. With per-step caching, only the failing step retries. The math has favored Inngest for every workload I've moved over so far.

If you're cost-sensitive enough that this matters, instrument each step with the model token count and the wall time, and ship those metrics into your existing analytics pipeline. After a week of data you'll know exactly which step is your dominant cost and where to invest in caching, smaller models, or prompt tightening. That's a much better conversation to have than "AI is expensive" in the abstract.

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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-05-03
Building Idempotency Keys and Dedupe Stores in TypeScript with Antigravity
A production guide to designing idempotency keys and dedupe stores in TypeScript with Antigravity — covering Stripe webhook retries, Temporal replays, and the Cloudflare KV / Redis / Postgres trade-offs you actually need to choose between.
App Dev2026-05-01
Drawing the Server/Client Boundary with Antigravity — A Workflow That Stops Next.js 16 RSC From Tripping You Up
Drawing the line between Server and Client Components is still the trickiest part of Next.js 16. Here's the practical workflow I use with Antigravity to stop misplaced `use client` directives and serialization errors before they happen.
App Dev2026-04-30
Antigravity × Better Auth: Building a Modern TypeScript Auth Stack End-to-End
End the NextAuth fatigue with Better Auth. A practical Antigravity-driven guide that ships schema generation, OAuth, RBAC, and Passkeys with production-ready patterns.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →