diff --git a/.changeset/storyboard-ux-fixes.md b/.changeset/storyboard-ux-fixes.md
new file mode 100644
index 000000000..5757b8cec
--- /dev/null
+++ b/.changeset/storyboard-ux-fixes.md
@@ -0,0 +1,5 @@
+---
+"adcontextprotocol": patch
+---
+
+Storyboard UX: short-circuit comply() when products fail, inline agent connect form, filter picker by capabilities, OAuth auth fallback, generative creative storyboard
diff --git a/docs/storyboards/creative_generative.yaml b/docs/storyboards/creative_generative.yaml
new file mode 100644
index 000000000..1264f6d44
--- /dev/null
+++ b/docs/storyboards/creative_generative.yaml
@@ -0,0 +1,317 @@
+id: creative_generative
+version: "1.0.0"
+title: "Generative creative agent"
+category: creative_generative
+summary: "Agent that takes a brief and generates finished creatives from scratch — no input assets required."
+
+narrative: |
+ You run a generative creative platform — an AI ad network, a generative DSP, or any system
+ that creates ad creatives from a natural-language brief and brand identity. The buyer doesn't
+ push assets to you. Instead, they describe what they want, point you at a brand.json, and
+ your agent produces finished creatives ready for trafficking.
+
+ This is fundamentally different from template-based transformation (Celtra) or library-based
+ retrieval (Innovid). Your agent creates something new. The buyer sends a brief, optionally
+ with seed assets or constraints, and your platform generates creatives — potentially in
+ multiple formats at once.
+
+ This storyboard walks through the generation flow: format discovery, brief-driven generation,
+ multi-format builds, iterative refinement, and quality progression from draft to production.
+
+agent:
+ interaction_model: stateless_generate
+ capabilities:
+ - supports_generation
+ examples:
+ - "OpenAds"
+ - "AI ad networks"
+ - "Generative DSPs"
+
+caller:
+ role: buyer_agent
+ example: "Scope3 (DSP)"
+
+prerequisites:
+ description: |
+ The caller needs a brand identity (brand.json at the brand's domain) for the agent
+ to resolve visual identity — logos, colors, fonts, tone. The test kit provides a
+ sample brand with campaign parameters.
+ test_kit: "test-kits/acme-outdoor.yaml"
+
+phases:
+ - id: format_discovery
+ title: "Discover generative formats"
+ narrative: |
+ The buyer needs to know what your agent can generate. Unlike a template platform
+ where formats are fixed dimensions, generative formats describe what the agent
+ produces — the output type, constraints, and what inputs it accepts. A generative
+ format might be "display_300x250_generative" that accepts a brief and produces
+ a finished banner, or "social_post_generative" that creates platform-native content.
+
+ steps:
+ - id: discover_formats
+ title: "Discover available generative formats"
+ narrative: |
+ The buyer asks: "What can you generate?" Your platform returns the formats
+ you support. Each format describes the output type, dimensions, and what
+ inputs it needs (brief text, brand reference, seed images, etc.).
+ task: list_creative_formats
+ schema_ref: "creative/list-creative-formats-request.json"
+ response_schema_ref: "creative/list-creative-formats-response.json"
+ doc_ref: "/creative/task-reference/list_creative_formats"
+ comply_scenario: creative_sync
+ stateful: false
+ expected: |
+ Return your generative formats. Each format should include:
+ - format_id with your agent_url and a unique id
+ - Human-readable name and description
+ - Asset slots describing what inputs are accepted (brief text, images, etc.)
+ - Render dimensions for the output
+ - Variables for any dynamic fields the buyer can control
+
+ sample_request: {}
+
+ validations:
+ - check: response_schema
+ description: "Response matches list-creative-formats-response.json schema"
+ - check: field_present
+ path: "formats"
+ description: "Response contains a formats array"
+ - check: field_present
+ path: "formats[0].format_id.agent_url"
+ description: "Each format has a format_id with agent_url"
+
+ - id: generate_from_brief
+ title: "Generate from brief"
+ narrative: |
+ The buyer describes what they want in natural language and your agent generates
+ a creative from scratch. The brief includes the campaign message, target audience
+ context, and a brand reference so your agent can resolve visual identity.
+
+ This is the core generative flow. The buyer doesn't provide finished assets —
+ they provide intent, and your agent creates.
+
+ steps:
+ - id: build_draft
+ title: "Generate a draft creative from brief"
+ narrative: |
+ The buyer sends a brief describing the campaign and a target format. Your
+ agent generates a draft creative — fast, lower-fidelity output the buyer can
+ review before committing to a production build.
+
+ The brief is passed as a message alongside the target format. The brand
+ reference lets your agent resolve brand.json for visual identity.
+ task: build_creative
+ schema_ref: "media-buy/build-creative-request.json"
+ response_schema_ref: "media-buy/build-creative-response.json"
+ doc_ref: "/creative/task-reference/build_creative"
+ comply_scenario: creative_flow
+ stateful: false
+ expected: |
+ Return a generated creative manifest:
+ - creative_manifest with the generated assets (images, copy, serving code)
+ - format_id matching the target format
+ - If include_preview is true, include a preview render
+
+ The output should be a coherent creative that reflects the brief and brand
+ identity — not a template with placeholder text.
+
+ sample_request:
+ message: "Create a display banner for a summer outdoor gear sale. Bold, adventurous tone. Headline should emphasize 40% off. Target audience: active adults 25-54."
+ target_format_id:
+ agent_url: "https://your-agent.example.com"
+ id: "display_300x250_generative"
+ brand:
+ domain: "acme-outdoor.example.com"
+ quality: "draft"
+ include_preview: true
+
+ validations:
+ - check: response_schema
+ description: "Response matches build-creative-response.json schema"
+ - check: field_present
+ path: "creative_manifest.assets"
+ description: "Output manifest includes generated assets"
+ - check: field_present
+ path: "creative_manifest.format_id"
+ description: "Output manifest includes format_id"
+
+ - id: refine
+ title: "Refine the creative"
+ narrative: |
+ The buyer reviews the draft and wants changes. They send the generated manifest
+ back with refinement instructions. Your agent modifies the creative based on the
+ feedback — adjusting copy, swapping imagery, or changing the layout.
+
+ This is iterative: the buyer can refine multiple times until they're satisfied,
+ then request a production-quality build.
+
+ steps:
+ - id: refine_creative
+ title: "Refine with feedback"
+ narrative: |
+ The buyer passes the generated manifest back with a message describing what
+ to change. Your agent applies the refinements and returns an updated creative.
+ task: build_creative
+ schema_ref: "media-buy/build-creative-request.json"
+ response_schema_ref: "media-buy/build-creative-response.json"
+ doc_ref: "/creative/task-reference/build_creative"
+ comply_scenario: creative_flow
+ stateful: false
+ expected: |
+ Return a refined creative manifest reflecting the requested changes.
+ The output should preserve what worked in the original while applying
+ the refinements.
+
+ sample_request:
+ message: "Make the headline larger. Replace the mountain imagery with a trail running scene. Add a CTA button that says 'Shop Now'."
+ creative_manifest:
+ format_id:
+ agent_url: "https://your-agent.example.com"
+ id: "display_300x250_generative"
+ assets:
+ - asset_id: "generated_image"
+ asset_type: "image"
+ url: "https://your-agent.example.com/generated/abc123.jpg"
+ - asset_id: "headline"
+ asset_type: "text"
+ text: "Summer Sale — 40% Off All Gear"
+ target_format_id:
+ agent_url: "https://your-agent.example.com"
+ id: "display_300x250_generative"
+ brand:
+ domain: "acme-outdoor.example.com"
+ quality: "draft"
+ include_preview: true
+
+ validations:
+ - check: response_schema
+ description: "Response matches build-creative-response.json schema"
+ - check: field_present
+ path: "creative_manifest.assets"
+ description: "Refined manifest includes assets"
+
+ - id: multi_format
+ title: "Multi-format generation"
+ narrative: |
+ The buyer needs the creative in multiple sizes. Instead of generating each
+ format separately, they pass target_format_ids (plural) and your agent produces
+ all formats in a single call. This is where generative agents shine — adapting
+ a concept across formats while maintaining visual coherence.
+
+ steps:
+ - id: build_multi_format
+ title: "Generate for multiple formats"
+ narrative: |
+ The buyer passes the refined manifest with multiple target formats. Your
+ agent generates a creative for each format, adapting layout, copy, and
+ imagery to fit each size while maintaining brand consistency.
+ task: build_creative
+ schema_ref: "media-buy/build-creative-request.json"
+ response_schema_ref: "media-buy/build-creative-response.json"
+ doc_ref: "/creative/task-reference/build_creative"
+ comply_scenario: creative_flow
+ stateful: false
+ expected: |
+ Return creative manifests for each requested format in creative_manifests
+ (plural). Each manifest should:
+ - Have a format_id matching one of the target formats
+ - Contain complete, format-appropriate assets
+ - Maintain visual coherence across formats
+
+ If a format cannot be produced, include it with an error — don't fail
+ the entire request.
+
+ sample_request:
+ message: "Generate production-ready versions for all three sizes."
+ creative_manifest:
+ format_id:
+ agent_url: "https://your-agent.example.com"
+ id: "display_300x250_generative"
+ assets:
+ - asset_id: "generated_image"
+ asset_type: "image"
+ url: "https://your-agent.example.com/generated/abc123-refined.jpg"
+ - asset_id: "headline"
+ asset_type: "text"
+ text: "Summer Sale — 40% Off All Gear"
+ - asset_id: "cta"
+ asset_type: "text"
+ text: "Shop Now"
+ target_format_ids:
+ - agent_url: "https://your-agent.example.com"
+ id: "display_300x250_generative"
+ - agent_url: "https://your-agent.example.com"
+ id: "display_728x90_generative"
+ - agent_url: "https://your-agent.example.com"
+ id: "display_320x50_generative"
+ brand:
+ domain: "acme-outdoor.example.com"
+ quality: "production"
+
+ validations:
+ - check: response_schema
+ description: "Response matches build-creative-response.json schema"
+ - check: field_present
+ path: "creative_manifests"
+ description: "Response contains creative_manifests array (plural)"
+
+ - id: production_build
+ title: "Production build"
+ narrative: |
+ The buyer is satisfied with the concept and needs a final, production-quality
+ creative ready for trafficking. They switch quality from draft to production.
+ Your agent generates the finished output with full-fidelity assets.
+
+ steps:
+ - id: build_production
+ title: "Build at production quality"
+ narrative: |
+ The buyer requests a production-quality build of the approved concept. Your
+ agent generates the final creative with full-fidelity rendering, polished
+ assets, and serving code ready for trafficking.
+ task: build_creative
+ schema_ref: "media-buy/build-creative-request.json"
+ response_schema_ref: "media-buy/build-creative-response.json"
+ doc_ref: "/creative/task-reference/build_creative"
+ comply_scenario: creative_flow
+ stateful: false
+ expected: |
+ Return a production-quality creative manifest:
+ - Full-fidelity generated assets
+ - Serving code (HTML, JavaScript, or VAST) ready for trafficking
+ - Provenance metadata if AI-generated (digital_source_type, ai_tool)
+
+ sample_request:
+ message: "Final production build. No changes needed."
+ creative_manifest:
+ format_id:
+ agent_url: "https://your-agent.example.com"
+ id: "display_300x250_generative"
+ assets:
+ - asset_id: "generated_image"
+ asset_type: "image"
+ url: "https://your-agent.example.com/generated/abc123-refined.jpg"
+ - asset_id: "headline"
+ asset_type: "text"
+ text: "Summer Sale — 40% Off All Gear"
+ - asset_id: "cta"
+ asset_type: "text"
+ text: "Shop Now"
+ target_format_id:
+ agent_url: "https://your-agent.example.com"
+ id: "display_300x250_generative"
+ brand:
+ domain: "acme-outdoor.example.com"
+ quality: "production"
+ include_preview: true
+
+ validations:
+ - check: response_schema
+ description: "Response matches build-creative-response.json schema"
+ - check: field_present
+ path: "creative_manifest.assets"
+ description: "Production manifest includes assets"
+ - check: field_present
+ path: "creative_manifest.format_id"
+ description: "Production manifest includes format_id"
diff --git a/server/public/dashboard-agents.html b/server/public/dashboard-agents.html
index caeeee6b1..f7840d05b 100644
--- a/server/public/dashboard-agents.html
+++ b/server/public/dashboard-agents.html
@@ -214,6 +214,10 @@
font-weight: var(--font-semibold);
color: var(--color-primary-600);
text-decoration: none;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
}
.agent-action-link:hover {
text-decoration: underline;
@@ -228,6 +232,45 @@
color: var(--color-text-muted);
}
+ .agent-connect-form {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ align-items: flex-end;
+ padding: var(--space-3) 0;
+ }
+ .agent-connect-form label {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ font-size: var(--text-xs);
+ color: var(--color-text-secondary);
+ }
+ .agent-connect-form input,
+ .agent-connect-form select {
+ font-size: var(--text-sm);
+ padding: var(--space-1) var(--space-2);
+ border: var(--border-1) solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-card);
+ }
+ .agent-connect-form input { min-width: 260px; }
+ .agent-connect-save {
+ background: var(--color-primary-600);
+ color: white;
+ border: none;
+ padding: var(--space-1) var(--space-3);
+ border-radius: var(--radius-sm);
+ font-size: var(--text-sm);
+ cursor: pointer;
+ }
+ .agent-connect-save:hover { background: var(--color-primary-700); }
+ .agent-connect-save:disabled { opacity: 0.6; cursor: not-allowed; }
+ .agent-connect-msg {
+ font-size: var(--text-xs);
+ margin-top: var(--space-1);
+ }
+
.agent-lifecycle-select {
font-size: var(--text-xs);
padding: 2px var(--space-2);
@@ -544,6 +587,27 @@
Agents
`;
}
+ const platformTypes = [
+ ['display_ad_server', 'Display ad server'],
+ ['video_ad_server', 'Video ad server'],
+ ['social_platform', 'Social platform'],
+ ['pmax_platform', 'Performance max'],
+ ['dsp', 'DSP'],
+ ['retail_media', 'Retail media'],
+ ['search_platform', 'Search platform'],
+ ['audio_platform', 'Audio platform'],
+ ['creative_transformer', 'Creative transformer'],
+ ['creative_library', 'Creative library'],
+ ['creative_ad_server', 'Creative ad server'],
+ ['si_platform', 'SI platform'],
+ ['ai_ad_network', 'AI ad network'],
+ ['ai_platform', 'AI platform'],
+ ['generative_dsp', 'Generative DSP'],
+ ];
+ const platformTypeOptions = platformTypes.map(([val, label]) =>
+ '' + label + ' '
+ ).join('');
+
return agents.map(agent => {
const data = complianceMap?.get(agent.url);
const cs = data?.status;
@@ -564,11 +628,14 @@ Agents
not yet checked
-
- ${hasAuth
- ? 'Auth configured. Compliance check will run on the next heartbeat cycle.'
- : 'Connect this agent through
Addie to save credentials and start compliance monitoring.'}
-
+ ${hasAuth
+ ? 'Auth configured. Compliance check will run on the next heartbeat cycle.
'
+ : '' +
+ 'Auth token ' +
+ 'Auth typeBearer Basic ' +
+ 'Platform typeAuto-detect ' + platformTypeOptions + ' ' +
+ 'Save ' +
+ '
'}
Test your agent
@@ -647,12 +714,19 @@
Agents
+ ${!hasAuth ? '
' : ''}
@@ -693,6 +767,71 @@
Agents
}
});
+ // Connect agent toggle
+ document.addEventListener('click', function(e) {
+ const toggle = e.target.closest('.agent-connect-toggle');
+ if (!toggle) return;
+ const cardId = toggle.dataset.cardId;
+ const panel = document.getElementById(cardId + '-connect');
+ if (panel) {
+ panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
+ }
+ });
+
+ // Connect agent save
+ document.addEventListener('click', async function(e) {
+ const saveBtn = e.target.closest('.agent-connect-save');
+ if (!saveBtn) return;
+ const form = saveBtn.closest('.agent-connect-form');
+ if (!form) return;
+
+ const agentUrl = form.dataset.agentUrl;
+ const authToken = form.querySelector('[name="auth_token"]').value.trim();
+ const authType = form.querySelector('[name="auth_type"]').value;
+ const platformType = form.querySelector('[name="platform_type"]').value;
+
+ if (!authToken) {
+ form.querySelector('[name="auth_token"]').focus();
+ return;
+ }
+
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Saving...';
+
+ try {
+ const body = { auth_token: authToken, auth_type: authType };
+ if (platformType) body.platform_type = platformType;
+
+ const res = await fetch('/api/registry/agents/' + encodeURIComponent(agentUrl) + '/connect', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || 'Failed to connect');
+ }
+
+ // Replace the form with a success message and reload after a moment
+ const card = form.closest('.agent-compliance-card');
+ form.innerHTML = '
Connected. Compliance check will run on the next heartbeat cycle.
';
+ setTimeout(() => location.reload(), 1500);
+ } catch (err) {
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Save';
+ let msg = form.querySelector('.agent-connect-msg');
+ if (!msg) {
+ msg = document.createElement('div');
+ msg.className = 'agent-connect-msg';
+ form.appendChild(msg);
+ }
+ msg.style.color = 'var(--color-error-600)';
+ msg.textContent = err.message;
+ }
+ });
+
// Monitoring pause toggle
document.addEventListener('change', async function(e) {
const toggle = e.target.closest('.monitoring-pause-toggle');
@@ -952,10 +1091,21 @@
Agents
}
}
+ // Map storyboard category prefixes to agent tracks
+ function storyboardMatchesAgent(sb, agentTracks) {
+ if (!agentTracks) return true;
+ const cat = sb.category || '';
+ if (cat.startsWith('media_buy')) return !!agentTracks.media_buy && agentTracks.media_buy !== 'skip';
+ if (cat.startsWith('creative')) return !!agentTracks.creative && agentTracks.creative !== 'skip';
+ if (cat.startsWith('signal')) return !!agentTracks.signals && agentTracks.signals !== 'skip';
+ return true;
+ }
+
// Shared: render the storyboard picker into a panel
- async function renderStoryboardPicker(panel, agentUrl, cardId) {
+ async function renderStoryboardPicker(panel, agentUrl, cardId, agentTracks) {
panel.style.display = 'block';
panel.innerHTML = '
Loading storyboards... ';
+ if (agentTracks) panel.dataset.agentTracks = JSON.stringify(agentTracks);
try {
const res = await fetch('/api/storyboards', { credentials: 'include' });
const data = await res.json();
@@ -963,8 +1113,16 @@
Agents
panel.innerHTML = '
No storyboards available yet. ';
return;
}
- let html = '
';
- for (const sb of data.storyboards) {
+
+ const recommended = data.storyboards.filter(sb => storyboardMatchesAgent(sb, agentTracks));
+ const other = data.storyboards.filter(sb => !storyboardMatchesAgent(sb, agentTracks));
+
+ let html = '';
+ if (recommended.length > 0 && other.length > 0) {
+ html += '
Recommended for this agent
';
+ }
+ html += '
';
+ for (const sb of recommended) {
html += '
';
html += '
';
html += '
' + escapeHtml(sb.title) + '
';
@@ -974,6 +1132,24 @@
Agents
html += '
';
}
html += '
';
+
+ if (other.length > 0) {
+ html += '
';
+ html += 'Other storyboards (' + other.length + ') ';
+ html += '';
+ for (const sb of other) {
+ html += '
';
+ html += '
';
+ html += '
' + escapeHtml(sb.title) + '
';
+ html += '
' + escapeHtml(sb.summary) + '
';
+ html += '
';
+ html += '
' + sb.phase_count + ' phases, ' + sb.step_count + ' steps ';
+ html += '
';
+ }
+ html += '
';
+ html += ' ';
+ }
+
panel.innerHTML = html;
} catch {
panel.innerHTML = '
Failed to load storyboards. ';
@@ -992,7 +1168,9 @@
Agents
panel.style.display = 'none';
return;
}
- await renderStoryboardPicker(panel, agentUrl, cardId);
+ let agentTracks = null;
+ try { agentTracks = JSON.parse(storyboardBtn.dataset.agentTracks || 'null'); } catch {}
+ await renderStoryboardPicker(panel, agentUrl, cardId, agentTracks);
return;
}
@@ -1064,7 +1242,11 @@
Agents
const agentUrl = backBtn.dataset.agentUrl;
const cardId = backBtn.dataset.cardId;
const panel = document.getElementById(cardId + '-storyboard');
- if (panel) await renderStoryboardPicker(panel, agentUrl, cardId);
+ if (panel) {
+ let tracks = null;
+ try { tracks = JSON.parse(panel.dataset.agentTracks || 'null'); } catch {}
+ await renderStoryboardPicker(panel, agentUrl, cardId, tracks);
+ }
return;
}
@@ -1159,6 +1341,7 @@
Agents
}
panel.innerHTML = html;
+ panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch {
progressEl.textContent = 'Failed to run storyboard evaluation.';
runBtn.disabled = false;
@@ -1231,6 +1414,7 @@
Agents
}
panel.innerHTML = html;
+ panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch {
progressEl.textContent = 'Failed to run comparison.';
compareBtn.disabled = false;
diff --git a/server/src/addie/services/compliance-testing.ts b/server/src/addie/services/compliance-testing.ts
index f40d72ffc..d057b66b4 100644
--- a/server/src/addie/services/compliance-testing.ts
+++ b/server/src/addie/services/compliance-testing.ts
@@ -1,6 +1,7 @@
import {
testAllScenarios,
DEFAULT_SCENARIOS,
+ SCENARIO_REQUIREMENTS,
setAgentTesterLogger,
type TestOptions,
type TestResult,
@@ -420,6 +421,18 @@ function buildPlatformCoherence(
};
}
+/**
+ * Check whether every scenario in a track requires get_products.
+ * If so, the track cannot produce any results when products are unavailable.
+ */
+function isTrackProductDependent(track: ComplianceTrack): boolean {
+ const scenarios = TRACK_SCENARIOS[track];
+ return scenarios.every((scenario) => {
+ const reqs = SCENARIO_REQUIREMENTS[scenario];
+ return reqs && reqs.includes('get_products');
+ });
+}
+
const ALL_KNOWN_SCENARIOS = new Set
(
(Object.values(TRACK_SCENARIOS) as TestScenario[][]).flat(),
);
@@ -454,22 +467,83 @@ export async function comply(agentUrl: string, options: ComplyOptions = {}): Pro
? options.tracks
: (Object.keys(TRACK_SCENARIOS) as ComplianceTrack[]);
- const suite = await testAllScenarios(agentUrl, {
+ // Phase 1: Run core + products tracks to check product availability
+ const foundationTracks: ComplianceTrack[] = ['core', 'products'].filter(
+ (t) => requestedTracks.includes(t as ComplianceTrack),
+ ) as ComplianceTrack[];
+ const remainingTracks = requestedTracks.filter((t) => !foundationTracks.includes(t));
+
+ const foundationScenarios = scenarioList.filter((s) => {
+ const foundationScenarioSet = new Set(foundationTracks.flatMap((t) => TRACK_SCENARIOS[t]));
+ return foundationScenarioSet.has(s);
+ });
+
+ const foundationSuite = await testAllScenarios(agentUrl, {
...options,
- scenarios: scenarioList,
+ scenarios: foundationScenarios.length > 0 ? foundationScenarios : buildScenarioList(foundationTracks),
});
- const trackResults = buildTrackResults(requestedTracks, suite.results);
+ // Check if product discovery produced any passing results
+ const productsTrackResults = buildTrackResults(
+ foundationTracks.filter((t) => t === 'products'),
+ foundationSuite.results,
+ );
+ const productsAvailable = productsTrackResults.some(
+ (t) => t.status === 'pass' || t.status === 'partial',
+ );
+
+ // Phase 2: Run remaining tracks, skipping product-dependent ones if products failed
+ let phase2Tracks: ComplianceTrack[];
+ let skippedTracks: ComplianceTrack[];
+ if (productsAvailable || remainingTracks.length === 0) {
+ phase2Tracks = remainingTracks;
+ skippedTracks = [];
+ } else {
+ phase2Tracks = remainingTracks.filter((t) => !isTrackProductDependent(t));
+ skippedTracks = remainingTracks.filter((t) => isTrackProductDependent(t));
+ }
+
+ let allResults = [...foundationSuite.results];
+ let totalDurationMs = foundationSuite.total_duration_ms;
+
+ if (phase2Tracks.length > 0) {
+ const phase2ScenarioSet = new Set(phase2Tracks.flatMap((t) => TRACK_SCENARIOS[t]));
+ const phase2Scenarios = options.scenarios?.length
+ ? scenarioList.filter((s) => phase2ScenarioSet.has(s))
+ : buildScenarioList(phase2Tracks);
+
+ const phase2Suite = await testAllScenarios(agentUrl, {
+ ...options,
+ scenarios: phase2Scenarios,
+ });
+ allResults = [...allResults, ...phase2Suite.results];
+ totalDurationMs += phase2Suite.total_duration_ms;
+ }
+
+ // Build track results — skipped product-dependent tracks get 'skip' status automatically
+ // since they have no scenario results in allResults
+ const trackResults = buildTrackResults(requestedTracks, allResults);
+
const tracks_passed = trackResults.filter((track) => track.status === 'pass').length;
const tracks_failed = trackResults.filter((track) => track.status === 'fail').length;
const tracks_partial = trackResults.filter((track) => track.status === 'partial').length;
const tracks_skipped = trackResults.filter((track) => track.status === 'skip').length;
const observations = buildObservations(trackResults);
+
+ if (skippedTracks.length > 0) {
+ const skippedLabels = skippedTracks.map((t) => TRACK_LABELS[t]).join(', ');
+ observations.push({
+ severity: 'warning',
+ category: 'products',
+ message: `Product discovery failed — skipped dependent tracks: ${skippedLabels}. Fix product schema issues first.`,
+ });
+ }
+
const headline = `${tracks_passed} track${tracks_passed === 1 ? '' : 's'} passed, ${tracks_failed} failed, ${tracks_partial} partial, ${tracks_skipped} skipped`;
// Hard-fail agents that do not support v3
- const agentVersion = suite.agent_profile.adcp_version;
+ const agentVersion = foundationSuite.agent_profile.adcp_version;
const v3GateFailed = !agentVersion || agentVersion === 'v2';
if (v3GateFailed) {
observations.push({
@@ -482,7 +556,7 @@ export async function comply(agentUrl: string, options: ComplyOptions = {}): Pro
}
return {
- agent_profile: suite.agent_profile,
+ agent_profile: foundationSuite.agent_profile,
tracks: trackResults,
summary: {
headline: v3GateFailed ? `v3 required — ${headline}` : headline,
@@ -492,8 +566,8 @@ export async function comply(agentUrl: string, options: ComplyOptions = {}): Pro
tracks_skipped,
},
observations,
- total_duration_ms: suite.total_duration_ms,
- dry_run: suite.dry_run,
+ total_duration_ms: totalDurationMs,
+ dry_run: foundationSuite.dry_run,
platform_coherence: buildPlatformCoherence(options.platform_type, trackResults),
v3_gate_failed: v3GateFailed || undefined,
};
diff --git a/server/src/db/compliance-db.ts b/server/src/db/compliance-db.ts
index 752c3428c..56ac46b61 100644
--- a/server/src/db/compliance-db.ts
+++ b/server/src/db/compliance-db.ts
@@ -419,13 +419,16 @@ export class ComplianceDatabase {
): Promise<{ type: 'bearer'; token: string } | { type: 'basic'; username: string; password: string } | undefined> {
try {
const result = await query(
- `SELECT ac.organization_id, ac.auth_token_encrypted, ac.auth_token_iv, ac.auth_type
+ `SELECT ac.organization_id,
+ ac.auth_token_encrypted, ac.auth_token_iv, ac.auth_type,
+ ac.oauth_access_token_encrypted, ac.oauth_access_token_iv,
+ ac.oauth_token_expires_at
FROM agent_contexts ac
JOIN member_profiles mp
ON mp.workos_organization_id = ac.organization_id
WHERE ac.agent_url = $1
AND mp.agents @> $2::jsonb
- AND ac.auth_token_encrypted IS NOT NULL
+ AND (ac.auth_token_encrypted IS NOT NULL OR ac.oauth_access_token_encrypted IS NOT NULL)
ORDER BY ac.updated_at DESC NULLS LAST
LIMIT 1`,
[agentUrl, JSON.stringify([{ url: agentUrl }])],
@@ -434,17 +437,39 @@ export class ComplianceDatabase {
const row = result.rows[0];
if (!row) return undefined;
- const token = decryptToken(row.auth_token_encrypted, row.auth_token_iv, row.organization_id);
+ // Prefer static token when available
+ if (row.auth_token_encrypted) {
+ const token = decryptToken(row.auth_token_encrypted, row.auth_token_iv, row.organization_id);
- if (row.auth_type === 'basic') {
- const decoded = Buffer.from(token, 'base64').toString();
- const colonIndex = decoded.indexOf(':');
- if (colonIndex >= 0) {
- return { type: 'basic', username: decoded.slice(0, colonIndex), password: decoded.slice(colonIndex + 1) };
+ if (row.auth_type === 'basic') {
+ const decoded = Buffer.from(token, 'base64').toString();
+ const colonIndex = decoded.indexOf(':');
+ if (colonIndex >= 0) {
+ return { type: 'basic', username: decoded.slice(0, colonIndex), password: decoded.slice(colonIndex + 1) };
+ }
}
+
+ return { type: 'bearer', token };
+ }
+
+ // Fall back to OAuth access token
+ if (row.oauth_access_token_encrypted && row.oauth_access_token_iv) {
+ // Check expiration with 5-minute buffer
+ if (row.oauth_token_expires_at) {
+ const expiresAt = new Date(row.oauth_token_expires_at);
+ if (expiresAt.getTime() - Date.now() <= 5 * 60 * 1000) {
+ logger.debug({ agentUrl, expiresAt }, 'OAuth token expired or expiring soon for compliance auth');
+ return undefined;
+ }
+ } else {
+ logger.debug({ agentUrl }, 'OAuth token has no expiration recorded');
+ }
+
+ const token = decryptToken(row.oauth_access_token_encrypted, row.oauth_access_token_iv, row.organization_id);
+ return { type: 'bearer', token };
}
- return { type: 'bearer', token };
+ return undefined;
} catch (error) {
logger.debug({ error, agentUrl }, 'Could not resolve owner auth for heartbeat');
return undefined;
diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts
index 187001fd2..88e6286a9 100644
--- a/server/src/routes/registry-api.ts
+++ b/server/src/routes/registry-api.ts
@@ -54,6 +54,8 @@ import { PropertyCheckService } from "../services/property-check.js";
import { PropertyCheckDatabase } from "../db/property-check-db.js";
import { BulkPropertyCheckService } from "../services/bulk-property-check.js";
import { ComplianceDatabase, type LifecycleStage } from "../db/compliance-db.js";
+import { AgentContextDatabase } from "../db/agent-context-db.js";
+import { getAllPlatformTypes } from "../addie/services/compliance-testing.js";
import { getRequestLog, getRequestCount } from "../db/outbound-log-db.js";
const logger = createLogger("registry-api");
@@ -61,6 +63,7 @@ const propertyCheckService = new PropertyCheckService();
const propertyCheckDb = new PropertyCheckDatabase();
const bulkCheckService = new BulkPropertyCheckService();
const complianceDb = new ComplianceDatabase();
+const agentContextDb = new AgentContextDatabase();
/** Strip protocol, path, query, and fragment from a URL to extract the domain. */
function extractDomain(raw: string): string {
@@ -2539,6 +2542,91 @@ export function createRegistryApiRouter(config: RegistryApiConfig): Router {
}
});
+ router.put("/registry/agents/:encodedUrl/connect", brandCreationRateLimiter, ...complianceWriteMiddleware, async (req, res) => {
+ try {
+ const agentUrl = decodeURIComponent(req.params.encodedUrl);
+ if (!validateAgentUrlParam(agentUrl)) {
+ return res.status(400).json({ error: "Invalid agent URL" });
+ }
+
+ if (!req.user) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ const { auth_token, auth_type, platform_type } = req.body;
+
+ if (auth_token && typeof auth_token !== "string") {
+ return res.status(400).json({ error: "auth_token must be a string" });
+ }
+ if (auth_token && auth_token.length > 4096) {
+ return res.status(400).json({ error: "auth_token exceeds maximum length" });
+ }
+
+ const validAuthTypes = ["bearer", "basic"];
+ if (auth_token && auth_type && !validAuthTypes.includes(auth_type)) {
+ return res.status(400).json({ error: `Invalid auth_type. Valid types: ${validAuthTypes.join(", ")}` });
+ }
+ const resolvedAuthType = validAuthTypes.includes(auth_type) ? auth_type : "bearer";
+
+ if (platform_type && typeof platform_type !== "string") {
+ return res.status(400).json({ error: "platform_type must be a string" });
+ }
+ const validPlatformTypes = new Set(getAllPlatformTypes() as string[]);
+ if (platform_type && !validPlatformTypes.has(platform_type)) {
+ return res.status(400).json({
+ error: `Invalid platform_type. Valid types: ${[...validPlatformTypes].join(", ")}`,
+ });
+ }
+
+ // Verify ownership and get org ID in a single query
+ const orgResult = await query(
+ `SELECT mp.workos_organization_id
+ FROM member_profiles mp
+ JOIN organization_memberships om
+ ON om.workos_organization_id = mp.workos_organization_id
+ WHERE mp.agents @> $1::jsonb
+ AND om.workos_user_id = $2
+ LIMIT 1`,
+ [JSON.stringify([{ url: agentUrl }]), req.user.id],
+ );
+
+ if (orgResult.rows.length === 0) {
+ return res.status(403).json({ error: "You do not have permission to modify this agent" });
+ }
+
+ const orgId = orgResult.rows[0].workos_organization_id;
+
+ // Get or create agent context
+ let context = await agentContextDb.getByOrgAndUrl(orgId, agentUrl);
+ if (!context) {
+ context = await agentContextDb.create({
+ organization_id: orgId,
+ agent_url: agentUrl,
+ created_by: req.user.id,
+ });
+ }
+
+ // Save auth token if provided
+ if (auth_token) {
+ await agentContextDb.saveAuthToken(context.id, auth_token, resolvedAuthType);
+ }
+
+ // Set platform type if provided
+ if (platform_type) {
+ await complianceDb.upsertRegistryMetadata(agentUrl, { platform_type });
+ }
+
+ res.json({
+ connected: true,
+ has_auth: !!auth_token || context.has_auth_token,
+ platform_type: platform_type || undefined,
+ });
+ } catch (error) {
+ logger.error({ err: error, path: req.path }, "Failed to connect agent");
+ res.status(500).json({ error: "Failed to connect agent" });
+ }
+ });
+
// ── Storyboards ────────────────────────────────────────────────
router.get("/storyboards", async (req, res) => {