build-signals-agent · git:20260503.79c2835 · 2026-05-03 · sha256 6c487e15c89a3fcd
build-signals-agent git:20260503.79c2835A
Immutable. This exact content is served forever at /api/v1/blob/6c487e15c89a3fcd.
---
name: build-signals-agent
description: Use when building an AdCP signals agent, creating an audience data server, or standing up a data provider agent that serves targeting segments to buyers.
---
# Build a Signals Agent
## Overview
A signals agent serves audience segments to buyers for campaign targeting. Two tools: `get_signals` (discovery) and `activate_signal` (push to DSPs or sales agents). The business model — marketplace vs owned data — shapes every implementation decision. Determine that first.
> **Common shape gotchas:** `signal_ids[]` in `get_signals` is `signal_id[]` (provenance objects with `source`/`data_provider_domain`/`id`), not bare strings. `activate_signal` returns deployments with `ActivationKey` `oneOf` — for `type: 'key_value'` the `key` and `value` fields sit at the TOP level of `activation_key`, NOT nested under a `key_value` sub-field. See [SHAPE-GOTCHAS.md](../SHAPE-GOTCHAS.md) for the patterns adopters consistently get wrong on first pass.
## When to Use
- User wants to build an agent that serves audience/targeting data
- User mentions signals, segments, audiences, data provider, or CDP in the context of AdCP
- User references `get_signals`, `activate_signal`, or the signals protocol
**Not this skill:**
- Selling ad inventory (products, packages, media buys) → `skills/build-seller-agent/`
- Rendering creatives from briefs → that's a creative agent
- Building a client that _calls_ a signals agent → see `docs/getting-started.md`
**Often claimed alongside:** [`sales-social`](../build-seller-agent/SKILL.md) (walled-garden audience push), [`sales-non-guaranteed`](../build-seller-agent/SKILL.md) (DSP data marketplace as a side surface), [`audience-sync`](../build-seller-agent/SKILL.md) (identity-provider owned-graph activation). See [Common multi-specialism bundles](../../examples/README.md#common-multi-specialism-bundles).
## Specialisms This Skill Covers
| Specialism | Status | Delta | See |
| -------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `signal-marketplace` | stable | `signal_id.source: 'catalog'` + resolvable `data_provider_domain`; span ≥2 providers in demos; platform activations are **async** (`is_live: false` → poll to live) | [§ signal-marketplace](#specialism-signal-marketplace) |
| `signal-owned` | stable | `signal_id.source: 'agent'` + your `agent_url`; `value_type` constraints (`allowed_values` for categorical, `min`/`max` for numeric); `deployed_at` on deployments | [§ signal-owned](#specialism-signal-owned) |
## Protocol-Wide Requirements
Full treatment lives in `skills/build-seller-agent/SKILL.md` §Protocol-Wide Requirements and §Composing. Minimum viable pointers for a signals agent:
- **`idempotency_key`** on every mutating request (`activate_signal`, and any future mutating signals tools). Pass `createIdempotencyStore` to `createAdcpServerFromPlatform(platform, { idempotency })`.
- **Authentication** via `serve({ authenticate })` with `verifyApiKey`/`verifyBearer` from `@adcp/sdk/server`. Unauthenticated agents fail the universal `security_baseline` storyboard.
- **Signature-header transparency**: accept requests with `Signature-Input`/`Signature` headers even if you don't claim `signed-requests`.
## Before Writing Code
Determine these four things. Ask the user — don't guess.
### 1. Marketplace or Owned?
These are fundamentally different businesses.
**Marketplace** — aggregates third-party data providers (LiveRamp, Oracle Data Cloud, Lotame). Each signal traces to a `data_provider_domain` that buyers can verify via `adagents.json`. `signal_type: "marketplace"`, `signal_id.source: "catalog"`.
**Owned** — first-party data (retailer CDP, publisher contextual, CRM). Buyers trust your agent directly. `signal_type: "owned"` or `"custom"`, `signal_id.source: "agent"`.
### 2. What Segments?
Get specifics: names, definitions, what each represents. Push for 3-5 segments with variety. Each needs:
- Clear behavioral/demographic definition
- Realistic `coverage_percentage` (typically 5-30%)
- Value type: `binary` (in/out), `categorical` (tier levels — define the categories), or `numeric` (score range — define min/max)
### 3. Pricing
At least one pricing option per signal. Signals use `VendorPricingOption` (field: `model`), distinct from product `PricingOption` (field: `pricing_model`).
- `cpm` — `{ pricing_option_id: "po_cpm", model: "cpm", cpm: 2.50, currency: "USD" }`
- `percent_of_media` — `{ pricing_option_id: "po_pom", model: "percent_of_media", percent: 15, currency: "USD" }`
- `flat_fee` — `{ pricing_option_id: "po_flat", model: "flat_fee", amount: 5000, period: "monthly", currency: "USD" }`
### 4. Activation Destinations
If implementing `activate_signal`:
- **Platform** (DSP): `type: "platform"`, returns `activation_key: { type: "segment_id", segment_id: "..." }`
- **Agent** (sales agent): `type: "agent"`, returns `activation_key: { type: "key_value", key: "...", value: "..." }`
## Tools and Required Response Shapes
> **Before writing any handler's return statement, fetch [`docs/llms.txt`](../../docs/llms.txt) and grep for `#### \`<tool_name>\``(e.g.`#### \`get_signals\``) to read the exact required + optional field list.** The schema-derived contract lives there; this skill covers patterns, gotchas, and domain-specific examples. Strict response validation is on by default in dev — it will tell you the exact field path if you drift, so write the obvious thing and trust the contract.
>
> **Cross-cutting pitfalls matrix runs keep catching:**
>
> - **Declare `capabilities.specialisms: ['signal-marketplace'] as const` (or `'signal-owned'`) on the `DecisioningPlatform` you pass to `createAdcpServerFromPlatform`.** Value is `string[]` of enum ids (not `[{id, version}]`). Agents that don't declare their specialism fail the grader with "No applicable tracks found" even if every tool works — tracks are gated on the specialism claim.
**`get_adcp_capabilities`** — auto-generated by `createAdcpServerFromPlatform` from your typed `DecisioningPlatform`. Do not register manually.
**`get_signals`** — handled by `signals.getSignals`
Two discovery modes — support both:
1. `signal_spec` — natural language. Match against segment names and descriptions.
2. `signal_ids` — exact lookup by `{ source, data_provider_domain, id }` or `{ source, agent_url, id }`.
Plus filtering via `filters.catalog_types`, `filters.max_cpm`, `filters.min_coverage_percentage`, and `max_results`.
```
getSignalsResponse({
signals: [{
signal_agent_segment_id: string, // required - key for activate_signal
name: string, // required
description: string, // required
signal_type: 'marketplace' | 'owned' | 'custom', // required
data_provider: string, // required - your company name
coverage_percentage: number, // required - 0 to 100
deployments: [], // required - empty array (not live until activated)
pricing_options: [{ // required - at least one
pricing_option_id: string, // required
model: 'cpm', // required - discriminator
cpm: number, // required for cpm model
currency: 'USD', // required
}],
// signal_id is critical — shape depends on marketplace vs owned:
signal_id: {
source: 'catalog', // marketplace
data_provider_domain: string, // marketplace — domain for provenance verification
id: string, // unique segment ID
},
// OR for owned:
signal_id: {
source: 'agent', // owned
agent_url: string, // your agent URL
id: string,
},
value_type: 'binary' | 'categorical' | 'numeric', // optional but recommended
}],
sandbox: true, // for mock data
})
```
**`activate_signal`** — handled by `signals.activateSignal`
Look up by `signal_agent_segment_id`. Validate `pricing_option_id`. Return deployments matching the requested destinations. **Platform activation is async; agent activation is sync** — different shape per destination type, driven by the compliance contract:
```
activateSignalResponse({
deployments: [
// Platform (DSP) — ASYNC. First response returns is_live:false plus
// an ETA AND the planned activation_key. Buyer re-sends activate_signal
// to poll until is_live:true; final response adds deployed_at.
{
type: 'platform',
platform: string,
account: string | null, // echo from request
is_live: false, // flips true on completion
estimated_activation_duration_minutes: number, // present while activating
activation_key: { type: 'segment_id', segment_id: string }, // committed up front
deployed_at: string, // ISO; present when is_live:true
},
// Agent (sales-agent) — SYNC. First response is the final response.
{
type: 'agent',
agent_url: string,
is_live: true,
activation_key: { type: 'key_value', key: string, value: string },
deployed_at: string, // ISO timestamp
},
],
sandbox: true,
})
```
### Context and Ext Passthrough
The framework auto-echoes the request's `context` into every response — **do not set `context` yourself in your handler return values.** It's injected post-handler only when the field isn't already present.
**Crucial:** `context` is schema-typed as an object. If your handler hand-sets a string or narrative description, validation fails with `/context: must be object` and the framework does not overwrite. Leave the field out entirely; the framework handles it.
Some schemas also define an `ext` field for vendor-namespaced extensions. If your request schema includes `ext`, accept it without error. Tools with explicit `ext` support: `activate_signal`.
## SDK Quick Reference
| SDK piece | Usage |
| ----------------------------------------------------- | ------------------------------------------------------------------------------ |
| `createAdcpServerFromPlatform(platform, opts)` | Create server from a typed `DecisioningPlatform` — compile-time specialism enforcement, auto-capabilities |
| `createAdcpServer(config)` *(legacy)* | v5 handler-bag entry. Mid-migration / escape-hatch only; reach via `@adcp/sdk/server/legacy/v5` |
| `serve(() => createAdcpServerFromPlatform(platform, opts))` | Start HTTP server on `:3001/mcp` |
| `signals: { getSignals, activateSignal }` | Domain group — register handlers by name |
| `ctx.store.put(collection, id, data)` | Persist state (activations, segment cache) across requests |
| `ctx.store.get(collection, id)` | Retrieve persisted state |
| `getSignalsResponse(data)` | Auto-applied response builder (don't call manually) |
| `activateSignalResponse(data)` | Auto-applied response builder (don't call manually) |
| `adcpError(code, { message })` | Structured error (`SIGNAL_NOT_FOUND`, `INVALID_DESTINATION`) |
| `createIdempotencyStore({ backend, ttlSeconds })` | Required on every mutating tool — pass via `createAdcpServerFromPlatform(platform, { idempotency })` |
| `memoryBackend()` / `pgBackend(pool)` | Idempotency backends (from `@adcp/sdk/server`) |
| `type Signal = GetSignalsResponse['signals'][number]` | Type for a single signal object |
Import: `import { createAdcpServerFromPlatform, serve, adcpError } from '@adcp/sdk/server';`
Server-only: `import { createIdempotencyStore, memoryBackend } from '@adcp/sdk/server';`
Types: `import type { GetSignalsResponse } from '@adcp/sdk';`
## Setup
```bash
npm init -y
npm install @adcp/sdk
npm install -D typescript @types/node
```
Minimal `tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"skipLibCheck": true,
"outDir": "dist"
}
}
```
`skipLibCheck: true` avoids false-positive errors from transitive `.d.ts` files (e.g., `@opentelemetry/api`).
## Implementation
1. Single `.ts` file — all tools in one file
2. Use `createAdcpServerFromPlatform` with `signals: SignalsPlatform` on a typed `DecisioningPlatform` class — `get_adcp_capabilities` is auto-generated
3. Handlers return raw data objects — response builders (`getSignalsResponse`, `activateSignalResponse`) are auto-applied
4. Use `ctx.store` for persisting signal activations across requests (InMemoryStateStore by default)
5. Set `sandbox: true` for mock/demo data
6. Context passthrough is handled by the framework — no need to manually echo `args.context`
```typescript
import {
createAdcpServerFromPlatform,
serve,
AdcpError,
createIdempotencyStore,
memoryBackend,
type DecisioningPlatform,
type SignalsPlatform,
type AccountStore,
} from '@adcp/sdk/server';
import type { GetSignalsResponse } from '@adcp/sdk';
// Type the catalog explicitly. Each entry MUST include `signal_agent_segment_id`
// — it's the key buyers pass to `activate_signal` and the field the framework
// uses to auto-store the signal for hydration on the activate call. If you
// omit it, `tsc` flags every entry; the response validator rejects the
// `get_signals` reply at runtime in dev/test (strict mode default).
type Signal = GetSignalsResponse['signals'][number];
const signals: Signal[] = [
/* your signal objects — `signal_agent_segment_id` is required on each */
];
// Publisher-internal storage for activations. Persist to your real DB in
// production; this Map is for the worked example. Note: a module-level Map
// is fine for single-tenant agents but leaks across tenants in a
// multi-tenant deployment — partition by `account.id` if you front many.
const activations = new Map<string, { destinations: unknown[]; activated_at: string }>();
// Idempotency — required for v3 compliance. `activate_signal` is mutating;
// `get_signals` is read-only and exempt from key validation.
const idempotency = createIdempotencyStore({
backend: memoryBackend(), // pgBackend(pool) for production
ttlSeconds: 86400, // 24 hours (spec bounds: 1h–7d)
});
class MySignals implements DecisioningPlatform {
capabilities = {
specialisms: ['signal-marketplace'] as const,
config: {},
};
// Single-tenant agent: AccountStore.resolve returns the same synthetic
// account every request. `upsert` and `list` are optional — omit them on
// stateless platforms (the framework returns `UNSUPPORTED_FEATURE` to
// buyers calling `sync_accounts` / `list_accounts`).
accounts: AccountStore = {
resolve: async () => ({
id: 'sg_acc_1',
operator: 'me',
ctx_metadata: {},
}),
};
signals: SignalsPlatform = {
getSignals: async params => {
let results = signals;
if (params.signal_spec) {
const query = params.signal_spec.toLowerCase();
results = results.filter(
s => s.name.toLowerCase().includes(query) || s.description.toLowerCase().includes(query)
);
}
if (params.signal_ids) {
results = results.filter(s => params.signal_ids!.some(id => id.id === s.signal_id.id));
}
return { signals: results, sandbox: true };
},
activateSignal: async params => {
const signal = signals.find(s => s.signal_agent_segment_id === params.signal_agent_segment_id);
// Throw `AdcpError` for buyer-fixable rejection. The framework
// catches the throw, projects to the spec's error envelope, and
// returns it to the buyer. Returning `adcpError(...)` from the
// success arm doesn't typecheck — `activateSignal` is declared
// `Promise<ActivateSignalSuccess>`, error is the throw path.
if (!signal) {
throw new AdcpError('SIGNAL_NOT_FOUND', {
recovery: 'correctable',
message: `Unknown segment: ${params.signal_agent_segment_id}`,
});
}
// Persist activation. Single-tenant: module-level Map is fine.
// Multi-tenant: partition by ctx.account.id (passed as 2nd arg).
activations.set(params.signal_agent_segment_id, {
destinations: params.destinations,
activated_at: new Date().toISOString(),
});
// Platform (DSP) activation is ASYNC per spec — return `is_live: false`
// with `estimated_activation_duration_minutes`. The buyer polls
// (a subsequent `activate_signal` with the same destinations, or a
// provider-specific status tool) until `is_live: true`.
//
// Agent (sales-agent) activation is SYNC — return `is_live: true` with
// `activation_key.type: 'key_value'` and a `deployed_at` timestamp.
//
// Both shapes include `activation_key` so the buyer knows how to
// reference the segment when building media buys through the DSP or SA.
const deployments = params.destinations.map(dest => {
if (dest.type === 'platform') {
return {
type: 'platform' as const,
platform: dest.platform,
is_live: false,
estimated_activation_duration_minutes: 30,
// Return activation_key even while is_live is false — the
// buyer needs to know the planned segment_id now so it can
// reference it in downstream media buys. `is_live` just
// flags whether the DSP has confirmed provisioning;
// `activation_key` is the agent's commitment.
activation_key: {
type: 'segment_id' as const,
segment_id: `${dest.platform}_${signal.signal_id.id}`,
},
};
}
return {
type: 'agent' as const,
agent_url: dest.agent_url,
is_live: true,
activation_key: { type: 'key_value' as const, key: 'audience', value: signal.signal_id.id },
deployed_at: new Date().toISOString(),
};
});
return { deployments, sandbox: true };
},
};
}
const platform = new MySignals();
serve(() =>
createAdcpServerFromPlatform(platform, {
name: 'My Signals Agent',
version: '1.0.0',
idempotency,
// Principal scoping for idempotency. MUST never return undefined.
resolveSessionKey: () => 'default-principal',
})
);
```
## Idempotency
AdCP v3 requires an `idempotency_key` on every mutating request — for signals agents that's `activate_signal` only (`get_signals` is read-only and exempt). Idempotency is already wired in the Implementation example above. The framework then handles:
- Missing/malformed key → `INVALID_REQUEST` (spec pattern `^[A-Za-z0-9_.:-]{16,255}$`)
- JCS-canonicalized payload hashing with same-key-different-payload → `IDEMPOTENCY_CONFLICT` (no payload leaked in the error body)
- Past-TTL replay → `IDEMPOTENCY_EXPIRED` (±60s clock-skew tolerance)
- Cache hits replay the cached envelope with `replayed: true` injected
- `adcp.idempotency.replay_ttl_seconds` auto-declared on `get_adcp_capabilities`
- Only successful responses cache — a failed activation re-executes on retry
- Atomic claim so concurrent retries with a fresh key don't all race to activate
Scoping is per-principal via `resolveSessionKey` (override with `resolveIdempotencyPrincipal` for custom scoping). `ttlSeconds` must be 3600–604800 — out of range throws at construction. If you register mutating handlers without wiring `idempotency`, the framework logs an error at server-creation time.
**Critical: probe the pool at boot (pgBackend).** `pg.Pool` is lazy — `new Pool({ connectionString })` does not validate the URL. A bad `DATABASE_URL` lets the server start, advertise `IdempotencySupported`, and then silently fail every `activate_signal` call. Wire `readinessCheck` on `serve()` so the server never accepts traffic with a broken pool:
```ts
const store = createIdempotencyStore({ backend: pgBackend(pool), ttlSeconds: 86400 });
pool.on('error', err => console.error('pg pool error', err)); // prevent crash on idle-client errors
serve(createAgent, {
readinessCheck: () => store.probe(), // throws with a descriptive error if pool/table is broken
});
```
## Protecting your agent
**An AdCP agent that accepts unauthenticated requests is non-compliant** (see `security_baseline` in the universal storyboard bundle). Ask the operator: "API key, OAuth, or both?" — then wire one of these into `serve()`.
```typescript
import { serve } from '@adcp/sdk';
import { verifyApiKey, verifyBearer, anyOf } from '@adcp/sdk/server';
// API key — simplest, good for B2B integrations
serve(createAgent, {
authenticate: verifyApiKey({
verify: async token => {
const row = await db.api_keys.findUnique({ where: { token } });
return row ? { principal: row.account_id } : null;
},
}),
});
// OAuth — best when buyers authenticate as themselves
const AGENT_URL = 'https://my-agent.example.com/mcp';
serve(createAgent, {
publicUrl: AGENT_URL, // canonical RFC 8707 audience — also served as `resource` in protected-resource metadata
authenticate: verifyBearer({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: AGENT_URL, // MUST equal publicUrl
}),
protectedResource: { authorization_servers: ['https://auth.example.com'] },
});
// Both
serve(createAgent, {
publicUrl: AGENT_URL,
authenticate: anyOf(verifyApiKey({ verify: lookupKey }), verifyBearer({ jwksUri, issuer, audience: AGENT_URL })),
protectedResource: { authorization_servers: [issuer] },
});
```
The framework produces RFC 6750-compliant `WWW-Authenticate: Bearer` 401s on failure, and serves `/.well-known/oauth-protected-resource<mountPath>` with `publicUrl` as the `resource` field so buyers get tokens bound to the right audience. The default JWT allowlist is asymmetric-only (RS*/ES*/PS\*/EdDSA) to prevent algorithm-confusion attacks.
## Validate Locally
**Full validation checklist:** [docs/guides/VALIDATE-YOUR-AGENT.md](../../docs/guides/VALIDATE-YOUR-AGENT.md). Signals-specific commands:
```bash
# Boot
npx tsx agent.ts &
# Happy path — the specialism you're claiming
npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp signal_owned --auth $TOKEN # owned data
npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp signal_marketplace --auth $TOKEN # marketplace
# Marketplace governance sub-scenario (if you claim signal_marketplace)
npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp signal_marketplace/governance_denied --auth $TOKEN
# Cross-cutting obligations
npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp \
--storyboards security_baseline,idempotency,schema_validation,error_compliance --auth $TOKEN
# Rejection-surface fuzz
npx @adcp/sdk@latest fuzz http://localhost:3001/mcp --tools get_signals --auth-token $TOKEN
```
Common failure decoder:
- `value_type` mismatch → `binary` vs. `continuous` — pick one; continuous signals return `value`, binary signals return membership
- Missing `deployments` on signal → required even if empty `[]`
- `activate_signal` returns sync success on a marketplace signal → marketplace activations are async; commit to an `activation_key` up-front and return `submitted`
- Missing `coverage_percentage` or `pricing_options` → required on every signal
**Keep iterating until all steps pass.** Can't bind ports? `npm run compliance:skill-matrix -- --filter signals` runs an isolated end-to-end test.
## Common Mistakes
| Mistake | Fix |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Using `createTaskCapableServer` + `server.tool()` | Use `createAdcpServerFromPlatform` with `signals: SignalsPlatform` on a typed `DecisioningPlatform` class |
| Calling `createAdcpServer` directly in new code | Reach for `createAdcpServerFromPlatform`; `createAdcpServer` lives at `@adcp/sdk/server/legacy/v5` for mid-migration / escape-hatch only |
| Using module-level Maps for state | Use `ctx.store.put/get` — framework provides `InMemoryStateStore` by default |
| Manually registering `get_adcp_capabilities` | Auto-generated by `createAdcpServerFromPlatform` from your typed `DecisioningPlatform` |
| Calling response builders manually | Handlers return raw data — `getSignalsResponse`/`activateSignalResponse` are auto-applied |
| Missing `signal_agent_segment_id` on signals | Buyers can't activate without it |
| Wrong `signal_id` shape | Marketplace: `{ source: "catalog", data_provider_domain, id }`. Owned: `{ source: "agent", agent_url, id }` |
| Missing `data_provider` field | Required on every signal — your company/brand name |
| Empty `pricing_options` array | Must have at least one pricing option per signal |
| `is_live: true` in get_signals deployments | Signals aren't live until `activate_signal` — use empty `deployments: []` |
| Activation doesn't match destination type | If request has `type: "platform"`, deployment must be `type: "platform"` |
| `sandbox: false` on mock data | Buyers may treat mock data as real |
## Specialism Details
### <a name="specialism-signal-marketplace"></a>signal-marketplace
**Async platform activation.** Unlike agent activations (`type: 'agent'`), platform activations (`type: 'platform'`) are not instant — the segment takes minutes-to-hours to propagate to the DSP. Return `is_live: false` with an estimate, then the buyer polls `activate_signal` again until `is_live: true`.
```typescript
activateSignal: async (params, ctx) => {
const signal = signals.find((s) => s.signal_agent_segment_id === params.signal_agent_segment_id);
if (!signal) return adcpError('SIGNAL_NOT_FOUND', { message: `Unknown segment` });
const deployments = await Promise.all(params.destinations.map(async (dest) => {
if (dest.type === 'platform') {
// Async — check whether this destination has already been propagated
const existing = await ctx.store.get('deployments', `${params.signal_agent_segment_id}:${dest.platform}`);
if (existing?.is_live) return existing;
if (!existing) {
const pending = {
type: 'platform' as const,
platform: dest.platform,
account: dest.account ?? null,
is_live: false,
estimated_activation_duration_minutes: 45,
// Commit the activation_key up front so the buyer can trust it across the poll window:
activation_key: {
type: 'segment_id' as const,
segment_id: `${dest.platform}_${signal.signal_id.id}`,
},
};
await ctx.store.put('deployments', `${params.signal_agent_segment_id}:${dest.platform}`, pending);
return pending;
}
return existing; // still propagating
}
// Agent activations are instant
return {
type: 'agent' as const,
agent_url: dest.agent_url,
is_live: true,
deployed_at: new Date().toISOString(),
activation_key: { type: 'key_value' as const, key: 'audience', value: signal.signal_id.id },
};
}));
return { deployments, sandbox: true };
},
```
Use `forceDeploymentStatus` in your `TestControllerStore` (if you implement compliance_testing) to flip pending deployments to live for deterministic tests.
**Provenance.** `data_provider_domain` must be resolvable — buyers fetch `https://{domain}/adagents.json` out-of-band to verify the provider. Use real domains even in demos, not `example.com`. For a marketplace demo, seed ≥2 different `data_provider_domain` values so the multi-provider nature is visible.
### <a name="specialism-signal-owned"></a>signal-owned
**Value types** drive targeting semantics. The storyboard validates these fields:
```typescript
// Binary — in/out of the segment
{ value_type: 'binary' as const }
// Categorical — enumerated tier/level
{
value_type: 'categorical' as const,
allowed_values: ['bronze', 'silver', 'gold', 'platinum'], // required for categorical
}
// Numeric — continuous score or count
{
value_type: 'numeric' as const,
min: 0,
max: 100,
units: 'purchase_frequency_last_90d', // optional but recommended
}
```
`signal_type: 'custom'` is for first-party signals that don't fit the `owned` conceptual model (e.g. contextual signals derived from page content rather than user identity). Use `owned` by default; pick `custom` only when the user data model differs materially.
Platform activations are async (same pattern as marketplace). Agent activations include `deployed_at`:
```typescript
{
type: 'agent' as const,
agent_url: dest.agent_url,
is_live: true,
deployed_at: new Date().toISOString(),
activation_key: { type: 'key_value' as const, key: 'audience', value: signal.signal_id.id },
}
```
## Reference
- `examples/signals-agent.ts` — complete runnable example
- `storyboards/signal_marketplace.yaml` — buyer call sequences for marketplace agent
- `storyboards/signal_owned.yaml` — call sequences for owned data agent
- `docs/guides/BUILD-AN-AGENT.md` — SDK patterns
- `docs/TYPE-SUMMARY.md` — curated type signatures
- `docs/llms.txt` — full protocol reference