After running Antigravity agents across multiple products in parallel, I started getting an uncomfortable feeling every time I opened the monthly invoices from Google Cloud and Anthropic — I could not confidently answer "which product caused this line item." I have been an indie developer since 2014 with roughly 50 million cumulative downloads across iOS and Android apps, and I also run Dolice Labs (Claude Lab, Gemini Lab, Antigravity Lab, Rork Lab) in parallel. The agents touch everything from AdMob revenue optimization for our Zen wallpaper app to article generation and Crashlytics triage for each Lab site. They share a common cache, a common Gemma4 inference node, and a common Claude Agent SDK job queue, so the invoice cannot tell me "the wallpaper app cost this much in isolation" or "Claude Lab's article generation cost that much."
For a while I split the cost loosely — "the app business is bigger, so 70% of this is theirs" — until one month an AdMob eCPM optimization agent went into an unexpected retry storm and a single tenant silently consumed 71% of the month's budget. With sloppy attribution, finding the culprit and prioritizing the fix took more than a day. To bring that down to under two hours, I rebuilt the cost-attribution layer into the Showback architecture this article walks through.
Showback is originally a cloud-industry term for a design that "does not chargeback to tenants but makes execution cost visible per tenant." It is one step lighter than chargeback (where the tenant actually pays internally), and the discipline is to make sure every cost-incurring path is recorded with a "who, when, what for" label and surfaced on a dashboard. Even at an indie scale, this discipline pays off the moment your agents start crossing product boundaries.
How to Carry the Tenant ID — Context Propagation
The starting point of Showback is the state where every cost-incurring event carries a label (tenant ID) saying which product it belongs to. If even one path leaks this label, you end up with an eerie "unattributed: ¥98,400" line on the monthly report, and the attribution loses its credibility.
I use TypeScript's AsyncLocalStorage for context propagation. The tenant is fixed at the agent's entry point (an HTTP request, a Cron trigger, or a queue consumer), and from there every downstream async call carries the tenant ID without needing to add it to function signatures. The version I call from a Next.js Route Handler looks like this:
// src/lib/tenant-context.ts
import { AsyncLocalStorage } from "node:async_hooks";
export type TenantContext = {
tenantId: string; // e.g. "wallpaper-zen-ios" / "claudelab-net" / "internal-cron"
productGroup: "app" | "lab" | "internal";
invocationId: string; // 1 request = 1 ID
budgetCapJpy?: number; // optional monthly cap
};
const storage = new AsyncLocalStorage<TenantContext>();
export function runWithTenant<T>(ctx: TenantContext, fn: () => Promise<T>) {
return storage.run(ctx, fn);
}
export function currentTenant(): TenantContext {
const ctx = storage.getStore();
if (!ctx) {
// Throw to prevent "unattributed" — never silently default
throw new Error("tenant context missing — wrap with runWithTenant()");
}
return ctx;
}Three points matter in the design. First, do not allow a default value when the context is missing. If you let an "unknown" or "default" tenant exist, you will one day find that 40% of cost sits there. I tripped over this for a week, but the upside was that every uninstrumented code path got flushed out, so I now strongly recommend forbidding the default as a production prerequisite.
Second, always issue invocationId as a ULID or UUIDv7. The Showback Ledger uses it in a unique constraint to absorb duplicate writes from at-least-once delivery.
Third, classify productGroup at the agent layer. In my setup the three buckets are app / lab / internal, which makes it easy to switch axes when generating the monthly report.
The Three Axes of Cost Measurement — Tokens, Requests, Side Effects
Antigravity agent execution cost cannot be measured fairly with a single unit. I track it on three axes.
The first axis is token cost — the LLM call itself for models like Gemini 3 Pro Thinking. Pricing differs per input/output and per provider, so I keep a unit-price table and record "current unit price × tokens" at call time. This includes retries and Tier 1 fallbacks, so the redundant cost during a resilience event is captured too.
The second axis is request cost — per-call fees from tool-side APIs (Apple Search Ads API, Crashlytics Reporting API, Stripe API) and Cloudflare Workers invocation fees. These stack up in tiny units like ¥0.001 per request and "who called this" disappears quickly if you do not attribute.
The third axis is side effects — the production-write outcomes the agent produced (ad spend, push notification fees, SMS/email fees). This is usually 3x to 10x larger than the LLM cost and is therefore the most important axis for Showback. For example, the keyword-bidding agent I run for AdMob mediation A/B tests costs ¥4,200/month in tokens but moves ¥180,000/month of actual ad spend. If you only track the former, you will conclude "LLM is cheap, no problem" and miss the real story.
The implementation can be a thin wrapper. A small layer that calls the Anthropic SDK and Google AI SDK "with tenant" leaves most of your code untouched.
// src/lib/cost-meter.ts
import { currentTenant } from "./tenant-context";
import { writeLedgerRow } from "./ledger";
type Pricing = { inputPer1k: number; outputPer1k: number; currency: "JPY" };
const PRICING: Record<string, Pricing> = {
"gemini-3-pro-thinking": { inputPer1k: 0.42, outputPer1k: 2.10, currency: "JPY" },
"claude-opus-4-6": { inputPer1k: 2.25, outputPer1k: 11.25, currency: "JPY" },
"gemma4-27b-onprem": { inputPer1k: 0.00, outputPer1k: 0.00, currency: "JPY" }, // electricity tracked elsewhere
};
export async function meterLlmCall<T>(opts: {
model: keyof typeof PRICING;
inputTokens: number;
outputTokens: number;
result: T;
}): Promise<T> {
const tenant = currentTenant();
const p = PRICING[opts.model];
const costJpy =
(opts.inputTokens / 1000) * p.inputPer1k +
(opts.outputTokens / 1000) * p.outputPer1k;
await writeLedgerRow({
tenantId: tenant.tenantId,
productGroup: tenant.productGroup,
invocationId: tenant.invocationId,
axis: "tokens",
model: opts.model,
units: opts.inputTokens + opts.outputTokens,
costJpy,
occurredAt: new Date().toISOString(),
});
return opts.result;
}The side-effect axis uses the same wrapper pattern. A separate row with axis: "side_effect" writes to the same ledger. Keeping all three axes in one table lets the monthly report use a plain SUM(cost_jpy) GROUP BY tenant, axis, which is much easier to operate over time.