Product
AutoAds
An advertising platform that turns a description of what you are selling into finished ad copy and composed artwork, and prices every generation before it runs.
Four moves, and a console across them
You define what is being advertised, who it is for, and the brand it has to look like. The platform conceives from those definitions, in copy first: Claude writes against the brief rather than against a prompt somebody retyped. You produce in a studio that composes the copy onto an artboard, layer by layer, with pan and zoom and per-lane retry. And you collect in a gallery that owns nothing and is only a view.
Across all four sits Runs, the console that answers two questions and no others: what is happening, and what did it cost.
That decomposition is the product. Every capability added since inherits the answer to “where does its output live” instead of re-arguing it.
There is no save button
Promote is the only curation gesture in the whole product. No keep, no favourite, no star. Everything generated is recorded automatically, and the single act a person performs is raising one composition to be the concept’s key visual.
The rule it enforces is a design test: if a surface needs a “save this” affordance, that surface’s responsibility is wrong. A gallery that owns its contents has quietly become a second source of truth, and two sources of truth for the same artwork is the bug you find six months later.
Tenancy is in the schema
Every domain table carries a non-null, indexed tenant id, and every
repository query is scoped at the data layer. The scoping does not depend
on the request, the token, or a middleware somebody might forget to apply.
Cross-tenant reads exist, and only behind an audited
withMasterScope(reason) wrapper. The rule arrives with the substrate this
forks from, which a fork extends and never edits.
The alternative was a guard reading the token on the way in. It is less code and it fails open: one controller querying a repository directly leaks another team’s rows, and nothing in the type system objects.
System
Clients
Studio
Next.js · React · TypeScript
Contract
Platform API
NestJS · TypeScript · Bun
Application
Definitions
TypeScript
Why
What is being advertised, who it is for, and the brand it has to look like. Every later stage reads from here rather than carrying its own copy of the brief.
Generation pipeline
TypeScript
Why
Definitions become a Concept, a Concept becomes compositions, and a composition is layers over a media asset. Each stage is addressable, so a run can re-enter partway rather than starting from the brief again.
Metering
Metered gateways
TypeScript
Why
Every cost-bearing call resolves through here, so a spend entry lands against the requesting team on the way past. Metering runs after the call returns, which means a failed generation is charged nothing.
Generation
Copy generation
Claude · TypeScript
Image generation
TypeScript
Why
The port carries the provider and the model as part of the request, so which vendor renders a lane is decided per call rather than per deployment. Pricing, locking, persistence and deletion sit above that choice and never learn about it.
State
Relational store
PostgreSQL · Drizzle
Why
Every domain table carries a non-null, indexed tenant id, and every repository query is scoped at the data layer instead of at the request.
Asset store
TypeScript
15/**16 * The image sibling of `MeteredLlmGateway`. Every cost-bearing image call in17 * the product resolves `IMAGE_GENERATION` and therefore passes through here —18 * cost attribution is designed in from the first call, not retrofitted19 * (CLAUDE.md, Fork-0004 §5).20 */21@Injectable()22export class MeteredImageGateway implements ImageGenerationPort {23 constructor(24 @Inject(IMAGE_GENERATION_INNER) private readonly inner: ImageGenerationPort,25 @Inject(USAGE_EVENTS) private readonly usage: UsageEventsPort,26 @Inject(SPEND_METER) private readonly spend: SpendMeterPort,27 ) {}2829 async generate(req: ImageGenerationRequest): Promise<ImageGenerationResult> {30 // Metering after the await is deliberate: a failed generation charges31 // nothing, matching MeteredLlmGateway.32 const result = await this.inner.generate(req);33 const { tenantId, userId, requestId, route } = req.attribution;34 recordGenerationSpend(35 { usage: this.usage, spend: this.spend },36 {37 tenantId,38 userId,39 // The port allows an unattributed call (a job has no HTTP request);40 // `unknown` is the sentinel the rate-limit metering path already uses,41 // and `request_id` is NOT NULL so something must stand in.42 requestId: requestId ?? 'unknown',43 route,44 bucket: 'ai.image',45 costKind: 'image_generation',46 costUsd: result.costUsd,47 },48 );49 return result;50 }51}autoads4d9734cfapps/api/src/modules/media/adapters/out/metered-image-gateway.tsLines 15 to 5137 lines
/**
* The image sibling of `MeteredLlmGateway`. Every cost-bearing image call in
* the product resolves `IMAGE_GENERATION` and therefore passes through here —
* cost attribution is designed in from the first call, not retrofitted
* (CLAUDE.md, Fork-0004 §5).
*/
@Injectable()
export class MeteredImageGateway implements ImageGenerationPort {
constructor(
@Inject(IMAGE_GENERATION_INNER) private readonly inner: ImageGenerationPort,
@Inject(USAGE_EVENTS) private readonly usage: UsageEventsPort,
@Inject(SPEND_METER) private readonly spend: SpendMeterPort,
) {}
async generate(req: ImageGenerationRequest): Promise<ImageGenerationResult> {
// Metering after the await is deliberate: a failed generation charges
// nothing, matching MeteredLlmGateway.
const result = await this.inner.generate(req);
const { tenantId, userId, requestId, route } = req.attribution;
recordGenerationSpend(
{ usage: this.usage, spend: this.spend },
{
tenantId,
userId,
// The port allows an unattributed call (a job has no HTTP request);
// `unknown` is the sentinel the rate-limit metering path already uses,
// and `request_id` is NOT NULL so something must stand in.
requestId: requestId ?? 'unknown',
route,
bucket: 'ai.image',
costKind: 'image_generation',
costUsd: result.costUsd,
},
);
return result;
}
}- Nothing to meter. Products, audiences, brand components, hooks and templates are ordinary tenant-scoped reads and writes, so they go to the store without passing through a layer whose only job is to price a call that costs money.
Paying for generation
Generation costs money on every call, so the meter is part of the wiring rather than something each call site remembers. Every cost-bearing call resolves a port that wraps the vendor, and a spend entry lands against the requesting team whether a person or a background job made it.
Three consequences a user actually feels:
- A run is priced before it fires. The generation bar shows what the selected lanes will cost while there is still time to deselect one.
- A lane that already succeeded is reused byte for byte, at no charge. Locking is the reuse mechanism, so retrying a partly failed run pays only for the parts that failed.
- Metering happens after the await. A failed generation charges nothing.
22
Bounded contexts
Each one owns its tables and exposes ports, not rows.
558
Test files
Unit, slice, integration and end to end, as four projects.
Contribution
- Commits authored
- 1,749
- Decisions recorded
- 66
14 of 14 weeks active · 11 May 2026 – 16 Aug 2026
Longest run · 14 weeks · May – Aug
One repository, with sixty-six decision records written alongside it.
Measured 2026-09-08