前編のAntigravityで個人SaaSを立ち上げる収益化ロードマップで書いた通り、エージェント駆動のSaaSは「時間節約価値」を販売する設計が肝です。本記事はその実装編で、Antigravityのエージェント機能・Stripe決済・Cloudflare Workers を組み合わせた月額課金SaaSを構築する完全な手順をまとめます。
私が運営するAntigravity Labのコンテンツ自動更新システムも、ここで紹介するパターンの簡易版で動いています。本番でWebhookが失敗したとき・エージェントタスクがタイムアウトしたとき・並列実行が暴走したときの対処を、実コード付きで書き残します。
コードはNext.js 16 (App Router) + Cloudflare Workers (OpenNext) + Stripe v15 + Anthropic SDK + Cloudflare Queues を前提にしています。エージェント実行は時間がかかるため、HTTP同期処理ではなくキューベースの非同期構成を取るのがエージェント型SaaSの定石です。
全体アーキテクチャ
最終構成は次の通りです。
- 認証: NextAuth.js
- 決済: Stripe Checkout(月額サブスクと単発購入両対応)
- アクセス制御: Stripe Webhook → Cloudflare KV
- タスクキュー: Cloudflare Queues(エージェント実行を非同期化)
- エージェント実行層: Cloudflare Workers + Anthropic SDK
- 状態管理: D1(タスク履歴・結果保存)
エージェント型SaaSのキモは、HTTPリクエストとエージェント実行を分離することです。ユーザーがフォームを送信したら、即座に「タスクID」を返してジョブをキューに積み、別Workerがバックグラウンドで実行する構成にします。これにより、エージェント処理が長時間化してもユーザー体験は損なわれません。
ステップ1: Stripe側のプロダクト設計
エージェント型SaaSは「月額固定 + タスク数上限」が基本モデルです。
- Pro月額(JPY): 商品名「Pro Agent Plan」、価格 ¥1,500/月、月10タスクまで
- Pro月額(USD): 商品名「Pro Agent Plan」、価格 $10/月、月10タスクまで
- タスク追加(JPY/USD): 価格 ¥300 / $2、1タスク分のクレジット
Stripe Dashboardで作成した price_id を wrangler.toml に登録します。
ステップ2: Checkoutセッション生成API
Claude × Stripeガイドと同じパターンですが、エージェント型では metadata にタスク数情報も入れます。
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
export const runtime = 'edge';
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const { plan, locale } = await req.json();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-10-31',
});
const isJa = locale === 'ja';
const isPro = plan === 'pro';
const params: Stripe.Checkout.SessionCreateParams = {
mode: isPro ? 'subscription' : 'payment',
success_url: `${process.env.SITE_URL}/${locale}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/${locale}/membership`,
customer_email: session.user.email,
line_items: [{
price_data: {
currency: isJa ? 'jpy' : 'usd',
recurring: isPro ? { interval: 'month' } : undefined,
unit_amount: isPro ? (isJa ? 1500 : 1000) : (isJa ? 300 : 200),
product_data: {
name: isPro
? (isJa ? 'Pro Agent Plan(月額)' : 'Pro Agent Plan (Monthly)')
: (isJa ? 'タスク追加クレジット' : 'Additional Task Credit'),
description: isPro
? (isJa ? '月10タスクのエージェント実行が可能' : '10 agent task executions per month')
: (isJa ? '1タスク分の追加クレジット' : 'One additional task credit'),
images: [`${process.env.SITE_URL}/images/stripe-product.png`],
},
},
quantity: 1,
}],
metadata: {
plan_type: plan,
task_credits: isPro ? '10' : '1',
user_email: session.user.email,
},
};
const checkoutSession = await stripe.checkout.sessions.create(params);
return NextResponse.json({ url: checkoutSession.url });
}metadata.task_credits はWebhookでKVに反映する際に使います。Pro契約者には毎月10タスクのクレジットを付与し、追加クレジットを購入したらその分を加算する設計です。
ステップ3: Stripe Webhookでタスククレジット管理
// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export const runtime = 'edge';
export async function POST(req: NextRequest) {
const sig = req.headers.get('stripe-signature');
const body = await req.text();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-10-31',
});
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body, sig!, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (e) {
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
const { env } = getCloudflareContext();
const kv = env.MEMBERSHIP_KV;
// 冪等性
const eventKey = `site:antigravity:webhook:${event.id}`;
if (await kv.get(eventKey)) {
return NextResponse.json({ received: true, duplicate: true });
}
await kv.put(eventKey, '1', { expirationTtl: 86400 * 7 });
switch (event.type) {
case 'checkout.session.completed': {
const s = event.data.object as Stripe.Checkout.Session;
const planType = s.metadata?.plan_type;
const email = s.customer_email ?? s.metadata?.user_email;
const taskCredits = parseInt(s.metadata?.task_credits ?? '0', 10);
if (!email) break;
if (planType === 'pro') {
const subscription = await stripe.subscriptions.retrieve(s.subscription as string);
await kv.put(
`site:antigravity:member:${email}`,
JSON.stringify({
plan: 'pro',
current_period_end: subscription.current_period_end,
subscription_id: subscription.id,
}),
{ expirationTtl: subscription.current_period_end - Math.floor(Date.now() / 1000) + 86400 * 3 }
);
// 月初のタスククレジットを付与
await addTaskCredits(kv, email, 10);
} else if (planType === 'credits') {
// 追加クレジット購入
await addTaskCredits(kv, email, taskCredits);
}
break;
}
case 'invoice.payment_succeeded': {
// 月次更新時にタスククレジットを再付与
const invoice = event.data.object as Stripe.Invoice;
if (invoice.billing_reason === 'subscription_cycle') {
const customer = await stripe.customers.retrieve(invoice.customer as string);
if ('email' in customer && customer.email) {
await addTaskCredits(kv, customer.email, 10);
}
}
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(sub.customer as string);
if ('email' in customer && customer.email) {
await kv.delete(`site:antigravity:member:${customer.email}`);
}
break;
}
}
return NextResponse.json({ received: true });
}
async function addTaskCredits(kv: any, email: string, amount: number) {
const key = `site:antigravity:credits:${email}`;
const current = parseInt((await kv.get(key)) ?? '0', 10);
await kv.put(key, String(current + amount), { expirationTtl: 86400 * 365 });
}invoice.payment_succeeded(billing_reason=subscription_cycle)で月次更新時にクレジットを再付与する点がエージェント型SaaSの特徴的な設計です。これにより、サブスク継続中は毎月10タスクが自動補充されます。
ステップ4: タスク投入APIとキュー処理
ユーザーがタスク実行を要求したら、即座にキューに積んで非同期処理にします。
// app/api/tasks/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export const runtime = 'edge';
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const email = session.user.email;
const { env } = getCloudflareContext();
// クレジット残高確認
const credits = parseInt(
(await env.MEMBERSHIP_KV.get(`site:antigravity:credits:${email}`)) ?? '0',
10
);
if (credits < 1) {
return NextResponse.json(
{ error: 'no_credits', message: 'タスククレジットが不足しています' },
{ status: 402 }
);
}
// 同時実行数チェック(最大3並列)
const runningKey = `site:antigravity:running:${email}`;
const running = parseInt((await env.MEMBERSHIP_KV.get(runningKey)) ?? '0', 10);
if (running >= 3) {
return NextResponse.json(
{ error: 'too_many_concurrent', message: '同時実行可能な数を超えています' },
{ status: 429 }
);
}
const { taskType, prompt } = await req.json();
const taskId = crypto.randomUUID();
// クレジット消費 & 実行カウンタ増加
await env.MEMBERSHIP_KV.put(
`site:antigravity:credits:${email}`,
String(credits - 1),
{ expirationTtl: 86400 * 365 }
);
await env.MEMBERSHIP_KV.put(runningKey, String(running + 1), { expirationTtl: 600 });
// D1にタスクレコード作成
await env.DB.prepare(
`INSERT INTO tasks (id, email, type, prompt, status, created_at) VALUES (?, ?, ?, ?, ?, ?)`
).bind(taskId, email, taskType, prompt, 'queued', Date.now()).run();
// Cloudflare Queueに投入
await env.AGENT_QUEUE.send({ taskId, email, taskType, prompt });
return NextResponse.json({ taskId, status: 'queued' });
}ステップ5: キューワーカーでエージェント実行
別Workerでエージェントタスクを実際に処理します。
// workers/agent-worker.ts
import Anthropic from '@anthropic-ai/sdk';
export default {
async queue(batch: MessageBatch, env: Env): Promise<void> {
for (const message of batch.messages) {
const { taskId, email, taskType, prompt } = message.body as any;
try {
await processAgentTask(taskId, email, taskType, prompt, env);
} catch (e: any) {
await env.DB.prepare(
`UPDATE tasks SET status = 'failed', error = ?, completed_at = ? WHERE id = ?`
).bind(e.message, Date.now(), taskId).run();
// クレジット返却
await refundCredit(env, email);
} finally {
await decrementRunning(env, email);
}
}
},
};
async function processAgentTask(
taskId: string,
email: string,
taskType: string,
prompt: string,
env: Env
): Promise<void> {
await env.DB.prepare(
`UPDATE tasks SET status = 'running', started_at = ? WHERE id = ?`
).bind(Date.now(), taskId).run();
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
// エージェント本体(簡易版): 複数ステップを順次実行
const steps = await planSteps(anthropic, taskType, prompt);
const results: string[] = [];
for (const step of steps) {
const stepResult = await executeStep(anthropic, step);
results.push(stepResult);
// タスクタイムアウト(10分)
const task = await env.DB.prepare(
`SELECT started_at FROM tasks WHERE id = ?`
).bind(taskId).first();
if (Date.now() - (task?.started_at as number) > 10 * 60 * 1000) {
throw new Error('Task timeout exceeded');
}
}
const finalReport = await generateReport(anthropic, results);
await env.DB.prepare(
`UPDATE tasks SET status = 'completed', result = ?, completed_at = ? WHERE id = ?`
).bind(finalReport, Date.now(), taskId).run();
}ステップ6: タスクタイムアウトと部分失敗の対処
エージェントタスクで最も詰まるのが、長時間実行と部分失敗です。
タイムアウト対策 — エージェントが想定以上にステップを踏むと、Cloudflare Workerの実行時間上限(30秒)に達してしまいます。これを回避するため、上のコードでは10分のタスクタイムアウトを別途設定し、超過したらクレジットを返却して失敗にしています。
部分失敗対策 — 多段階タスクの途中でステップが失敗した場合、それまでの成果を保存しておく点が肝心です。各ステップ完了時にD1へ中間結果を保存し、失敗時にユーザーに「途中までの結果」を提供できる設計にします。
// 各ステップ後に中間結果を保存
await env.DB.prepare(
`INSERT INTO task_steps (task_id, step_index, result) VALUES (?, ?, ?)`
).bind(taskId, stepIndex, stepResult).run();ステップ7: コスト爆発の予防
エージェント型SaaSで最も恐ろしいのが、無限ループバグによるトークン消費の暴走です。私自身、開発中に「エージェントが計画の中でさらに別エージェントを呼び出して、再帰的にAPIを呼び続ける」というバグに2回ハマっています。
予防策として、次の3層を必ず入れてください。
- タスクごとの最大ステップ数制限 —
MAX_STEPS = 20のような上限を設定し、超過したら強制終了 - タスクごとの最大トークン消費上限 —
MAX_TOKENS = 200000で計測し、超過したら停止 - Anthropic Console側の月間予算アラート — 個人開発者の場合、月予算の80%到達でメール通知
let totalTokens = 0;
const MAX_TOKENS = 200000;
const MAX_STEPS = 20;
let stepCount = 0;
for (const step of steps) {
if (stepCount >= MAX_STEPS) throw new Error('Max steps exceeded');
if (totalTokens >= MAX_TOKENS) throw new Error('Token budget exceeded');
const result = await executeStep(anthropic, step);
totalTokens += result.usage.input_tokens + result.usage.output_tokens;
stepCount++;
}これらのガードを入れておけば、エージェントの暴走で月額数十万円の請求が来る事故を防げます。
ステップ8: Cloudflareエッジキャッシュ対策
他サイトと同じく、課金状態ユーザーへの正確な配信のためのキャッシュバイパスが必要です(STUMBLING_POINTS #73)。
addEventListener('fetch', event => {
const req = event.request;
const cookie = req.headers.get('cookie') || '';
if (cookie.includes('premium_token')) {
event.respondWith(fetch(req));
return;
}
event.respondWith(handleCachedRequest(event));
});本番投入前のチェックリスト
- Stripe Webhookエンドポイントが本番URLを指している
STRIPE_WEBHOOK_SECRETANTHROPIC_API_KEYが本番モードのものに切り替わっている- KVのkey prefixが
site:antigravity:で他サイトと衝突しないことを確認 - Cloudflare Queues の binding が wrangler.toml に追加されている
- D1の
taskstask_stepsテーブルが作成済み - Anthropic Consoleで月間予算アラートを設定
- Stripe Test Modeでサブスク作成 → 月次更新 → 解約の3パスを通している
- タスクのMAX_STEPS/MAX_TOKENS ガードが効くことを意図的に発火させて確認
cache-worker.jsでpremium_tokenCookieバイパスが効いている
全体を振り返って — エージェント型SaaSの3つの差別化ポイント
この実装ガイドの中で、エージェント型SaaSならではの設計判断は次の3つです。
- キュー駆動の非同期実行 — HTTPと切り離して長時間タスクを安定運用する
- 同時実行数制御 — 1ユーザーあたりの並列度を制限し、API quota枯渇を防ぐ
- 3層のコスト爆発予防 — ステップ数・トークン上限・予算アラートで暴走をブロック
これらをきちんと実装すれば、Antigravityで作るエージェント型SaaSは、本番運用に耐える商用プロダクトとして立ち上がります。前編で書いた価格設計と組み合わせれば、初日から赤字にならず、毎月安定した課金が成立する構成になります。
実装で詰まったら、Antigravity Lab・Claude Lab・Gemini Lab・Rork Labのリポジトリ群が同じ構成で動いている実例として参考になるはずです。