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.

KeyDirect path
  1. Clients

    • Studio

      Next.js · React · TypeScript

  2. Contract

    • Platform API

      NestJS · TypeScript · Bun

  3. 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.

  4. 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.

  5. 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.

  6. 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

Every paid call goes past the meterTypeScriptFrom Metered gateways
/** * 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;  }}
The image half of the metering pair. Nothing resolves the generation port without passing through here, so cost attribution is a property of the wiring instead of something each call site has to remember. Two details carry the judgment: metering runs after the await, so a failed generation is charged nothing, and an unattributed call from a background job falls back to a named sentinel because the column is NOT NULL and something has to stand in.

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