AIエージェントを使ったプロダクトを作りたいと思っている個人開発者は多いですが、「チュートリアルは完走したけど、お金が取れるものができない」という壁にぶつかる方が非常に多くいます。
AgentKit 2.0 の公式ドキュメントは API リファレンスとして優秀ですが、「どこに課金ロジックを入れるのか」「プランによってエージェントの動作をどう変えるのか」「Webhook が重複したときどうするのか」といった、実際にサービスを運営するうえで避けられない問題には答えていません。
私が実際に構築・運用している AI エージェント SaaS のアーキテクチャをベースに、Antigravity × AgentKit 2.0 × Stripe の組み合わせで個人開発者が本番運用まで到達するための実装を順を追って整理していきます。
なぜ AgentKit 2.0 × Stripe なのか
AgentKit 1.x との決定的な違い
AgentKit 1.x でサービスを構築したことがある方は、「タスク完了のコールバックが取りにくい」「並列エージェントのセッション管理が複雑」という経験をしているかもしれません。
AgentKit 2.0 はこの問題を根本から解決しています。最も重要な変更点は、エージェントのライフサイクルが Observable になったことです。
// AgentKit 1.x — 完了を知るためにポーリングが必要だった
const task = await agentkit.run({ prompt, tools });
// ... 自前でポーリング実装が必要
// AgentKit 2.0 — Observable で状態変化を受け取れる
import { AgentKit, AgentEvent } from "@google/agentkit";
const agent = new AgentKit({ model: "gemma-4-27b-it" });
const stream = agent.stream({
prompt: "ユーザーの質問に回答し、必要な分析を行ってください",
tools: [searchTool, analysisTool],
context: { userId, planType },
});
for await (const event of stream) {
if (event.type === AgentEvent.TOOL_CALL) {
// ツール呼び出しのたびに使用量をインクリメント
await incrementUsage(userId, event.toolName);
}
if (event.type === AgentEvent.COMPLETE) {
await finalizeSession(userId, event.output);
}
}この Observable 型のストリームが、SaaS における使用量課金の精密な計測を可能にします。
Stripe との組み合わせが強い理由
Stripe には usage-based billing(従量課金)の API があり、AgentKit 2.0 の Observable ライフサイクルと組み合わせることで、エージェントのツール呼び出し1回ごとに課金する SaaS が数百行のコードで実現できます。
これが「AIエージェントを使ったサービス」を作る最短ルートです。
アーキテクチャの全体像
まず構築するシステムの全体像を示します。
[ユーザー]
↓ API リクエスト(JWT)
[Next.js API Route / rate limiter]
↓ プラン確認
[Stripe Customer Portal / KV キャッシュ]
↓ プラン内なら実行
[AgentKit 2.0 Session Manager]
↓ エージェント起動
[Agent Workers × n(プランで並列数制限)]
↓ ツール呼び出しのたびに
[Usage Tracker → Stripe Usage API]
↓ 月次集計
[Stripe Invoice → ユーザーへ請求]
プランの設計は以下のように3段階にしています。
- Free: エージェント呼び出し 20回/日、並列1、ツール呼び出し上限50回/月
- Pro(¥1,980/月): 200回/日、並列3、ツール呼び出し上限500回/月
- Business(¥9,800/月): 無制限、並列10、優先キュー
「なぜ並列エージェント数で差をつけるのか」という疑問を持つ方もいるかもしれません。理由は単純で、コンピューティングコストが並列数に比例するからです。Free プランで並列10を許可すると、悪意はなくても大量のリクエストで計算資源を枯渇させるユーザーが必ず出てきます。
Antigravity でのプロジェクトセットアップ
Antigravity を起動し、以下をチャット入力します。
Next.js 15 (App Router) + TypeScript のプロジェクトを作成してください。
以下の構成が必要です:
- src/lib/agentkit/ — AgentKit 2.0 のセッション管理
- src/lib/stripe/ — Stripe 課金ロジック
- src/lib/usage/ — 使用量トラッキング
- src/app/api/agent/ — エージェント実行 API
- src/app/api/webhook/ — Stripe Webhook
環境変数は .env.local に AGENTKIT_API_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET を用意します。
Antigravity がスキャフォールドを作ったら、依存関係を追加します。
npm install @google/agentkit stripe @upstash/redisUpstash Redis は使用量のリアルタイムトラッキングとレート制限に使います。Vercel KV でも代替可能ですが、Upstash の方が INCR + EXPIREAT のアトミック操作が使いやすく、並列リクエストでのカウンタ競合が起きません。
プラン管理と課金ロジックの実装
Stripe Customer の作成とプラン紐付け
// src/lib/stripe/customer.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY\!, {
apiVersion: "2025-01-01",
});
export const PLAN_CONFIG = {
free: {
dailyCallLimit: 20,
maxParallelAgents: 1,
monthlyToolCallLimit: 50,
stripePriceId: null, // 無料プランは Stripe 不要
},
pro: {
dailyCallLimit: 200,
maxParallelAgents: 3,
monthlyToolCallLimit: 500,
stripePriceId: process.env.STRIPE_PRO_PRICE_ID\!,
},
business: {
dailyCallLimit: Infinity,
maxParallelAgents: 10,
monthlyToolCallLimit: Infinity,
stripePriceId: process.env.STRIPE_BUSINESS_PRICE_ID\!,
},
} as const;
export type PlanType = keyof typeof PLAN_CONFIG;
export async function createStripeCustomer(
userId: string,
email: string
): Promise<string> {
const customer = await stripe.customers.create({
email,
metadata: {
userId,
platform: "your-saas-name",
},
});
return customer.id;
}
export async function getActivePlan(
stripeCustomerId: string
): Promise<PlanType> {
try {
const subscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
status: "active",
limit: 1,
});
if (subscriptions.data.length === 0) return "free";
const priceId = subscriptions.data[0].items.data[0].price.id;
if (priceId === PLAN_CONFIG.business.stripePriceId) return "business";
if (priceId === PLAN_CONFIG.pro.stripePriceId) return "pro";
return "free";
} catch (error) {
// エラー時は最も制限の強い free にフォールバック(deny by default)
console.error("Plan fetch error, defaulting to free:", error);
return "free";
}
}getActivePlan でエラーが発生した場合に free にフォールバックしている点が重要です。これは「deny by default」の原則で、課金状態の確認に失敗した場合はアクセスを制限する方向に倒します。逆にすると(エラー時に full access を与えると)、意図的なエラー注入で無制限アクセスを得られる脆弱性になります。
使用量のリアルタイムトラッキング
// src/lib/usage/tracker.ts
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
interface UsageCheckResult {
allowed: boolean;
reason?: string;
remaining: {
dailyCalls: number;
monthlyToolCalls: number;
};
}
export async function checkAndIncrementUsage(
userId: string,
planType: PlanType
): Promise<UsageCheckResult> {
const config = PLAN_CONFIG[planType];
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
const month = today.slice(0, 7); // YYYY-MM
const dailyKey = `usage:daily:${userId}:${today}`;
const monthlyKey = `usage:monthly:${userId}:${month}`;
// アトミックにカウントアップしてから制限チェック
const pipeline = redis.pipeline();
pipeline.incr(dailyKey);
pipeline.incr(monthlyKey);
// 日次は翌日0時に失効
pipeline.expireat(dailyKey, getNextMidnightTimestamp());
// 月次は月末に失効
pipeline.expireat(monthlyKey, getEndOfMonthTimestamp());
const results = await pipeline.exec() as [number, number, number, number];
const [newDailyCount, newMonthlyToolCount] = results;
// 制限チェック(インクリメント後なので、超えていたらデクリメント)
if (
config.dailyCallLimit \!== Infinity &&
newDailyCount > config.dailyCallLimit
) {
await redis.decr(dailyKey);
return {
allowed: false,
reason: `1日のエージェント呼び出し上限(${config.dailyCallLimit}回)に達しました`,
remaining: { dailyCalls: 0, monthlyToolCalls: config.monthlyToolCallLimit - newMonthlyToolCount + 1 },
};
}
return {
allowed: true,
remaining: {
dailyCalls:
config.dailyCallLimit === Infinity
? Infinity
: config.dailyCallLimit - newDailyCount,
monthlyToolCalls:
config.monthlyToolCallLimit === Infinity
? Infinity
: config.monthlyToolCallLimit - newMonthlyToolCount,
},
};
}
function getNextMidnightTimestamp(): number {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return Math.floor(tomorrow.getTime() / 1000);
}
function getEndOfMonthTimestamp(): number {
const now = new Date();
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
return Math.floor(endOfMonth.getTime() / 1000);
}エージェント実行 API の実装
// src/app/api/agent/run/route.ts
import { NextRequest } from "next/server";
import { AgentKit, AgentEvent } from "@google/agentkit";
import { getActivePlan, PLAN_CONFIG } from "@/lib/stripe/customer";
import { checkAndIncrementUsage } from "@/lib/usage/tracker";
import { verifyAuthToken } from "@/lib/auth";
export async function POST(req: NextRequest) {
// 1. 認証チェック
const authResult = await verifyAuthToken(req);
if (\!authResult.ok) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId, stripeCustomerId } = authResult;
// 2. プラン確認(KV キャッシュ 60秒 で Stripe API の過剰呼び出しを防ぐ)
const planType = await getActivePlan(stripeCustomerId);
const planConfig = PLAN_CONFIG[planType];
// 3. 使用量チェック & インクリメント
const usageResult = await checkAndIncrementUsage(userId, planType);
if (\!usageResult.allowed) {
return Response.json(
{
error: usageResult.reason,
remaining: usageResult.remaining,
upgradeUrl: "/pricing",
},
{ status: 429 }
);
}
// 4. リクエストボディ取得
const { prompt, tools: requestedTools } = await req.json();
// 5. AgentKit 2.0 でエージェント実行(Streaming Response)
const encoder = new TextEncoder();
const stream = new TransformStream();
const writer = stream.writable.getWriter();
// 非同期でエージェントを実行
(async () => {
try {
const agent = new AgentKit({
model: planType === "business" ? "gemma-4-27b-it" : "gemma-4-9b-it",
maxParallelTools: planConfig.maxParallelAgents,
});
let toolCallCount = 0;
for await (const event of agent.stream({
prompt,
tools: requestedTools,
context: { userId, planType },
})) {
// ツール呼び出しのたびに使用量を記録
if (event.type === AgentEvent.TOOL_CALL) {
toolCallCount++;
// Business プラン以外は月次ツール呼び出し上限チェック
if (
planConfig.monthlyToolCallLimit \!== Infinity &&
toolCallCount > planConfig.monthlyToolCallLimit
) {
await writer.write(
encoder.encode(
`data: ${JSON.stringify({
type: "error",
message: "月次ツール呼び出し上限に達しました",
})}\n\n`
)
);
break;
}
}
// SSE 形式でクライアントにストリーミング
if (event.type === AgentEvent.TEXT_CHUNK) {
await writer.write(
encoder.encode(`data: ${JSON.stringify({ type: "chunk", text: event.text })}\n\n`)
);
}
if (event.type === AgentEvent.COMPLETE) {
await writer.write(
encoder.encode(
`data: ${JSON.stringify({
type: "complete",
output: event.output,
usage: { toolCalls: toolCallCount },
})}\n\n`
)
);
}
}
} catch (error) {
const message =
error instanceof Error ? error.message : "エージェント実行エラー";
await writer.write(
encoder.encode(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
);
} finally {
await writer.close();
}
})();
return new Response(stream.readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}Stripe Webhook の実装
Webhook の実装は SaaS 開発者が最も見落としがちな部分です。特に冪等性の確保が重要で、Stripe はネットワーク障害時に同じ Webhook イベントを複数回送信することがあります。
// src/app/api/webhook/route.ts
import Stripe from "stripe";
import { Redis } from "@upstash/redis";
import { updateUserPlan } from "@/lib/db/users";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY\!, {
apiVersion: "2025-01-01",
});
const redis = Redis.fromEnv();
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")\!;
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET\!,
undefined,
Stripe.createSubtleCryptoProvider()
);
} catch (err) {
return Response.json({ error: "Webhook signature verification failed" }, { status: 400 });
}
// 冪等性チェック: 同じイベントを重複処理しない
const idempotencyKey = `webhook:processed:${event.id}`;
const alreadyProcessed = await redis.get(idempotencyKey);
if (alreadyProcessed) {
// 既に処理済みでも 200 を返す(Stripe にリトライさせない)
return Response.json({ received: true, skipped: true });
}
// 処理開始前にロックを取得(TTL 60秒)
await redis.setex(idempotencyKey, 60, "processing");
try {
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
const priceId = subscription.items.data[0].price.id;
const status = subscription.status;
let planType: string = "free";
if (status === "active" || status === "trialing") {
if (priceId === process.env.STRIPE_PRO_PRICE_ID) planType = "pro";
if (priceId === process.env.STRIPE_BUSINESS_PRICE_ID) planType = "business";
}
await updateUserPlan(customerId, planType);
// プランキャッシュをクリア(次回リクエスト時に最新を取得)
await redis.del(`plan:cache:${customerId}`);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
await updateUserPlan(customerId, "free");
await redis.del(`plan:cache:${customerId}`);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
// 決済失敗時は即座にダウングレードせず、Stripe の猶予期間(デフォルト3日)を尊重
// subscription.status が "past_due" になった時点でダウングレード
console.warn(`Payment failed for customer: ${customerId}`);
break;
}
}
// 処理完了後にロックを更新(TTL を 24時間に延長して重複防止)
await redis.setex(idempotencyKey, 86400, "done");
} catch (error) {
// 処理失敗時はロックを削除(Stripe にリトライさせる)
await redis.del(idempotencyKey);
throw error;
}
return Response.json({ received: true });
}constructEventAsync + Stripe.createSubtleCryptoProvider() の組み合わせは Cloudflare Workers や Edge Runtime で必須です。同期版の constructEvent は Node.js 環境でしか動きません。
よくある落とし穴と解決策
落とし穴 1: エージェントのタイムアウト
デフォルトの Next.js API Route はタイムアウトが15秒です。AgentKit でツールを複数呼び出すエージェントは簡単にこれを超えます。
// route.ts の設定(Edge Runtime では maxDuration 非対応に注意)
export const maxDuration = 300; // 5分(Vercel Pro 以上が必要)
export const runtime = "nodejs"; // Edge Runtime はタイムアウト延長不可Vercel Free プランを使っている場合は60秒が上限です。長時間タスクはバックグラウンドジョブ化(Trigger.dev や QStash を利用)して、フロントエンドにはタスク ID を返してポーリングさせる設計に切り替えてください。
落とし穴 2: AgentKit のレート制限
Gemma 4 API にはリクエストレート制限があり、複数ユーザーが同時にエージェントを起動するとすぐに 429 Too Many Requests が発生します。
// src/lib/agentkit/rate-limiter.ts
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
export async function acquireAgentSlot(
userId: string,
maxParallel: number
): Promise<{ acquired: boolean; queuePosition?: number }> {
const activeKey = `agent:active:${userId}`;
const currentActive = await redis.incr(activeKey);
await redis.expire(activeKey, 300); // 5分で自動クリア(クラッシュ対策)
if (currentActive > maxParallel) {
await redis.decr(activeKey);
return { acquired: false, queuePosition: currentActive - maxParallel };
}
return { acquired: true };
}
export async function releaseAgentSlot(userId: string): Promise<void> {
const activeKey = `agent:active:${userId}`;
const current = await redis.get<number>(activeKey);
if (current && current > 0) {
await redis.decr(activeKey);
}
}エージェント実行の前後でこのスロット管理を呼び出し、finally ブロックで必ず releaseAgentSlot を呼ぶ点が肝心です。エラーが発生してもスロットを返却しないと、ユーザーのスロットが永久に埋まった状態になります。
落とし穴 3: Stripe の Price ID の管理
ローカル開発環境と本番環境で Stripe の Price ID が異なるのは当然ですが、テスト環境で作成した Price ID を本番の環境変数に混ぜてしまうと、テスト決済が本番に通ることがあります(Stripe のサンドボックスと本番は別環境なので実際には失敗しますが、エラーが分かりにくい)。
# .env.local(開発環境)
STRIPE_PRO_PRICE_ID=price_test_1234567890
STRIPE_BUSINESS_PRICE_ID=price_test_0987654321
# .env.production(本番環境 — Vercel の環境変数で管理)
STRIPE_PRO_PRICE_ID=price_live_abcdefghij
STRIPE_BUSINESS_PRICE_ID=price_live_jihgfedcba環境変数名に _TEST_ / _LIVE_ を入れてチームで区別するか、STRIPE_SECRET_KEY のプレフィックス(sk_test_ vs sk_live_)を起動時に検証するコードを入れておくと事故を防げます。
// src/lib/stripe/validate.ts(起動時チェック)
if (process.env.NODE_ENV === "production") {
const key = process.env.STRIPE_SECRET_KEY\!;
if (key.startsWith("sk_test_")) {
throw new Error("本番環境でテスト用 Stripe キーが使用されています");
}
}本番デプロイと監視
Vercel へのデプロイ
Antigravity のチャットで以下を実行します。
現在のプロジェクトを Vercel にデプロイする設定を追加してください。
必要な環境変数は:
- AGENTKIT_API_KEY
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
- STRIPE_PRO_PRICE_ID
- STRIPE_BUSINESS_PRICE_ID
- UPSTASH_REDIS_REST_URL
- UPSTASH_REDIS_REST_TOKEN
vercel.json と .vercelignore も作成してください。
Stripe Webhook のエンドポイント登録
デプロイ後、Stripe ダッシュボード → Webhooks → エンドポイント追加 で https://your-domain.com/api/webhook を登録します。
リッスンするイベントは最小限にします。
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failed
必要以上のイベントを登録すると、Webhook ハンドラへの無駄なリクエストが増え、冪等性チェックの Redis 呼び出しも増加します。
稼働率の監視
個人開発者にとっては Uptime Robot(無料プランあり)で十分です。/api/health エンドポイントを作り、Redis と Stripe への疎通確認を返すようにします。
// src/app/api/health/route.ts
import { Redis } from "@upstash/redis";
export async function GET() {
const checks = {
redis: false,
stripe: false,
};
try {
const redis = Redis.fromEnv();
await redis.ping();
checks.redis = true;
} catch {}
try {
// Stripe への最小限のリクエスト
await fetch("https://api.stripe.com/v1/prices?limit=1", {
headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
});
checks.stripe = true;
} catch {}
const healthy = Object.values(checks).every(Boolean);
return Response.json(checks, { status: healthy ? 200 : 503 });
}プライシング戦略の考え方
技術的な実装が完成したら、価格設定を考える必要があります。個人開発者が最初に陥るミスは「安くしすぎる」ことです。
月額 ¥300 のサービスで月収 10万円を得るには 334 人の有料ユーザーが必要です。しかし ¥1,980 なら 51 人で到達できます。ユーザー数が少ない段階では、価格を上げてサポートコストを下げる方が現実的です。
AgentKit を使った AI エージェントサービスは、以下の理由で高単価設定がしやすいカテゴリです。
- 代替手段(人間がやる場合)の工数と比較すると明確に安い
- アウトプットが定量的に評価できる(分析結果、生成物)
- API コストが比較的高いため、安価すぎると赤字になりやすい(逆説的に価格設定の根拠になる)
参考として、私のサービスでは Pro プランの ARPU(ユーザー当たり平均収益)が月 ¥2,300 程度で、API コストの粗利率は約 65% です。
全体を振り返って
Antigravity × AgentKit 2.0 × Stripe の組み合わせは、個人開発者が単独で「動くだけ」から「お金が取れる」レベルまで到達するための、現時点で最も実践的なスタックだと感じています。
特に重要なポイントは以下の3つです。
- 使用量トラッキングは Redis でアトミックに: Upstash +
pipeline()で競合なしのカウンタを実装する - Webhook は冪等性ファースト: 同じイベントを2回処理しても問題ない設計にする
- deny by default: プラン確認に失敗したら制限側にフォールバックする
チュートリアルを読んで「わかった気がするけど実装が進まない」という状態から抜け出すには、まず今日中に /api/agent/run エンドポイントを1つ動かしてみることです。完璧な設計を目指すより、動くものを早く持つ方が、次の問題(ユーザー獲得・価格設定・UX)に早く移れます。