feat(agentad): AgentAd Marketplace PRD + reference exchange (M5)
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled

Add the AgentBBS M5 "AgentAd marketplace" spec and a working reference
implementation built on the existing @logicsrc/schemas AgentAd contracts.

- docs/agentad-marketplace.md: two-sided exchange PRD (buy/sell sides,
  match -> auction -> pace -> serve -> meter -> settle, CoinPay settlement,
  AgentBBS as reference publisher, milestones M5.0-M5.5).
- packages/agentad (@logicsrc/agentad): reference exchange
  - builders that emit schema-valid, always-disclosed ad/campaign/placement docs
  - HMAC-signed, single-use impression/click tracking tokens
  - AgentAdExchange: targeting/format/category matching, second-price auction,
    budget pacing + daily caps, frequency capping, token-driven metering
  - pluggable settlement (InMemorySettlement) that can't overspend escrow
  - runtime validation against the canonical agentad-*.schema.json
- 22 vitest cases (builders, tokens, full serve/meter/settle lifecycle).
- Wire package into root build; link the PRD from README + docs/agentad.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-01 10:29:18 +00:00
parent 0bca203527
commit 7d62e3b661
18 changed files with 1862 additions and 2 deletions

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { createAd, createCampaign, createPlacement, createAdRequest } from "./builders.js";
import { validate } from "./validate.js";
describe("AgentAd builders", () => {
it("builds a schema-valid, disclosed ad and always forces sponsored:true", () => {
const ad = createAd({
advertiser_did: "railway.app",
campaign_id: "cmp-1",
format: "json",
title: "Ship your CLI in 60s",
url: "https://railway.app/?ref=cl1s",
pricing: { model: "cpc", bid: 0.5, currency: "USD" },
machine_readable: { product: "railway" }
});
expect(ad.type).toBe("agentad.ad");
expect(ad.disclosure).toEqual({ sponsored: true, label: "Sponsored" });
expect(validate("agentad-ad", ad).ok).toBe(true);
});
it("honors a custom disclosure label and advertiser name", () => {
const ad = createAd({
advertiser_did: "acme.dev",
format: "text",
title: "Acme",
url: "https://acme.dev",
disclosure: { label: "Ad", advertiser_name: "Acme, Inc." }
});
expect(ad.disclosure.label).toBe("Ad");
expect(ad.disclosure.advertiser_name).toBe("Acme, Inc.");
expect(ad.disclosure.sponsored).toBe(true);
});
it("defaults a campaign to draft and validates", () => {
const campaign = createCampaign({
advertiser_did: "railway.app",
name: "Launch",
budget: { total: 100, currency: "USD" }
});
expect(campaign.status).toBe("draft");
expect(validate("agentad-campaign", campaign).ok).toBe(true);
});
it("builds valid placements and requests", () => {
const placement = createPlacement({
publisher_did: "agentbbs.sh",
surface: "agent",
accepted_formats: ["json"]
});
const request = createAdRequest({ placement_id: placement.id, consumer: "agent" });
expect(validate("agentad-placement", placement).ok).toBe(true);
expect(validate("agentad-ad-request", request).ok).toBe(true);
});
it("generates unique ids across calls", () => {
const a = createPlacement({ publisher_did: "p.p", surface: "cli", accepted_formats: ["text"] });
const b = createPlacement({ publisher_did: "p.p", surface: "cli", accepted_formats: ["text"] });
expect(a.id).not.toBe(b.id);
});
});

View file

@ -0,0 +1,90 @@
// Convenience builders that produce schema-valid AgentAd documents with sensible
// LogicSRC defaults. Every builder fills `type`/`version` and — for ads — enforces
// the mandatory disclosure contract so an undisclosed ad can never be constructed.
import type { Ad, AdRequest, Campaign, Placement } from "./types.js";
import { assertValid } from "./validate.js";
export const AGENTAD_VERSION = "0.1";
let counter = 0;
function autoId(prefix: string): string {
counter += 1;
return `${prefix}-${Date.now().toString(36)}-${counter.toString(36)}`;
}
export type NewAd = Omit<Ad, "type" | "version" | "id" | "disclosure"> & {
id?: string;
version?: string;
disclosure?: Partial<Ad["disclosure"]>;
};
export function createAd(input: NewAd): Ad {
const ad: Ad = {
type: "agentad.ad",
version: input.version ?? AGENTAD_VERSION,
id: input.id ?? autoId("ad"),
...stripMeta(input),
disclosure: {
sponsored: true,
label: input.disclosure?.label ?? "Sponsored",
...(input.disclosure?.advertiser_name
? { advertiser_name: input.disclosure.advertiser_name }
: {})
}
};
return assertValid("agentad-ad", ad);
}
export type NewCampaign = Omit<Campaign, "type" | "version" | "id"> & {
id?: string;
version?: string;
};
export function createCampaign(input: NewCampaign): Campaign {
const campaign: Campaign = {
type: "agentad.campaign",
version: input.version ?? AGENTAD_VERSION,
id: input.id ?? autoId("cmp"),
status: input.status ?? "draft",
...stripMeta(input)
};
return assertValid("agentad-campaign", campaign);
}
export type NewPlacement = Omit<Placement, "type" | "version" | "id"> & {
id?: string;
version?: string;
};
export function createPlacement(input: NewPlacement): Placement {
const placement: Placement = {
type: "agentad.placement",
version: input.version ?? AGENTAD_VERSION,
id: input.id ?? autoId("plc"),
...stripMeta(input)
};
return assertValid("agentad-placement", placement);
}
export type NewAdRequest = Omit<AdRequest, "type" | "version"> & { version?: string };
export function createAdRequest(input: NewAdRequest): AdRequest {
const request: AdRequest = {
type: "agentad.ad_request",
version: input.version ?? AGENTAD_VERSION,
...stripMeta(input)
};
return assertValid("agentad-ad-request", request);
}
// Drop the builder-only override keys so they don't leak into the document and
// trip additionalProperties:false during validation.
function stripMeta<T extends Record<string, unknown>>(input: T): Omit<T, "version" | "id" | "disclosure" | "status"> {
const { version, id, disclosure, status, ...rest } = input as Record<string, unknown>;
void version;
void id;
void disclosure;
void status;
return rest as Omit<T, "version" | "id" | "disclosure" | "status">;
}

View file

@ -0,0 +1,338 @@
import { beforeEach, describe, expect, it } from "vitest";
import { AgentAdExchange } from "./exchange.js";
import { InMemorySettlement } from "./settlement.js";
import { createAd, createCampaign, createPlacement, createAdRequest } from "./builders.js";
import { validate } from "./validate.js";
import type { Ad } from "./types.js";
const NOW = Date.parse("2026-07-01T00:00:00Z");
const clock = () => NOW;
function newExchange(feeRate = 0.15) {
return new AgentAdExchange({
secret: "test-secret",
now: clock,
settlement: new InMemorySettlement({ networkFeeRate: feeRate })
});
}
function jsonPlacement(exchange: AgentAdExchange, overrides = {}) {
return exchange.registerPlacement(
createPlacement({
publisher_did: "agentbbs.sh",
surface: "agent",
accepted_formats: ["json"],
...overrides
})
);
}
function activeCampaign(exchange: AgentAdExchange, total = 100) {
const campaign = createCampaign({
advertiser_did: "railway.app",
name: "Launch",
status: "active",
budget: { total, currency: "USD" }
});
return exchange.registerCampaign(campaign);
}
describe("AgentAdExchange serving", () => {
let exchange: AgentAdExchange;
beforeEach(() => {
exchange = newExchange();
});
it("serves a matching, disclosed ad and produces a schema-valid response", () => {
const campaign = activeCampaign(exchange);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "Ship your CLI in 60s",
url: "https://railway.app/?ref=cl1s",
pricing: { model: "cpc", bid: 0.5, currency: "USD" },
machine_readable: { product: "railway" }
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(
createAdRequest({ placement_id: placement.id, consumer: "agent" })
);
expect(res.no_fill_reason).toBeUndefined();
expect(res.ads).toHaveLength(1);
expect(validate("agentad-ad-response", res).ok).toBe(true);
const rendered = JSON.parse(res.ads[0].rendered ?? "{}");
expect(rendered.sponsored).toBe(true);
expect(rendered.data.product).toBe("railway");
});
it("no-fills with no_inventory when nothing matches", () => {
jsonPlacement(exchange);
const placementId = jsonPlacement(exchange).id;
const res = exchange.requestAds(createAdRequest({ placement_id: placementId }));
expect(res.ads).toHaveLength(0);
expect(res.no_fill_reason).toBe("no_inventory");
});
it("no-fills with invalid_request for an unknown placement", () => {
const res = exchange.requestAds(createAdRequest({ placement_id: "does-not-exist" }));
expect(res.no_fill_reason).toBe("invalid_request");
});
it("does not serve ads from draft campaigns", () => {
const draft = exchange.registerCampaign(
createCampaign({
advertiser_did: "railway.app",
name: "Draft",
budget: { total: 100, currency: "USD" }
})
);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: draft.id,
format: "json",
title: "Draft ad",
url: "https://x.dev",
pricing: { model: "cpm", bid: 5, currency: "USD" }
})
);
const placement = jsonPlacement(exchange);
expect(exchange.requestAds(createAdRequest({ placement_id: placement.id })).no_fill_reason).toBe(
"no_inventory"
);
});
it("blocks ads whose category is on the placement block list", () => {
const campaign = activeCampaign(exchange);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "Buy coins",
url: "https://coins.example",
pricing: { model: "cpm", bid: 5, currency: "USD" },
machine_readable: { category: "crypto" }
})
);
const placement = jsonPlacement(exchange, { block_categories: ["crypto"] });
expect(exchange.requestAds(createAdRequest({ placement_id: placement.id })).no_fill_reason).toBe(
"blocked_category"
);
});
it("refuses to register an undisclosed ad", () => {
const campaign = activeCampaign(exchange);
const undisclosed = {
type: "agentad.ad",
version: "0.1",
id: "ad-bad",
campaign_id: campaign.id,
advertiser_did: "railway.app",
format: "text",
title: "Sneaky",
url: "https://x.dev",
disclosure: { sponsored: false, label: "Sponsored" }
} as unknown as Ad;
expect(() => exchange.registerAd(undisclosed)).toThrow();
});
});
describe("AgentAdExchange metering & settlement", () => {
it("charges CPC on click (not impression) and pays the publisher net of fee", () => {
const exchange = newExchange(0.15);
const campaign = activeCampaign(exchange);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "CPC ad",
url: "https://railway.app",
pricing: { model: "cpc", bid: 1, currency: "USD" }
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
const imp = exchange.confirmImpression(res.ads[0].impression_token);
expect(imp.charged).toBe(0); // cpc is billed on click
expect(validate("agentad-impression", imp.impression).ok).toBe(true);
const click = exchange.confirmClick(imp.click_token, { action: "open_url" });
expect(validate("agentad-click", click).ok).toBe(true);
// single candidate → pays own bid ($1); publisher gets 85%
expect(exchange.earnings("agentbbs.sh")).toBeCloseTo(0.85, 6);
expect(exchange.remaining(campaign.id)).toBeCloseTo(99, 6);
expect(exchange.ledger().filter((e) => e.kind === "click" && e.amount > 0)).toHaveLength(1);
});
it("charges CPM on impression", () => {
const exchange = newExchange(0);
const campaign = activeCampaign(exchange, 100);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "CPM ad",
url: "https://railway.app",
pricing: { model: "cpm", bid: 10, currency: "USD" } // $10 CPM = $0.01 / impression
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
const imp = exchange.confirmImpression(res.ads[0].impression_token);
expect(imp.charged).toBeCloseTo(0.01, 6);
expect(exchange.earnings("agentbbs.sh")).toBeCloseTo(0.01, 6); // 0% fee
});
it("runs a second-price auction: the winner pays the runner-up's price", () => {
const exchange = newExchange(0);
const high = exchange.registerCampaign(
createCampaign({
advertiser_did: "high.dev",
name: "High",
status: "active",
budget: { total: 100, currency: "USD" }
})
);
const low = exchange.registerCampaign(
createCampaign({
advertiser_did: "low.dev",
name: "Low",
status: "active",
budget: { total: 100, currency: "USD" }
})
);
exchange.registerAd(
createAd({
advertiser_did: "high.dev",
campaign_id: high.id,
format: "json",
title: "High bid",
url: "https://high.dev",
pricing: { model: "cpc", bid: 1, currency: "USD" }
})
);
exchange.registerAd(
createAd({
advertiser_did: "low.dev",
campaign_id: low.id,
format: "json",
title: "Low bid",
url: "https://low.dev",
pricing: { model: "cpc", bid: 0.5, currency: "USD" }
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
expect(res.ads).toHaveLength(1);
// winner is high.dev; clearing factor = 0.5/1 → charged 0.5 on click
const imp = exchange.confirmImpression(res.ads[0].impression_token);
exchange.confirmClick(imp.click_token);
expect(exchange.remaining(high.id)).toBeCloseTo(99.5, 6);
expect(exchange.remaining(low.id)).toBeCloseTo(100, 6); // runner-up not charged
});
it("never charges beyond escrowed budget", () => {
const exchange = newExchange(0);
const campaign = exchange.registerCampaign(
createCampaign({
advertiser_did: "railway.app",
name: "Tiny",
status: "active",
budget: { total: 0.005, currency: "USD" }
})
);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "CPM ad",
url: "https://railway.app",
pricing: { model: "cpm", bid: 10, currency: "USD" } // wants $0.01 / impression
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
const imp = exchange.confirmImpression(res.ads[0].impression_token);
expect(imp.charged).toBeCloseTo(0.005, 6); // capped at escrow
expect(exchange.remaining(campaign.id)).toBe(0);
// budget exhausted → no more inventory
expect(exchange.requestAds(createAdRequest({ placement_id: placement.id })).no_fill_reason).toBe(
"no_inventory"
);
});
it("rejects reused and forged tokens", () => {
const exchange = newExchange();
const campaign = activeCampaign(exchange);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "Ad",
url: "https://railway.app",
pricing: { model: "cpc", bid: 1, currency: "USD" }
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
const token = res.ads[0].impression_token;
exchange.confirmImpression(token);
expect(() => exchange.confirmImpression(token)).toThrow(/already consumed/);
expect(() => exchange.confirmImpression("garbage.token")).toThrow(/invalid impression token/);
});
});
describe("AgentAdExchange frequency capping", () => {
it("caps impressions per session", () => {
const exchange = newExchange();
const campaign = activeCampaign(exchange);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "Ad",
url: "https://railway.app",
pricing: { model: "cpm", bid: 5, currency: "USD" }
})
);
const placement = jsonPlacement(exchange, { frequency_cap: { max_per_session: 1 } });
const first = exchange.requestAds(
createAdRequest({ placement_id: placement.id }),
{ sessionId: "s1" }
);
expect(first.ads).toHaveLength(1);
const second = exchange.requestAds(
createAdRequest({ placement_id: placement.id }),
{ sessionId: "s1" }
);
expect(second.no_fill_reason).toBe("frequency_capped");
// a different session is unaffected
const other = exchange.requestAds(
createAdRequest({ placement_id: placement.id }),
{ sessionId: "s2" }
);
expect(other.ads).toHaveLength(1);
});
});

View file

@ -0,0 +1,544 @@
// AgentAdExchange — the reference two-sided exchange described in
// docs/agentad-marketplace.md. It registers advertiser campaigns/ads and
// publisher placements, then for each ad request runs:
//
// match -> auction (second price) -> pace (budget) -> serve -> meter -> settle
//
// Metering is token-driven: serving mints a single-use, HMAC-signed
// impression_token; confirming the impression mints a click_token. Settlement is
// pluggable (CoinPay in production; in-memory here).
import { AGENTAD_VERSION } from "./builders.js";
import { InMemorySettlement, type SettlementProvider } from "./settlement.js";
import { mintToken, verifyToken } from "./tokens.js";
import type {
Ad,
AdRequest,
AdResponse,
Campaign,
CampaignStatus,
Click,
ClickAction,
Consumer,
Impression,
NoFillReason,
Placement,
ServedAd
} from "./types.js";
import { assertValid } from "./validate.js";
export interface LedgerEntry {
kind: "impression" | "click";
campaign_id: string;
ad_id: string;
placement_id: string;
action?: ClickAction;
amount: number;
currency: string;
occurred_at: string;
}
export interface AgentAdExchangeOptions {
/** HMAC secret used to sign tracking tokens. */
secret: string;
/** Settlement backend. Defaults to a fresh InMemorySettlement. */
settlement?: SettlementProvider;
/** Clock, for deterministic tests. */
now?: () => number;
/** Token lifetime in ms. Default 24h. */
tokenTtlMs?: number;
/** Expected click-through rate for normalizing cpc/cpa bids. Default 0.02. */
expectedCtr?: number;
/** Expected conversion rate for normalizing cpa bids. Default 0.05. */
expectedCvr?: number;
/** Weight applied to keyword relevance in ranking. Default 0.25. */
relevanceWeight?: number;
}
export interface RequestOptions {
now?: number;
/** Session identifier for per-session frequency capping. Default "default". */
sessionId?: string;
}
export interface ConfirmImpressionResult {
impression: Impression;
click_token: string;
charged: number;
}
let requestCounter = 0;
export class AgentAdExchange {
private readonly secret: string;
private readonly settlement: SettlementProvider;
private readonly clock: () => number;
private readonly tokenTtlMs: number;
private readonly expectedCtr: number;
private readonly expectedCvr: number;
private readonly relevanceWeight: number;
private readonly ads = new Map<string, Ad>();
private readonly campaigns = new Map<string, Campaign>();
private readonly placements = new Map<string, Placement>();
private readonly escrowed = new Set<string>();
private readonly reputation = new Map<string, number>();
private readonly usedTokens = new Set<string>();
private readonly sessionCount = new Map<string, number>();
private readonly dayCount = new Map<string, number>();
private readonly dailySpend = new Map<string, number>();
private readonly ledgerEntries: LedgerEntry[] = [];
constructor(options: AgentAdExchangeOptions) {
if (!options.secret) throw new Error("AgentAdExchange requires a signing secret");
this.secret = options.secret;
this.settlement = options.settlement ?? new InMemorySettlement();
this.clock = options.now ?? (() => Date.now());
this.tokenTtlMs = options.tokenTtlMs ?? 24 * 60 * 60 * 1000;
this.expectedCtr = options.expectedCtr ?? 0.02;
this.expectedCvr = options.expectedCvr ?? 0.05;
this.relevanceWeight = options.relevanceWeight ?? 0.25;
}
// --- registration -------------------------------------------------------
registerAd(ad: Ad): Ad {
assertValid("agentad-ad", ad);
if (ad.disclosure.sponsored !== true) {
throw new Error(`ad ${ad.id} is not disclosed as sponsored`);
}
this.ads.set(ad.id, ad);
return ad;
}
registerCampaign(campaign: Campaign): Campaign {
assertValid("agentad-campaign", campaign);
this.campaigns.set(campaign.id, campaign);
if ((campaign.status ?? "draft") === "active") this.escrowCampaign(campaign);
return campaign;
}
registerPlacement(placement: Placement): Placement {
assertValid("agentad-placement", placement);
this.placements.set(placement.id, placement);
return placement;
}
setCampaignStatus(campaignId: string, status: CampaignStatus): void {
const campaign = this.campaigns.get(campaignId);
if (!campaign) throw new Error(`unknown campaign ${campaignId}`);
campaign.status = status;
if (status === "active") this.escrowCampaign(campaign);
}
setReputation(advertiserDid: string, score: number): void {
if (score <= 0) throw new Error("reputation must be > 0");
this.reputation.set(advertiserDid, score);
}
private escrowCampaign(campaign: Campaign): void {
if (this.escrowed.has(campaign.id)) return;
this.settlement.escrow(
campaign.id,
campaign.advertiser_did,
campaign.budget.total,
campaign.budget.currency
);
this.escrowed.add(campaign.id);
}
// --- serving ------------------------------------------------------------
requestAds(request: AdRequest, opts: RequestOptions = {}): AdResponse {
const requestId = `req-${(requestCounter += 1).toString(36)}-${this.clock().toString(36)}`;
const validation = assertValidSafe("agentad-ad-request", request);
if (!validation) return this.noFill(requestId, "invalid_request");
const placement = this.placements.get(request.placement_id);
if (!placement) return this.noFill(requestId, "invalid_request");
const now = opts.now ?? this.clock();
const day = dayKey(now);
const session = opts.sessionId ?? "default";
const consumer: Consumer = request.consumer ?? "human";
const freqRemaining = this.frequencyRemaining(placement, session, day);
if (freqRemaining <= 0) return this.noFill(requestId, "frequency_capped");
const ctx = contextKeywords(placement, request);
let blockedByCategory = false;
const candidates: Candidate[] = [];
for (const ad of this.ads.values()) {
const campaign = ad.campaign_id ? this.campaigns.get(ad.campaign_id) : undefined;
if (!campaign || (campaign.status ?? "draft") !== "active") continue;
if (isExpired(ad, now)) continue;
if (request.format_override && ad.format !== request.format_override) continue;
if (!placement.accepted_formats.includes(ad.format)) continue;
if (ad.targeting?.surfaces && !ad.targeting.surfaces.includes(placement.surface)) continue;
const category = adCategory(ad);
if (category && placement.block_categories?.includes(category)) {
blockedByCategory = true;
continue;
}
if (category && placement.allow_categories && !placement.allow_categories.includes(category)) {
blockedByCategory = true;
continue;
}
if (ad.targeting?.exclude_keywords?.some((k) => ctx.has(k.toLowerCase()))) continue;
if (this.settlement.remaining(campaign.id) <= 0) continue;
if (
campaign.budget.daily_cap != null &&
(this.dailySpend.get(`${campaign.id}:${day}`) ?? 0) >= campaign.budget.daily_cap
) {
continue;
}
const relevance = overlapCount(ad.targeting?.keywords, ctx);
const ev = this.effectiveValue(ad);
const rep = this.reputation.get(ad.advertiser_did) ?? 1;
const score = ev * (1 + this.relevanceWeight * relevance) * rep;
candidates.push({ ad, campaign, ev, score });
}
if (candidates.length === 0) {
return this.noFill(requestId, blockedByCategory ? "blocked_category" : "no_inventory");
}
candidates.sort((a, b) => b.score - a.score || b.ev - a.ev || a.ad.id.localeCompare(b.ad.id));
const wanted = Math.min(request.count ?? 1, candidates.length, freqRemaining);
const served: ServedAd[] = [];
const exp = now + this.tokenTtlMs;
for (let i = 0; i < wanted; i += 1) {
const winner = candidates[i];
const next = candidates[i + 1];
const clearingFactor = next && winner.ev > 0 ? clamp(next.ev / winner.ev, 0, 1) : 1;
const charge = this.unitCharge(winner.ad, clearingFactor);
const currency = winner.ad.pricing?.currency ?? winner.campaign.budget.currency;
const token = mintToken(this.secret, {
k: "impression",
rid: requestId,
pid: placement.id,
aid: winner.ad.id,
cid: winner.campaign.id,
model: winner.ad.pricing?.model ?? "flat",
charge,
cur: currency,
exp,
n: nonce()
});
served.push({
ad: winner.ad,
impression_token: token,
rendered: render(winner.ad, consumer)
});
this.bumpFrequency(placement, session, day);
}
const response: AdResponse = {
type: "agentad.ad_response",
version: AGENTAD_VERSION,
request_id: requestId,
ads: served
};
assertValid("agentad-ad-response", response);
return response;
}
// --- metering -----------------------------------------------------------
confirmImpression(
token: string,
opts: { now?: number; consumer?: Consumer } = {}
): ConfirmImpressionResult {
const now = opts.now ?? this.clock();
const payload = this.acceptToken(token, "impression", now);
const impression: Impression = {
type: "agentad.impression",
version: AGENTAD_VERSION,
impression_token: token,
ad_id: payload.aid,
placement_id: payload.pid,
...(opts.consumer ? { consumer: opts.consumer } : {}),
occurred_at: new Date(now).toISOString()
};
assertValid("agentad-impression", impression);
let charged = 0;
if (payload.model === "cpm" || payload.model === "flat") {
charged = this.settle(payload, "impression", now);
} else {
this.record("impression", payload, 0, now);
}
const clickToken = mintToken(this.secret, {
...payload,
k: "click",
n: nonce(),
exp: now + this.tokenTtlMs
});
return { impression, click_token: clickToken, charged };
}
confirmClick(
token: string,
opts: { now?: number; consumer?: Consumer; action?: ClickAction } = {}
): Click {
const now = opts.now ?? this.clock();
const payload = this.acceptToken(token, "click", now);
const action: ClickAction = opts.action ?? "click";
const click: Click = {
type: "agentad.click",
version: AGENTAD_VERSION,
click_token: token,
ad_id: payload.aid,
placement_id: payload.pid,
action,
...(opts.consumer ? { consumer: opts.consumer } : {}),
occurred_at: new Date(now).toISOString()
};
assertValid("agentad-click", click);
const billable =
payload.model === "cpc" || (payload.model === "cpa" && action === "convert");
if (billable) {
this.settle(payload, "click", now, action);
} else {
this.record("click", payload, 0, now, action);
}
return click;
}
// --- reporting ----------------------------------------------------------
ledger(): readonly LedgerEntry[] {
return this.ledgerEntries;
}
remaining(campaignId: string): number {
return this.settlement.remaining(campaignId);
}
earnings(publisherDid: string): number {
return this.settlement.earnings(publisherDid);
}
// --- internals ----------------------------------------------------------
private settle(
payload: TokenLike,
kind: "impression" | "click",
now: number,
action?: ClickAction
): number {
const placement = this.placements.get(payload.pid);
const publisherDid = placement?.publisher_did ?? "unknown.publisher";
const charged = this.settlement.charge({
campaignId: payload.cid,
publisherDid,
amount: payload.charge,
currency: payload.cur
});
if (charged > 0) {
const key = `${payload.cid}:${dayKey(now)}`;
this.dailySpend.set(key, (this.dailySpend.get(key) ?? 0) + charged);
}
this.record(kind, payload, charged, now, action);
return charged;
}
private record(
kind: "impression" | "click",
payload: TokenLike,
amount: number,
now: number,
action?: ClickAction
): void {
this.ledgerEntries.push({
kind,
campaign_id: payload.cid,
ad_id: payload.aid,
placement_id: payload.pid,
...(action ? { action } : {}),
amount,
currency: payload.cur,
occurred_at: new Date(now).toISOString()
});
}
private acceptToken(token: string, kind: "impression" | "click", now: number): TokenLike {
const result = verifyToken(this.secret, token, now);
if (!result.ok || !result.payload) {
throw new Error(`invalid ${kind} token: ${result.reason ?? "unknown"}`);
}
if (result.payload.k !== kind) {
throw new Error(`expected a ${kind} token, got ${result.payload.k}`);
}
if (this.usedTokens.has(token)) {
throw new Error(`${kind} token already consumed`);
}
this.usedTokens.add(token);
return result.payload;
}
private effectiveValue(ad: Ad): number {
const p = ad.pricing;
if (!p) return 0;
switch (p.model) {
case "cpm":
return p.bid / 1000;
case "flat":
return p.bid;
case "cpc":
return p.bid * this.expectedCtr;
case "cpa":
return p.bid * this.expectedCtr * this.expectedCvr;
default:
return 0;
}
}
private unitCharge(ad: Ad, clearingFactor: number): number {
const p = ad.pricing;
if (!p) return 0;
const base = p.model === "cpm" ? p.bid / 1000 : p.bid;
return round(base * clearingFactor);
}
private frequencyRemaining(placement: Placement, session: string, day: string): number {
const caps = placement.frequency_cap;
const perSession =
caps?.max_per_session != null
? caps.max_per_session - (this.sessionCount.get(`${placement.id}:${session}`) ?? 0)
: Number.POSITIVE_INFINITY;
const perDay =
caps?.max_per_day != null
? caps.max_per_day - (this.dayCount.get(`${placement.id}:${day}`) ?? 0)
: Number.POSITIVE_INFINITY;
return Math.min(perSession, perDay);
}
private bumpFrequency(placement: Placement, session: string, day: string): void {
const sKey = `${placement.id}:${session}`;
const dKey = `${placement.id}:${day}`;
this.sessionCount.set(sKey, (this.sessionCount.get(sKey) ?? 0) + 1);
this.dayCount.set(dKey, (this.dayCount.get(dKey) ?? 0) + 1);
}
private noFill(requestId: string, reason: NoFillReason): AdResponse {
return {
type: "agentad.ad_response",
version: AGENTAD_VERSION,
request_id: requestId,
ads: [],
no_fill_reason: reason
};
}
}
interface Candidate {
ad: Ad;
campaign: Campaign;
ev: number;
score: number;
}
interface TokenLike {
k: "impression" | "click";
rid: string;
pid: string;
aid: string;
cid: string;
model: "cpm" | "cpc" | "cpa" | "flat";
charge: number;
cur: string;
exp: number;
n: string;
}
// assertValid variant that returns a boolean instead of throwing.
function assertValidSafe(kind: Parameters<typeof assertValid>[0], data: unknown): boolean {
try {
assertValid(kind, data);
return true;
} catch {
return false;
}
}
function adCategory(ad: Ad): string | undefined {
const value = ad.machine_readable?.category;
return typeof value === "string" ? value : undefined;
}
function contextKeywords(placement: Placement, request: AdRequest): Set<string> {
const set = new Set<string>();
for (const tag of placement.context_tags ?? []) set.add(tag.toLowerCase());
for (const kw of request.context?.keywords ?? []) set.add(kw.toLowerCase());
return set;
}
function overlapCount(keywords: string[] | undefined, ctx: Set<string>): number {
if (!keywords) return 0;
let n = 0;
for (const kw of keywords) if (ctx.has(kw.toLowerCase())) n += 1;
return n;
}
function render(ad: Ad, consumer: Consumer): string {
if (consumer === "agent") {
return JSON.stringify({
sponsored: true,
advertiser: ad.disclosure.advertiser_name ?? ad.advertiser_did,
title: ad.title,
url: ad.url,
...(ad.cta ? { cta: ad.cta } : {}),
data: ad.machine_readable ?? {}
});
}
if (ad.format === "banner" && ad.media?.ansi_art) {
return `[${ad.disclosure.label}]\n${ad.media.ansi_art}\n${ad.title}${ad.url}`;
}
const lines = [`[${ad.disclosure.label}] ${ad.title}`];
if (ad.body) lines.push(ad.body);
lines.push(ad.cta ? `${ad.cta}: ${ad.url}` : ad.url);
return lines.join("\n");
}
function isExpired(ad: Ad, now: number): boolean {
if (!ad.expires_at) return false;
const t = Date.parse(ad.expires_at);
return Number.isFinite(t) && t < now;
}
function dayKey(now: number): string {
return new Date(now).toISOString().slice(0, 10);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function round(value: number): number {
return Math.round(value * 1e6) / 1e6;
}
function nonce(): string {
return Math.random().toString(36).slice(2, 10);
}

View file

@ -0,0 +1,41 @@
// @logicsrc/agentad — AgentAd Marketplace reference exchange.
// See docs/agentad-marketplace.md (AgentBBS milestone M5) and docs/agentad.md.
export * from "./types.js";
export {
AGENTAD_VERSION,
createAd,
createCampaign,
createPlacement,
createAdRequest,
type NewAd,
type NewCampaign,
type NewPlacement,
type NewAdRequest
} from "./builders.js";
export {
validate,
assertValid,
agentAdSchemas,
type AgentAdSchemaKind,
type ValidationResult
} from "./validate.js";
export {
mintToken,
verifyToken,
type TokenKind,
type TokenPayload,
type VerifyResult
} from "./tokens.js";
export {
InMemorySettlement,
type SettlementProvider,
type InMemorySettlementOptions
} from "./settlement.js";
export {
AgentAdExchange,
type AgentAdExchangeOptions,
type RequestOptions,
type ConfirmImpressionResult,
type LedgerEntry
} from "./exchange.js";

View file

@ -0,0 +1,100 @@
// Settlement abstraction. In production this is backed by the CoinPay plugin
// (DID balances, escrow, payouts). The in-memory implementation here is the
// reference used by tests and local development; it enforces the one invariant
// that matters: an advertiser can never be charged beyond what it escrowed.
export interface SettlementProvider {
/** Lock `amount` of an advertiser's balance to a campaign. */
escrow(campaignId: string, advertiserDid: string, amount: number, currency: string): void;
/** How much escrow remains unspent for a campaign. */
remaining(campaignId: string): number;
/**
* Charge the advertiser and credit the publisher (minus network fee).
* Returns the amount actually charged (0 if escrow was exhausted).
*/
charge(input: {
campaignId: string;
publisherDid: string;
amount: number;
currency: string;
}): number;
/** Total credited to a publisher, net of fees. */
earnings(publisherDid: string): number;
/** Total network fee collected. */
fees(): number;
}
interface EscrowRecord {
advertiserDid: string;
currency: string;
locked: number;
spent: number;
}
export interface InMemorySettlementOptions {
/** Network take rate, 0..1. Default 0.15. */
networkFeeRate?: number;
}
export class InMemorySettlement implements SettlementProvider {
private readonly escrows = new Map<string, EscrowRecord>();
private readonly publisherEarnings = new Map<string, number>();
private feePool = 0;
private readonly feeRate: number;
constructor(options: InMemorySettlementOptions = {}) {
const rate = options.networkFeeRate ?? 0.15;
if (rate < 0 || rate >= 1) {
throw new Error(`networkFeeRate must be in [0, 1), got ${rate}`);
}
this.feeRate = rate;
}
escrow(campaignId: string, advertiserDid: string, amount: number, currency: string): void {
if (amount < 0) throw new Error("escrow amount must be >= 0");
const existing = this.escrows.get(campaignId);
if (existing) {
if (existing.currency !== currency) {
throw new Error(`campaign ${campaignId} escrow currency mismatch`);
}
existing.locked += amount;
return;
}
this.escrows.set(campaignId, { advertiserDid, currency, locked: amount, spent: 0 });
}
remaining(campaignId: string): number {
const rec = this.escrows.get(campaignId);
if (!rec) return 0;
return Math.max(0, rec.locked - rec.spent);
}
charge(input: { campaignId: string; publisherDid: string; amount: number; currency: string }): number {
const rec = this.escrows.get(input.campaignId);
if (!rec || input.amount <= 0) return 0;
if (rec.currency !== input.currency) {
throw new Error(`campaign ${input.campaignId} charge currency mismatch`);
}
const available = Math.max(0, rec.locked - rec.spent);
const charged = Math.min(available, input.amount);
if (charged <= 0) return 0;
rec.spent += charged;
const fee = charged * this.feeRate;
this.feePool += fee;
this.publisherEarnings.set(
input.publisherDid,
(this.publisherEarnings.get(input.publisherDid) ?? 0) + (charged - fee)
);
return charged;
}
earnings(publisherDid: string): number {
return this.publisherEarnings.get(publisherDid) ?? 0;
}
fees(): number {
return this.feePool;
}
}

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { mintToken, verifyToken, type TokenPayload } from "./tokens.js";
const base: TokenPayload = {
k: "impression",
rid: "req-1",
pid: "plc-1",
aid: "ad-1",
cid: "cmp-1",
model: "cpc",
charge: 0.5,
cur: "USD",
exp: 2_000_000_000_000,
n: "abc123"
};
describe("AgentAd tracking tokens", () => {
it("round-trips a signed payload", () => {
const token = mintToken("s3cret", base);
const result = verifyToken("s3cret", token, 1_000);
expect(result.ok).toBe(true);
expect(result.payload).toEqual(base);
});
it("rejects a token signed with a different secret", () => {
const token = mintToken("s3cret", base);
const result = verifyToken("other", token, 1_000);
expect(result.ok).toBe(false);
expect(result.reason).toBe("bad_signature");
});
it("rejects a tampered body", () => {
const token = mintToken("s3cret", base);
const [, sig] = token.split(".");
const forged = `${Buffer.from(JSON.stringify({ ...base, charge: 9999 })).toString("base64url")}.${sig}`;
expect(verifyToken("s3cret", forged).ok).toBe(false);
});
it("rejects expired tokens", () => {
const token = mintToken("s3cret", { ...base, exp: 500 });
const result = verifyToken("s3cret", token, 1_000);
expect(result.ok).toBe(false);
expect(result.reason).toBe("expired");
});
it("flags malformed tokens", () => {
expect(verifyToken("s3cret", "not-a-token").reason).toBe("malformed");
});
});

View file

@ -0,0 +1,78 @@
// Signed, single-use tracking tokens for impressions and clicks. The exchange
// mints an impression_token when it serves an ad; confirming that impression
// mints a click_token. Tokens are HMAC-signed so a publisher cannot forge a
// billable event, and they carry the pricing context needed to settle.
import { createHmac, timingSafeEqual } from "node:crypto";
export type TokenKind = "impression" | "click";
export interface TokenPayload {
/** Token kind. */
k: TokenKind;
/** request_id the token was minted for. */
rid: string;
/** placement id. */
pid: string;
/** ad id. */
aid: string;
/** campaign id. */
cid: string;
/** pricing model of the winning ad. */
model: "cpm" | "cpc" | "cpa" | "flat";
/** per-unit charge for the winner at the second-price clearing level. */
charge: number;
/** currency code. */
cur: string;
/** unix ms expiry. */
exp: number;
/** random nonce to keep tokens unique + single-use. */
n: string;
}
function b64url(input: Buffer | string): string {
return Buffer.from(input).toString("base64url");
}
function sign(secret: string, body: string): string {
return createHmac("sha256", secret).update(body).digest("base64url");
}
export function mintToken(secret: string, payload: TokenPayload): string {
const body = b64url(JSON.stringify(payload));
return `${body}.${sign(secret, body)}`;
}
export interface VerifyResult {
ok: boolean;
reason?: "malformed" | "bad_signature" | "expired";
payload?: TokenPayload;
}
export function verifyToken(secret: string, token: string, now = Date.now()): VerifyResult {
const dot = token.indexOf(".");
if (dot <= 0) return { ok: false, reason: "malformed" };
const body = token.slice(0, dot);
const sig = token.slice(dot + 1);
const expected = sign(secret, body);
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return { ok: false, reason: "bad_signature" };
}
let payload: TokenPayload;
try {
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as TokenPayload;
} catch {
return { ok: false, reason: "malformed" };
}
if (typeof payload.exp === "number" && payload.exp < now) {
return { ok: false, reason: "expired", payload };
}
return { ok: true, payload };
}

View file

@ -0,0 +1,128 @@
// TypeScript contract types mirroring the @logicsrc/schemas agentad-*.schema.json
// documents. These are hand-maintained views of the canonical JSON Schemas; the
// runtime source of truth is validation via ./validate.ts.
export type AdFormat = "text" | "markdown" | "ansi" | "banner" | "json";
export type Surface = "cli" | "tui" | "agent" | "ci";
export type PricingModel = "cpm" | "cpc" | "cpa" | "flat";
export type Consumer = "human" | "agent";
export type CampaignStatus = "draft" | "active" | "paused" | "completed";
export type ClickAction = "click" | "open_url" | "copy_command" | "install" | "convert";
export type NoFillReason = "no_inventory" | "frequency_capped" | "blocked_category" | "invalid_request";
export interface Disclosure {
sponsored: true;
label: string;
advertiser_name?: string;
}
export interface AdPricing {
model: PricingModel;
bid: number;
currency: string;
}
export interface AdTargeting {
surfaces?: Surface[];
keywords?: string[];
tools?: string[];
languages?: string[];
exclude_keywords?: string[];
}
export interface Ad {
type: "agentad.ad";
version: string;
id: string;
campaign_id?: string;
advertiser_did: string;
format: AdFormat;
title: string;
body?: string;
url: string;
cta?: string;
disclosure: Disclosure;
machine_readable?: Record<string, unknown>;
targeting?: AdTargeting;
media?: { ansi_art?: string; icon?: string };
pricing?: AdPricing;
expires_at?: string;
}
export interface CampaignBudget {
total: number;
daily_cap?: number;
currency: string;
}
export interface Campaign {
type: "agentad.campaign";
version: string;
id: string;
advertiser_did: string;
name: string;
status?: CampaignStatus;
budget: CampaignBudget;
schedule?: { start_at?: string; end_at?: string };
ad_ids?: string[];
}
export interface Placement {
type: "agentad.placement";
version: string;
id: string;
publisher_did: string;
surface: Surface;
accepted_formats: AdFormat[];
dimensions?: { max_width?: number; max_lines?: number };
context_tags?: string[];
frequency_cap?: { max_per_session?: number; max_per_day?: number };
allow_categories?: string[];
block_categories?: string[];
}
export interface AdRequest {
type: "agentad.ad_request";
version: string;
placement_id: string;
publisher_did?: string;
consumer?: Consumer;
context?: { tool?: string; keywords?: string[]; language?: string; locale?: string };
format_override?: AdFormat;
count?: number;
}
export interface ServedAd {
ad: Ad;
impression_token: string;
rendered?: string;
}
export interface AdResponse {
type: "agentad.ad_response";
version: string;
request_id: string;
ads: ServedAd[];
no_fill_reason?: NoFillReason;
}
export interface Impression {
type: "agentad.impression";
version: string;
impression_token: string;
ad_id: string;
placement_id?: string;
consumer?: Consumer;
occurred_at: string;
}
export interface Click {
type: "agentad.click";
version: string;
click_token: string;
ad_id: string;
placement_id?: string;
action?: ClickAction;
consumer?: Consumer;
occurred_at: string;
}

View file

@ -0,0 +1,72 @@
// Runtime validation against the canonical AgentAd JSON Schemas. Mirrors the
// approach in @logicsrc/validators: import the schema documents directly from
// @logicsrc/schemas and compile them with Ajv 2020. Keeping this local (rather
// than importing the built @logicsrc/validators dist) lets the exchange and its
// tests run straight from source with no cross-package build step.
import * as Ajv2020Module from "ajv/dist/2020.js";
import * as addFormatsModule from "ajv-formats";
import type { ErrorObject } from "ajv";
import adSchema from "../../schemas/schemas/agentad-ad.schema.json" with { type: "json" };
import placementSchema from "../../schemas/schemas/agentad-placement.schema.json" with { type: "json" };
import adRequestSchema from "../../schemas/schemas/agentad-ad-request.schema.json" with { type: "json" };
import adResponseSchema from "../../schemas/schemas/agentad-ad-response.schema.json" with { type: "json" };
import impressionSchema from "../../schemas/schemas/agentad-impression.schema.json" with { type: "json" };
import clickSchema from "../../schemas/schemas/agentad-click.schema.json" with { type: "json" };
import campaignSchema from "../../schemas/schemas/agentad-campaign.schema.json" with { type: "json" };
type CompiledValidator = { (data: unknown): boolean; errors?: ErrorObject[] | null };
const Ajv2020 = (Ajv2020Module as unknown as {
default: new (options: Record<string, unknown>) => { compile: (schema: unknown) => CompiledValidator };
}).default;
const addFormats = (addFormatsModule as unknown as { default: (ajv: unknown) => void }).default;
export const agentAdSchemas = {
"agentad-ad": adSchema,
"agentad-placement": placementSchema,
"agentad-ad-request": adRequestSchema,
"agentad-ad-response": adResponseSchema,
"agentad-impression": impressionSchema,
"agentad-click": clickSchema,
"agentad-campaign": campaignSchema
} as const;
export type AgentAdSchemaKind = keyof typeof agentAdSchemas;
export type ValidationResult =
| { ok: true; kind: AgentAdSchemaKind; data: unknown }
| { ok: false; kind: AgentAdSchemaKind; errors: ErrorObject[] };
const cache = new Map<AgentAdSchemaKind, CompiledValidator>();
function compiled(kind: AgentAdSchemaKind): CompiledValidator {
const existing = cache.get(kind);
if (existing) return existing;
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
const fn = ajv.compile(agentAdSchemas[kind]);
cache.set(kind, fn);
return fn;
}
export function validate(kind: AgentAdSchemaKind, data: unknown): ValidationResult {
const fn = compiled(kind);
const ok = fn(data);
return ok
? { ok: true, kind, data }
: { ok: false, kind, errors: fn.errors ?? [] };
}
/** Validate or throw with a readable message. Returns the value narrowed to T. */
export function assertValid<T>(kind: AgentAdSchemaKind, data: T): T {
const result = validate(kind, data);
if (!result.ok) {
const detail = result.errors
.map((e) => `${e.instancePath || "/"} ${e.message ?? "is invalid"}`)
.join("; ");
throw new Error(`Invalid ${kind} document: ${detail}`);
}
return data;
}