Building Sonna AI: A Spec-Driven Multi-Modal AI Studio using Kiro's Agentic IDE
Inside Sonna AI: Building a Spec-Driven Multi-Modal AI Studio with NestJS, Turso DB, and Native Android (Kotlin) Hey developers! π We are excited to share Sonna AI, a unified multi-modal AI Creative Studio built for c
Inside Sonna AI: Building a Spec-Driven Multi-Modal AI Studio with NestJS, Turso DB, and Native Android (Kotlin)
Hey developers! π
We are excited to share Sonna AI, a unified multi-modal AI Creative Studio built for creators and developers. Sonna unifies voice, music, image, and video generation in a single workspace, serving as a complete alternative to single-modality tools.
Web Dashboard: sonnalabs.app
Android App: Google Play Store
π οΈ The Tech Stack & Production Status
Our infrastructure is fully live in production (deployed via custom PM2 configurations and automated shell deployment scripts) on a dedicated VPS stack:
- Backend: NestJS (Node.js framework running on PM2 as
sonnalabs-backend) - Database: Turso DB (Distributed SQLite with 19 active tables)
- Cache & Rate Limiting: Redis
- Storage: Cloudflare R2 (S3-compatible bucket storage for media output)
- Web Client: Next.js (React framework running on localhost/production)
- Mobile Client: Native Android built in Kotlin (integrated with Google Play Billing)
π Architectural Deep Dive: Resilient Multi-Platform Billing (SSOT)
Handling subscriptions and credit allocations across different payment platforms is notorious for logic drift and fraud. We built a strict Single Source of Truth (SSOT) billing engine using atomic transactions and proration strategies.
1. Google Play RTDN Safety Net
To prevent wrongful account downgrades due to delayed Google Play RTDN (Real-Time Developer Notifications) webhooks, we implemented a pre-downgrade validator. Before executing a lazy downgrade on an expired subscription, our backend calls verifyAndDowngradeUserIfExpired(userId) in backend/sonna/src/services/billing/api-utils.ts.
export async function verifyAndDowngradeUserIfExpired(userId: string): Promise<boolean> {
// 1. Fetch latest completed purchase token
const tokenRow = await db.execute({
sql: "SELECT purchase_token FROM purchases WHERE user_id = ? AND purchase_kind = 'subscription' AND status = 'completed' AND platform IN ('android', 'google_play') ORDER BY verified_at DESC LIMIT 1",
args: [userId],
});
const token = tokenRow.rows[0]?.purchase_token ? String(tokenRow.rows[0].purchase_token) : null;
// 2. Perform live check against Google Play API if token exists
if (token) {
const verify = await verifyGooglePlaySubscriptionV2(SUBSCRIPTION_PRODUCT_ID, token);
if (verify.valid && verify.expiryMs && verify.expiryMs > Date.now()) {
// Extend subscription expiry and prevent downgrade
return false;
}
}
// 3. Fallback to downgrade if verification fails
await downgradeUserToFree(userId);
return true;
}
If Google Play confirms the automatic renewal is active, the downgrade is canceled, and database expiry timestamps are extended.
2. Transaction Safety: Upfront Wallet Deductions & Ledger Auditing
To prevent race conditions where two concurrent requests on different PM2 instances could double-spend credits, we implemented an atomic transaction read-modify-write pattern in deductCredits():
export async function deductCredits(userId: string, amount: number, meta?: { jobId?: string; feature?: string }) {
const tx = await db.transaction("write");
try {
const read = await tx.execute({
sql: "SELECT subscription_credits, payg_credits, credits, plan_type FROM users WHERE id = ?",
args: [userId],
});
if (read.rows.length === 0) {
await tx.rollback();
return { success: false, error: "User not found" };
}
const r = read.rows[0];
const sub = Number(r.subscription_credits || 0);
const payg = Number(r.payg_credits || 0);
const free = Number(r.credits || 0);
// Calculate split waterfall: Subscription Credits -> PAYG -> Free Credits
const breakdown = splitWaterfall(amount, sub, payg, free);
const newSub = sub - breakdown.fromSubscription;
const newPayg = payg - breakdown.fromPayg;
const newFree = free - breakdown.fromFree;
// Perform atomic deduction
await tx.execute({
sql: "UPDATE users SET subscription_credits = ?, payg_credits = ?, credits = ? WHERE id = ?",
args: [newSub, newPayg, newFree, userId],
});
// Log to audit credit_ledger in the same transaction
await tx.execute({
sql: "INSERT INTO credit_ledger (id, user_id, kind, job_id, feature, amount, from_subscription, from_payg, from_free) VALUES (?, ?, 'deduct', ?, ?, ?, ?, ?, ?)",
args: [randomUUID(), userId, meta?.jobId, meta?.feature, amount, breakdown.fromSubscription, breakdown.fromPayg, breakdown.fromFree],
});
await tx.commit();
} catch (error) {
await tx.rollback();
}
}
If the downstream generation fails (e.g., the model provider returns a transient error), refundCredits() parses the exact ledger breakdown and restores the credits to their original buckets with zero-ambiguity.
3. Verification Security & Replay Prevention
In billing.controller.ts, our verifyPurchase() endpoint enforces strict checks:
- Replay Prevention: Purchase tokens are hashed and checked against the database (
iap_${purchaseToken}orrestore_${purchaseToken}) to block duplicate claims. - Cross-Account Token Guard: Prevent users from claiming tokens purchased by another account (
tokenOwner.user_id !== userId). - Accumulation & Zeroing Policy: A paid subscription zeroes the free
creditsbucket to prevent stacking free and paid allocations. Upgrades dynamically add credits (subscription_credits = subscription_credits + new_allocation) without wiping old balances.
4. Client-Side Android Billing Flows
In BillingViewModel.kt, we handle Google Play subscriptions by calculating the difference between plans to assign the correct replacement mode:
val replacementMode = if (targetRank > currentRank) {
// Upgrade (Pro -> Max)
BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE
} else {
// Downgrade (Max -> Pro)
BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION
}
This ensures Google Play charges the full price immediately for upgrades (carrying over remaining time), while downgrades only apply at the next renewal, preserving the user's current tier for the active billing cycle.
β‘ The Platform Configuration Layer (Zero-Redeploy Architecture)
To minimize downtime, we built a database-driven provider orchestration layer. This allows us to scale rate limits, switch providers, and monitor errors without redeploying code:
- Visual Generation Routing: All image and video generations route through a multi-key API key pool (
API_KEY_0andAPI_KEY_1) targeting our serverless model generation endpoints (FLUX, LTX-Video, and WanVideo). - DB-Driven Settings: Rate limits and feature flags are loaded from Turso DB (
provider_configandfeature_flagstables) and cached in-memory with a 5-minute TTL. Toggling an engine or updating a rate window (e.g., changing a provider's limit of 18/10s to 50/10s) requires a single SQLUPDATEstatement. - Universal Logging & Slack/Telegram Alerts: Errors across our generation engines, ElevenLabs, Google TTS, and Gemini TTS are logged in
provider_error_logs. If errors cross a threshold (>10 errors/hour per provider), our backend automatically dispatches a Telegram alert. We can export full error reports as CSVs using/api/admin/provider-error-report.csvto forward logs directly to support tickets.
π€ How We Leveraged Kiro's Agentic IDE
Building a cross-platform stack requires tight coordination. We relied heavily on Kiro's agentic automation features to keep our sprints organized:
- Spec-Driven Workflows: We mapped all our multi-platform billing states, rollover rules, and database schemas in
requirements.mdanddesign.mdusing formal EARS notation before writing code. Kiro parsed these files to generate correct APIs, keeping Next.js and NestJS interfaces synchronized. - Steering & Domain Boundaries: We defined clear boundary rules in
.kiro/steering/to enforce clean architectureβensuring Kiro never mixed logic between NestJS controller layers and raw Turso database queries, and respected native Android domain structures. - Parallel Agents: Kiro coordinated parallel sub-agents to construct our NestJS Midtrans integration endpoints while simultaneously generating the Kotlin model structures for the mobile client.
- Vitest Coverage Hooks: Automated hooks were set up to execute schema migrations and verify our suite of 63 unit/integration tests on Turso SQLite schemas immediately upon saving migration scripts.
π± Android Kotlin Client UI Showcase
Here is a quick look at our native mobile client interface built in Kotlin:
Home & New Generation
Explore Music & Media & Library
Creator Space
π Live Demo & Feedback
Sonna AI is now live in production!
- Web Dashboard: Try it out at sonnalabs.app
- Android App: Download the native Kotlin application on the Google Play Store
π‘ Access Policy Note: Anyone can download the app and sign up. The free tier gives you access to Google TTS. Generation features for Music, Images, Videos, as well as advanced TTS engines (ElevenLabs and Gemini TTS) require subscription credits or PAYG credits.
We would love to hear your thoughts on our DB-driven platform layer design, our Google Play billing proration setup, or how you utilize agentic steering files in your own workflows. Any feedback on our Web or Android client UI is highly appreciated!
Happy coding!
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.





