From c1355f2ec3ed7cefe12b401a41d643cf138f3e6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:53:44 +0000 Subject: [PATCH 01/58] Version Packages --- .changeset/account-lifecycle-stage.md | 4 - .changeset/activity-user-names.md | 4 - .changeset/addie-bot-token-fix.md | 8 - .changeset/addie-dm-no-threading.md | 4 - .changeset/addie-email-domain-linking.md | 3 - .changeset/addie-first-dm-fix.md | 3 - .changeset/addie-home-nav-fix.md | 4 - .changeset/addie-knowledge-improvements.md | 4 - .changeset/addie-newline-fix.md | 4 - .changeset/addie-news-feeds.md | 4 - .changeset/addie-role-suggestions.md | 3 - .changeset/addie-slack-endpoint.md | 18 - .changeset/addie-thread-reply-fix.md | 4 - .changeset/addie-thread-sharing.md | 3 - .changeset/addie-user-simulation.md | 4 - .changeset/afraid-ends-mate.md | 4 - .changeset/afraid-hairs-add.md | 2 - .changeset/afraid-pigs-sit.md | 8 - .changeset/all-dolls-beam.md | 2 - .changeset/all-llamas-sin.md | 2 - .changeset/allow-extra-fields.md | 7 - .changeset/anthropic-timeout.md | 4 - .changeset/better-papayas-attack.md | 2 - .changeset/big-lies-end.md | 6 - .changeset/block-gmail-corp-domain.md | 4 - .changeset/block-personal-workspace-merge.md | 2 - .changeset/blue-tables-agree.md | 2 - .changeset/bold-hornets-behave.md | 2 - .changeset/brave-emus-invite.md | 2 - .changeset/brave-forks-crash.md | 4 - .changeset/breezy-cats-own.md | 2 - .changeset/breezy-pens-kick.md | 2 - .changeset/breezy-zoos-hope.md | 2 - .changeset/bright-kids-say.md | 4 - .changeset/brown-doors-fly.md | 2 - .changeset/bumpy-carrots-change.md | 2 - .changeset/bumpy-cycles-hear.md | 2 - .changeset/bumpy-kings-follow.md | 4 - .changeset/busy-swans-judge.md | 2 - .changeset/calm-lamps-doubt.md | 2 - .changeset/chatty-tools-lead.md | 2 - .changeset/chubby-baboons-kick.md | 4 - .changeset/chubby-books-film.md | 2 - .changeset/chubby-pumas-smoke.md | 2 - .changeset/clean-ghosts-beam.md | 4 - .changeset/clean-groups-press.md | 2 - .changeset/clean-jokes-invent.md | 10 - .changeset/clean-pumas-open.md | 2 - .changeset/clear-colts-wink.md | 2 - .changeset/clever-pets-brush.md | 2 - .changeset/clickable-user-name.md | 3 - .changeset/cold-shrimps-taste.md | 2 - .changeset/common-cougars-unite.md | 2 - .changeset/common-geckos-wash.md | 2 - .changeset/cool-memes-wear.md | 2 - .changeset/crisp-moments-wear.md | 2 - .changeset/crisp-pets-tease.md | 2 - .changeset/crisp-readers-rule.md | 2 - .changeset/cuddly-pets-sort.md | 2 - .changeset/cuddly-windows-accept.md | 2 - .changeset/curvy-rice-bake.md | 2 - .changeset/cute-taxes-boil.md | 2 - .changeset/cyan-cooks-hang.md | 2 - .changeset/dashboard-user-menu.md | 2 - .changeset/david-event-mgmt-fix.md | 4 - .changeset/dirty-kiwis-cheer.md | 46 - .changeset/dirty-meteors-sell.md | 2 - .changeset/dirty-spies-burn.md | 2 - .changeset/docs-versioning.md | 3 - .changeset/dry-singers-pull.md | 3 - .changeset/dull-dogs-live.md | 4 - .changeset/dull-rooms-battle.md | 2 - .changeset/dull-singers-mate.md | 2 - .changeset/eager-eels-sneeze.md | 2 - .changeset/eager-humans-change.md | 2 - .changeset/easy-lizards-hug.md | 2 - .changeset/eighty-onions-leave.md | 2 - .changeset/eighty-sloths-feel.md | 2 - .changeset/eleven-rabbits-enter.md | 2 - .changeset/eleven-wolves-return.md | 2 - .changeset/email-tracking-logging.md | 4 - .changeset/enable-addie-public.md | 3 - .changeset/engagement-scoring-job.md | 3 - .changeset/error-details-type.md | 7 - .changeset/event-attendance-tracking.md | 2 - .changeset/event-visibility-fix.md | 4 - .changeset/every-falcons-peel.md | 4 - .changeset/every-moose-kick.md | 2 - .changeset/fair-plants-reply.md | 2 - .changeset/fair-turkeys-sell.md | 2 - .changeset/famous-rockets-report.md | 2 - .changeset/famous-sides-hide.md | 7 - .changeset/fast-hounds-lick.md | 2 - .changeset/fast-sloths-press.md | 2 - .changeset/fiery-actors-show.md | 4 - .changeset/fiery-days-roll.md | 2 - .changeset/fiery-maps-tan.md | 12 - .changeset/fiery-sides-allow.md | 4 - .changeset/fifty-bars-pump.md | 4 - .changeset/fifty-pandas-sleep.md | 2 - .changeset/fix-addie-github-link.md | 3 - .changeset/fix-admin-checks-mobile-pane.md | 4 - .changeset/fix-admin-member-links.md | 4 - .changeset/fix-analytics-views.md | 4 - .changeset/fix-broken-readme-links.md | 3 - .changeset/fix-company-type-validation.md | 3 - .changeset/fix-daily-digest.md | 3 - .changeset/fix-domain-health-orgs.md | 4 - .changeset/fix-duplicate-members.md | 2 - .changeset/fix-email-preferences-table.md | 4 - .changeset/fix-feed-email-slugs.md | 4 - .changeset/fix-feedback-modal.md | 4 - .changeset/fix-html-entities-news.md | 4 - .../fix-industry-alerts-channel-filter.md | 3 - .changeset/fix-industry-alerts-token.md | 3 - .changeset/fix-interaction-analyzer-column.md | 4 - .changeset/fix-invoice-products.md | 4 - .changeset/fix-login-display-and-stats.md | 3 - .changeset/fix-member-name-click.md | 4 - .changeset/fix-migration-151.md | 5 - .changeset/fix-org-memberships-table.md | 4 - .changeset/fix-outreach-stats.md | 4 - .changeset/fix-package-request-fields.md | 22 - .changeset/fix-pending-articles.md | 4 - .changeset/fix-perspectives-body.md | 3 - .changeset/fix-perspectives-link.md | 4 - .changeset/fix-planner-column-names.md | 4 - .changeset/fix-resend-inbound-webhook.md | 4 - .changeset/fix-slack-auth-security.md | 4 - .changeset/fix-slack-leader-display.md | 12 - .changeset/flat-candies-crash.md | 2 - .changeset/flat-friends-learn.md | 2 - .changeset/flat-frogs-go.md | 2 - .changeset/flat-turkeys-tell.md | 2 - .changeset/floppy-apples-live.md | 4 - .changeset/floppy-crews-greet.md | 2 - .changeset/forty-emus-sniff.md | 2 - .changeset/forty-symbols-kick.md | 2 - .changeset/four-forks-go.md | 2 - .changeset/four-ways-yawn.md | 2 - .changeset/frank-pets-tan.md | 4 - .changeset/free-chicken-write.md | 2 - .changeset/free-terms-send.md | 2 - .changeset/fresh-crews-wash.md | 2 - .changeset/fresh-numbers-hide.md | 2 - .changeset/fruity-papayas-act.md | 2 - .changeset/fruity-states-spend.md | 2 - .changeset/fruity-wasps-hope.md | 2 - .changeset/funky-bars-chew.md | 9 - .changeset/funny-jeans-attack.md | 2 - .changeset/funny-sheep-remain.md | 2 - .changeset/fuzzy-showers-know.md | 2 - .changeset/fuzzy-streets-call.md | 2 - .changeset/gentle-coins-live.md | 2 - .changeset/gentle-moose-greet.md | 2 - .changeset/gentle-pianos-spend.md | 2 - .changeset/gentle-rivers-add.md | 2 - .changeset/giant-eggs-stare.md | 2 - .changeset/giant-parts-heal.md | 4 - .changeset/giant-tires-argue.md | 2 - .changeset/github-issue-offer.md | 4 - .changeset/gold-spies-wait.md | 2 - .changeset/good-ads-rule.md | 2 - .changeset/good-cougars-serve.md | 2 - .changeset/good-kiwis-tie.md | 2 - .changeset/good-lemons-accept.md | 2 - .changeset/good-steaks-slide.md | 2 - .changeset/goofy-deer-pick.md | 2 - .changeset/great-cobras-wave.md | 2 - .changeset/great-singers-greet.md | 2 - .changeset/green-banks-juggle.md | 4 - .changeset/green-bottles-fly.md | 2 - .changeset/green-cooks-double.md | 4 - .changeset/happy-llamas-hope.md | 2 - .changeset/happy-nights-talk.md | 4 - .changeset/heavy-dots-doubt.md | 2 - .changeset/heavy-tips-visit.md | 2 - .changeset/hip-bugs-jam.md | 2 - .changeset/honest-spiders-tickle.md | 2 - .changeset/hot-teams-leave.md | 2 - .changeset/huge-taxes-sin.md | 2 - .changeset/humble-clouds-smell.md | 4 - .changeset/hungry-bars-jump.md | 2 - .changeset/hungry-feet-jump.md | 4 - .changeset/icy-toes-pay.md | 2 - .changeset/industry-gathering-slug-fix.md | 3 - .changeset/jolly-chefs-knock.md | 2 - .changeset/jolly-houses-shake.md | 2 - .changeset/jolly-spoons-jump.md | 2 - .changeset/kind-horses-fry.md | 2 - .changeset/late-candles-invite.md | 2 - .changeset/late-teeth-divide.md | 4 - .changeset/lazy-doors-read.md | 2 - .changeset/legal-dodos-rush.md | 2 - .changeset/lemon-cats-brake.md | 2 - .changeset/link-org-direct.md | 4 - .changeset/loose-feet-live.md | 4 - .changeset/loud-ducks-cough.md | 4 - .changeset/loud-hairs-repeat.md | 4 - .changeset/loud-pigs-heal.md | 2 - .changeset/lovely-banks-argue.md | 2 - .changeset/lovely-dragons-know.md | 4 - .changeset/many-cups-teach.md | 2 - .changeset/many-doors-shine.md | 10 - .changeset/member-search-intros.md | 4 - .changeset/metal-ravens-argue.md | 2 - .changeset/mighty-needles-count.md | 2 - .changeset/mighty-rings-add.md | 2 - .changeset/modern-maps-happen.md | 2 - .changeset/modern-planets-marry.md | 2 - .changeset/neat-things-lay.md | 2 - .changeset/new-coins-think.md | 2 - .changeset/new-meals-battle.md | 2 - .changeset/new-olives-play.md | 2 - .changeset/new-suns-study.md | 4 - .changeset/nice-camels-serve.md | 4 - .changeset/nice-meals-help.md | 2 - .changeset/nine-lies-raise.md | 2 - .changeset/nine-rings-beam.md | 2 - .changeset/nine-views-clap.md | 4 - .changeset/ninety-oranges-rush.md | 4 - .changeset/old-geese-dress.md | 4 - .changeset/old-shirts-hammer.md | 4 - .changeset/olive-buses-doubt.md | 2 - .changeset/olive-coats-bathe.md | 2 - .changeset/open-rooms-tap.md | 2 - .changeset/open-squids-flow.md | 2 - .changeset/orange-bottles-reply.md | 4 - .changeset/orange-coins-wish.md | 2 - .changeset/perky-deserts-matter.md | 2 - .changeset/personal-workspace-enforcement.md | 3 - .changeset/petite-eels-fly.md | 2 - .changeset/pink-corners-allow.md | 2 - .changeset/plain-needles-smell.md | 4 - .changeset/plain-terms-sip.md | 2 - .changeset/plenty-trees-relate.md | 28 - .changeset/polite-candies-rescue.md | 2 - .changeset/polite-candles-walk.md | 2 - .changeset/polite-cycles-hang.md | 2 - .changeset/polite-socks-swim.md | 2 - .changeset/polite-tables-fail.md | 11 - .changeset/polite-webs-cheat.md | 2 - .changeset/posthog-analytics.md | 4 - .changeset/pretty-cameras-jog.md | 4 - .changeset/pretty-crabs-create.md | 2 - .changeset/pretty-horses-add.md | 2 - .changeset/proud-jars-attack.md | 2 - .changeset/proud-pugs-punch.md | 2 - .changeset/public-needles-argue.md | 2 - .changeset/puny-terms-start.md | 4 - .changeset/purple-rice-unite.md | 2 - .changeset/quick-pandas-invent.md | 2 - .changeset/ready-breads-matter.md | 2 - .changeset/ready-kids-teach.md | 2 - .changeset/real-cases-join.md | 2 - .changeset/real-places-float.md | 4 - .changeset/red-mails-do.md | 2 - .changeset/release-relaxed-schemas.md | 9 - .changeset/ripe-ghosts-bet.md | 9 - .changeset/ripe-zebras-sell.md | 2 - .changeset/salty-cooks-grin.md | 2 - .changeset/seven-deer-marry.md | 2 - .changeset/seven-heads-smile.md | 2 - .changeset/seven-rules-invent.md | 4 - .changeset/seven-squids-carry.md | 2 - .changeset/shaggy-rocks-train.md | 2 - .changeset/shaky-paths-attend.md | 2 - .changeset/shiny-points-spend.md | 2 - .changeset/short-clouds-feel.md | 2 - .changeset/shy-bats-run.md | 2 - .changeset/silent-cars-watch.md | 2 - .changeset/silent-crews-lose.md | 2 - .changeset/silly-readers-hunt.md | 2 - .changeset/silver-goats-exist.md | 4 - .changeset/silver-suns-sell.md | 2 - .changeset/sixty-colts-bathe.md | 4 - .changeset/sixty-ends-ask.md | 2 - .changeset/sixty-paws-punch.md | 4 - .changeset/skip-welcome-engaged-users.md | 6 - .changeset/slack-engagement-scores.md | 4 - .changeset/slick-camels-hide.md | 2 - .changeset/slick-singers-prove.md | 2 - .changeset/slick-tigers-invite.md | 2 - .changeset/slimy-meteors-grab.md | 2 - .changeset/slimy-singers-care.md | 2 - .changeset/slow-snails-stand.md | 2 - .changeset/slow-trains-agree.md | 2 - .changeset/small-geese-leave.md | 2 - .changeset/smart-olives-sort.md | 2 - .changeset/smooth-rice-chew.md | 2 - .changeset/soft-apples-play.md | 4 - .changeset/soft-rooms-spend.md | 4 - .changeset/solid-hotels-occur.md | 2 - .changeset/solid-ideas-shake.md | 2 - .changeset/some-phones-study.md | 2 - .changeset/some-teeth-judge.md | 2 - .changeset/sparkly-camels-dream.md | 4 - .changeset/sparkly-onions-hug.md | 4 - .changeset/spicy-animals-fail.md | 2 - .changeset/spicy-doors-flow.md | 2 - .changeset/spicy-plants-rescue.md | 2 - .changeset/spicy-tigers-double.md | 2 - .changeset/stale-cobras-smile.md | 2 - .changeset/stale-hats-dance.md | 2 - .changeset/stale-tips-drive.md | 2 - .changeset/strict-foxes-wash.md | 8 - .changeset/strict-lamps-turn.md | 4 - .changeset/sunny-apples-jam.md | 4 - .changeset/sunny-bears-stare.md | 2 - .changeset/sweet-brooms-give.md | 2 - .changeset/sweet-planes-follow.md | 10 - .changeset/sweet-queens-send.md | 2 - .changeset/sweet-taxis-act.md | 2 - .changeset/swift-points-sniff.md | 2 - .changeset/tame-hornets-peel.md | 2 - .changeset/tame-hounds-swim.md | 2 - .changeset/tame-llamas-shake.md | 2 - .changeset/tasty-sails-tie.md | 2 - .changeset/ten-bikes-shout.md | 2 - .changeset/ten-crews-joke.md | 2 - .changeset/ten-experts-fall.md | 2 - .changeset/ten-groups-heal.md | 2 - .changeset/ten-towns-admire.md | 2 - .changeset/tender-experts-repeat.md | 4 - .changeset/tender-jeans-double.md | 2 - .changeset/tender-needles-beg.md | 2 - .changeset/tender-pants-glow.md | 2 - .changeset/thick-cows-add.md | 2 - .changeset/thin-lions-trade.md | 2 - .changeset/thirty-dots-feel.md | 2 - .changeset/thirty-papayas-rescue.md | 2 - .changeset/thirty-regions-dance.md | 2 - .changeset/thirty-snails-kneel.md | 4 - .changeset/thirty-snakes-fix.md | 2 - .changeset/tiny-games-decide.md | 2 - .changeset/tiny-news-smash.md | 4 - .changeset/tired-rockets-lie.md | 2 - .changeset/tricky-baths-obey.md | 2 - .changeset/tricky-worms-push.md | 2 - .changeset/twelve-baboons-smell.md | 2 - .changeset/twelve-books-cross.md | 2 - .changeset/twenty-ads-lay.md | 2 - .changeset/twenty-bobcats-fall.md | 2 - .changeset/wacky-seals-eat.md | 2 - .changeset/wacky-things-refuse.md | 2 - .changeset/wacky-worms-speak.md | 4 - .changeset/warm-jars-attack.md | 2 - .changeset/warm-lamps-accept.md | 2 - .changeset/wet-keys-wash.md | 4 - .changeset/wicked-islands-tickle.md | 2 - .changeset/wicked-memes-act.md | 4 - .changeset/wide-kiwis-say.md | 11 - .changeset/wide-shirts-attack.md | 2 - .changeset/wise-bags-invite.md | 2 - .changeset/wise-places-invent.md | 4 - .changeset/wise-tigers-lose.md | 2 - .changeset/witty-teeth-live.md | 2 - .changeset/yellow-ghosts-appear.md | 2 - .changeset/yellow-knives-warn.md | 2 - .changeset/yellow-llamas-lie.md | 2 - .changeset/young-baths-kiss.md | 2 - .changeset/young-masks-care.md | 2 - .changeset/yummy-areas-bet.md | 9 - CHANGELOG.md | 47 + dist/schemas/2.5.3/adagents.json | 494 ++++ .../media-buy/build-creative-request.json | 1349 +++++++++++ .../media-buy/build-creative-response.json | 1383 +++++++++++ .../media-buy/create-media-buy-request.json | 2025 +++++++++++++++++ .../media-buy/create-media-buy-response.json | 356 +++ .../get-media-buy-delivery-request.json | 92 + .../get-media-buy-delivery-response.json | 700 ++++++ .../media-buy/get-products-request.json | 657 ++++++ .../media-buy/get-products-response.json | 1313 +++++++++++ .../list-authorized-properties-request.json | 36 + .../list-authorized-properties-response.json | 128 ++ .../list-creative-formats-request.json | 137 ++ .../list-creative-formats-response.json | 646 ++++++ .../media-buy/list-creatives-request.json | 294 +++ .../media-buy/list-creatives-response.json | 1654 ++++++++++++++ .../bundled/media-buy/package-request.json | 1433 ++++++++++++ .../provide-performance-feedback-request.json | 116 + ...provide-performance-feedback-response.json | 117 + .../media-buy/sync-creatives-request.json | 1475 ++++++++++++ .../media-buy/sync-creatives-response.json | 208 ++ .../media-buy/update-media-buy-request.json | 1530 +++++++++++++ .../media-buy/update-media-buy-response.json | 358 +++ .../signals/activate-signal-request.json | 90 + .../signals/activate-signal-response.json | 295 +++ .../bundled/signals/get-signals-request.json | 154 ++ .../bundled/signals/get-signals-response.json | 327 +++ dist/schemas/2.5.3/core/activation-key.json | 50 + .../2.5.3/core/assets/audio-asset.json | 32 + dist/schemas/2.5.3/core/assets/css-asset.json | 21 + .../2.5.3/core/assets/daast-asset.json | 87 + .../schemas/2.5.3/core/assets/html-asset.json | 21 + .../2.5.3/core/assets/image-asset.json | 38 + .../2.5.3/core/assets/javascript-asset.json | 21 + .../2.5.3/core/assets/markdown-asset.json | 31 + .../schemas/2.5.3/core/assets/text-asset.json | 21 + dist/schemas/2.5.3/core/assets/url-asset.json | 26 + .../schemas/2.5.3/core/assets/vast-asset.json | 87 + .../2.5.3/core/assets/video-asset.json | 44 + .../2.5.3/core/assets/webhook-asset.json | 71 + .../2.5.3/core/async-response-data.json | 88 + .../2.5.3/core/brand-manifest-ref.json | 33 + dist/schemas/2.5.3/core/brand-manifest.json | 409 ++++ dist/schemas/2.5.3/core/context.json | 8 + dist/schemas/2.5.3/core/creative-asset.json | 125 + .../2.5.3/core/creative-assignment.json | 31 + dist/schemas/2.5.3/core/creative-filters.json | 111 + .../schemas/2.5.3/core/creative-manifest.json | 72 + dist/schemas/2.5.3/core/creative-policy.json | 27 + dist/schemas/2.5.3/core/delivery-metrics.json | 171 ++ dist/schemas/2.5.3/core/deployment.json | 93 + dist/schemas/2.5.3/core/destination.json | 53 + dist/schemas/2.5.3/core/error.json | 40 + dist/schemas/2.5.3/core/ext.json | 8 + dist/schemas/2.5.3/core/format-id.json | 47 + dist/schemas/2.5.3/core/format.json | 324 +++ dist/schemas/2.5.3/core/frequency-cap.json | 18 + .../2.5.3/core/mcp-webhook-payload.json | 152 ++ dist/schemas/2.5.3/core/measurement.json | 48 + dist/schemas/2.5.3/core/media-buy.json | 62 + dist/schemas/2.5.3/core/package.json | 72 + .../2.5.3/core/performance-feedback.json | 90 + dist/schemas/2.5.3/core/placement.json | 34 + dist/schemas/2.5.3/core/pricing-option.json | 35 + dist/schemas/2.5.3/core/product-filters.json | 102 + dist/schemas/2.5.3/core/product.json | 153 ++ .../2.5.3/core/promoted-offerings.json | 115 + .../schemas/2.5.3/core/promoted-products.json | 67 + dist/schemas/2.5.3/core/property-id.json | 14 + dist/schemas/2.5.3/core/property-tag.json | 16 + dist/schemas/2.5.3/core/property.json | 62 + .../schemas/2.5.3/core/protocol-envelope.json | 146 ++ .../core/publisher-property-selector.json | 92 + .../2.5.3/core/push-notification-config.json | 48 + .../2.5.3/core/reporting-capabilities.json | 71 + dist/schemas/2.5.3/core/response.json | 24 + dist/schemas/2.5.3/core/signal-filters.json | 35 + dist/schemas/2.5.3/core/start-timing.json | 18 + dist/schemas/2.5.3/core/sub-asset.json | 79 + dist/schemas/2.5.3/core/targeting.json | 50 + .../schemas/2.5.3/core/tasks-get-request.json | 43 + .../2.5.3/core/tasks-get-response.json | 166 ++ .../2.5.3/core/tasks-list-request.json | 192 ++ .../2.5.3/core/tasks-list-response.json | 183 ++ .../2.5.3/creative/asset-types/index.json | 106 + .../list-creative-formats-request.json | 73 + .../list-creative-formats-response.json | 61 + .../creative/preview-creative-request.json | 164 ++ .../creative/preview-creative-response.json | 245 ++ .../2.5.3/creative/preview-render.json | 225 ++ dist/schemas/2.5.3/enums/adcp-domain.json | 11 + .../2.5.3/enums/asset-content-type.json | 22 + dist/schemas/2.5.3/enums/auth-scheme.json | 11 + .../schemas/2.5.3/enums/available-metric.json | 18 + dist/schemas/2.5.3/enums/channels.json | 18 + .../2.5.3/enums/co-branding-requirement.json | 12 + dist/schemas/2.5.3/enums/creative-action.json | 14 + .../enums/creative-agent-capability.json | 13 + .../2.5.3/enums/creative-sort-field.json | 15 + dist/schemas/2.5.3/enums/creative-status.json | 19 + .../2.5.3/enums/daast-tracking-event.json | 20 + dist/schemas/2.5.3/enums/daast-version.json | 11 + dist/schemas/2.5.3/enums/delivery-type.json | 15 + dist/schemas/2.5.3/enums/dimension-unit.json | 13 + dist/schemas/2.5.3/enums/feed-format.json | 12 + dist/schemas/2.5.3/enums/feedback-source.json | 13 + dist/schemas/2.5.3/enums/format-category.json | 16 + .../2.5.3/enums/format-id-parameter.json | 11 + .../2.5.3/enums/frequency-cap-scope.json | 13 + .../2.5.3/enums/history-entry-type.json | 11 + dist/schemas/2.5.3/enums/http-method.json | 11 + .../schemas/2.5.3/enums/identifier-types.json | 34 + .../2.5.3/enums/javascript-module-type.json | 12 + .../2.5.3/enums/landing-page-requirement.json | 12 + dist/schemas/2.5.3/enums/markdown-flavor.json | 11 + .../schemas/2.5.3/enums/media-buy-status.json | 19 + dist/schemas/2.5.3/enums/metric-type.json | 17 + .../2.5.3/enums/notification-type.json | 13 + dist/schemas/2.5.3/enums/pacing.json | 17 + .../2.5.3/enums/preview-output-format.json | 11 + dist/schemas/2.5.3/enums/pricing-model.json | 25 + dist/schemas/2.5.3/enums/property-type.json | 16 + .../enums/publisher-identifier-types.json | 19 + .../2.5.3/enums/reporting-frequency.json | 12 + .../2.5.3/enums/signal-catalog-type.json | 12 + dist/schemas/2.5.3/enums/sort-direction.json | 11 + .../2.5.3/enums/standard-format-ids.json | 103 + dist/schemas/2.5.3/enums/task-status.json | 29 + dist/schemas/2.5.3/enums/task-type.json | 27 + .../schemas/2.5.3/enums/update-frequency.json | 13 + dist/schemas/2.5.3/enums/url-asset-type.json | 12 + dist/schemas/2.5.3/enums/validation-mode.json | 11 + .../2.5.3/enums/vast-tracking-event.json | 25 + dist/schemas/2.5.3/enums/vast-version.json | 14 + .../2.5.3/enums/webhook-response-type.json | 13 + .../2.5.3/enums/webhook-security-method.json | 12 + dist/schemas/2.5.3/index.json | 604 +++++ .../media-buy/build-creative-request.json | 31 + .../media-buy/build-creative-response.json | 65 + ...dia-buy-async-response-input-required.json | 31 + ...te-media-buy-async-response-submitted.json | 16 + ...eate-media-buy-async-response-working.json | 36 + .../media-buy/create-media-buy-request.json | 126 + .../media-buy/create-media-buy-response.json | 97 + .../get-media-buy-delivery-request.json | 54 + .../get-media-buy-delivery-response.json | 285 +++ ...roducts-async-response-input-required.json | 38 + ...get-products-async-response-submitted.json | 21 + .../get-products-async-response-working.json | 34 + .../2.5.3/media-buy/get-products-request.json | 28 + .../media-buy/get-products-response.json | 33 + .../list-authorized-properties-request.json | 26 + .../list-authorized-properties-response.json | 70 + .../list-creative-formats-request.json | 58 + .../list-creative-formats-response.json | 61 + .../media-buy/list-creatives-request.json | 137 ++ .../media-buy/list-creatives-response.json | 437 ++++ .../2.5.3/media-buy/package-request.json | 80 + .../provide-performance-feedback-request.json | 88 + ...provide-performance-feedback-response.json | 66 + ...eatives-async-response-input-required.json | 25 + ...nc-creatives-async-response-submitted.json | 16 + ...sync-creatives-async-response-working.json | 46 + .../media-buy/sync-creatives-request.json | 178 ++ .../media-buy/sync-creatives-response.json | 149 ++ ...dia-buy-async-response-input-required.json | 24 + ...te-media-buy-async-response-submitted.json | 16 + ...date-media-buy-async-response-working.json | 36 + .../media-buy/update-media-buy-request.json | 129 ++ .../media-buy/update-media-buy-response.json | 99 + .../2.5.3/pricing-options/cpc-option.json | 52 + .../2.5.3/pricing-options/cpcv-option.json | 52 + .../pricing-options/cpm-auction-option.json | 81 + .../pricing-options/cpm-fixed-option.json | 52 + .../2.5.3/pricing-options/cpp-option.json | 73 + .../2.5.3/pricing-options/cpv-option.json | 88 + .../pricing-options/flat-rate-option.json | 93 + .../pricing-options/vcpm-auction-option.json | 81 + .../pricing-options/vcpm-fixed-option.json | 52 + .../2.5.3/protocols/adcp-extension.json | 33 + .../signals/activate-signal-request.json | 32 + .../signals/activate-signal-response.json | 68 + .../2.5.3/signals/get-signals-request.json | 59 + .../2.5.3/signals/get-signals-response.json | 100 + package.json | 2 +- static/schemas/source/index.json | 2 +- 549 files changed, 27622 insertions(+), 1165 deletions(-) delete mode 100644 .changeset/account-lifecycle-stage.md delete mode 100644 .changeset/activity-user-names.md delete mode 100644 .changeset/addie-bot-token-fix.md delete mode 100644 .changeset/addie-dm-no-threading.md delete mode 100644 .changeset/addie-email-domain-linking.md delete mode 100644 .changeset/addie-first-dm-fix.md delete mode 100644 .changeset/addie-home-nav-fix.md delete mode 100644 .changeset/addie-knowledge-improvements.md delete mode 100644 .changeset/addie-newline-fix.md delete mode 100644 .changeset/addie-news-feeds.md delete mode 100644 .changeset/addie-role-suggestions.md delete mode 100644 .changeset/addie-slack-endpoint.md delete mode 100644 .changeset/addie-thread-reply-fix.md delete mode 100644 .changeset/addie-thread-sharing.md delete mode 100644 .changeset/addie-user-simulation.md delete mode 100644 .changeset/afraid-ends-mate.md delete mode 100644 .changeset/afraid-hairs-add.md delete mode 100644 .changeset/afraid-pigs-sit.md delete mode 100644 .changeset/all-dolls-beam.md delete mode 100644 .changeset/all-llamas-sin.md delete mode 100644 .changeset/allow-extra-fields.md delete mode 100644 .changeset/anthropic-timeout.md delete mode 100644 .changeset/better-papayas-attack.md delete mode 100644 .changeset/big-lies-end.md delete mode 100644 .changeset/block-gmail-corp-domain.md delete mode 100644 .changeset/block-personal-workspace-merge.md delete mode 100644 .changeset/blue-tables-agree.md delete mode 100644 .changeset/bold-hornets-behave.md delete mode 100644 .changeset/brave-emus-invite.md delete mode 100644 .changeset/brave-forks-crash.md delete mode 100644 .changeset/breezy-cats-own.md delete mode 100644 .changeset/breezy-pens-kick.md delete mode 100644 .changeset/breezy-zoos-hope.md delete mode 100644 .changeset/bright-kids-say.md delete mode 100644 .changeset/brown-doors-fly.md delete mode 100644 .changeset/bumpy-carrots-change.md delete mode 100644 .changeset/bumpy-cycles-hear.md delete mode 100644 .changeset/bumpy-kings-follow.md delete mode 100644 .changeset/busy-swans-judge.md delete mode 100644 .changeset/calm-lamps-doubt.md delete mode 100644 .changeset/chatty-tools-lead.md delete mode 100644 .changeset/chubby-baboons-kick.md delete mode 100644 .changeset/chubby-books-film.md delete mode 100644 .changeset/chubby-pumas-smoke.md delete mode 100644 .changeset/clean-ghosts-beam.md delete mode 100644 .changeset/clean-groups-press.md delete mode 100644 .changeset/clean-jokes-invent.md delete mode 100644 .changeset/clean-pumas-open.md delete mode 100644 .changeset/clear-colts-wink.md delete mode 100644 .changeset/clever-pets-brush.md delete mode 100644 .changeset/clickable-user-name.md delete mode 100644 .changeset/cold-shrimps-taste.md delete mode 100644 .changeset/common-cougars-unite.md delete mode 100644 .changeset/common-geckos-wash.md delete mode 100644 .changeset/cool-memes-wear.md delete mode 100644 .changeset/crisp-moments-wear.md delete mode 100644 .changeset/crisp-pets-tease.md delete mode 100644 .changeset/crisp-readers-rule.md delete mode 100644 .changeset/cuddly-pets-sort.md delete mode 100644 .changeset/cuddly-windows-accept.md delete mode 100644 .changeset/curvy-rice-bake.md delete mode 100644 .changeset/cute-taxes-boil.md delete mode 100644 .changeset/cyan-cooks-hang.md delete mode 100644 .changeset/dashboard-user-menu.md delete mode 100644 .changeset/david-event-mgmt-fix.md delete mode 100644 .changeset/dirty-kiwis-cheer.md delete mode 100644 .changeset/dirty-meteors-sell.md delete mode 100644 .changeset/dirty-spies-burn.md delete mode 100644 .changeset/docs-versioning.md delete mode 100644 .changeset/dry-singers-pull.md delete mode 100644 .changeset/dull-dogs-live.md delete mode 100644 .changeset/dull-rooms-battle.md delete mode 100644 .changeset/dull-singers-mate.md delete mode 100644 .changeset/eager-eels-sneeze.md delete mode 100644 .changeset/eager-humans-change.md delete mode 100644 .changeset/easy-lizards-hug.md delete mode 100644 .changeset/eighty-onions-leave.md delete mode 100644 .changeset/eighty-sloths-feel.md delete mode 100644 .changeset/eleven-rabbits-enter.md delete mode 100644 .changeset/eleven-wolves-return.md delete mode 100644 .changeset/email-tracking-logging.md delete mode 100644 .changeset/enable-addie-public.md delete mode 100644 .changeset/engagement-scoring-job.md delete mode 100644 .changeset/error-details-type.md delete mode 100644 .changeset/event-attendance-tracking.md delete mode 100644 .changeset/event-visibility-fix.md delete mode 100644 .changeset/every-falcons-peel.md delete mode 100644 .changeset/every-moose-kick.md delete mode 100644 .changeset/fair-plants-reply.md delete mode 100644 .changeset/fair-turkeys-sell.md delete mode 100644 .changeset/famous-rockets-report.md delete mode 100644 .changeset/famous-sides-hide.md delete mode 100644 .changeset/fast-hounds-lick.md delete mode 100644 .changeset/fast-sloths-press.md delete mode 100644 .changeset/fiery-actors-show.md delete mode 100644 .changeset/fiery-days-roll.md delete mode 100644 .changeset/fiery-maps-tan.md delete mode 100644 .changeset/fiery-sides-allow.md delete mode 100644 .changeset/fifty-bars-pump.md delete mode 100644 .changeset/fifty-pandas-sleep.md delete mode 100644 .changeset/fix-addie-github-link.md delete mode 100644 .changeset/fix-admin-checks-mobile-pane.md delete mode 100644 .changeset/fix-admin-member-links.md delete mode 100644 .changeset/fix-analytics-views.md delete mode 100644 .changeset/fix-broken-readme-links.md delete mode 100644 .changeset/fix-company-type-validation.md delete mode 100644 .changeset/fix-daily-digest.md delete mode 100644 .changeset/fix-domain-health-orgs.md delete mode 100644 .changeset/fix-duplicate-members.md delete mode 100644 .changeset/fix-email-preferences-table.md delete mode 100644 .changeset/fix-feed-email-slugs.md delete mode 100644 .changeset/fix-feedback-modal.md delete mode 100644 .changeset/fix-html-entities-news.md delete mode 100644 .changeset/fix-industry-alerts-channel-filter.md delete mode 100644 .changeset/fix-industry-alerts-token.md delete mode 100644 .changeset/fix-interaction-analyzer-column.md delete mode 100644 .changeset/fix-invoice-products.md delete mode 100644 .changeset/fix-login-display-and-stats.md delete mode 100644 .changeset/fix-member-name-click.md delete mode 100644 .changeset/fix-migration-151.md delete mode 100644 .changeset/fix-org-memberships-table.md delete mode 100644 .changeset/fix-outreach-stats.md delete mode 100644 .changeset/fix-package-request-fields.md delete mode 100644 .changeset/fix-pending-articles.md delete mode 100644 .changeset/fix-perspectives-body.md delete mode 100644 .changeset/fix-perspectives-link.md delete mode 100644 .changeset/fix-planner-column-names.md delete mode 100644 .changeset/fix-resend-inbound-webhook.md delete mode 100644 .changeset/fix-slack-auth-security.md delete mode 100644 .changeset/fix-slack-leader-display.md delete mode 100644 .changeset/flat-candies-crash.md delete mode 100644 .changeset/flat-friends-learn.md delete mode 100644 .changeset/flat-frogs-go.md delete mode 100644 .changeset/flat-turkeys-tell.md delete mode 100644 .changeset/floppy-apples-live.md delete mode 100644 .changeset/floppy-crews-greet.md delete mode 100644 .changeset/forty-emus-sniff.md delete mode 100644 .changeset/forty-symbols-kick.md delete mode 100644 .changeset/four-forks-go.md delete mode 100644 .changeset/four-ways-yawn.md delete mode 100644 .changeset/frank-pets-tan.md delete mode 100644 .changeset/free-chicken-write.md delete mode 100644 .changeset/free-terms-send.md delete mode 100644 .changeset/fresh-crews-wash.md delete mode 100644 .changeset/fresh-numbers-hide.md delete mode 100644 .changeset/fruity-papayas-act.md delete mode 100644 .changeset/fruity-states-spend.md delete mode 100644 .changeset/fruity-wasps-hope.md delete mode 100644 .changeset/funky-bars-chew.md delete mode 100644 .changeset/funny-jeans-attack.md delete mode 100644 .changeset/funny-sheep-remain.md delete mode 100644 .changeset/fuzzy-showers-know.md delete mode 100644 .changeset/fuzzy-streets-call.md delete mode 100644 .changeset/gentle-coins-live.md delete mode 100644 .changeset/gentle-moose-greet.md delete mode 100644 .changeset/gentle-pianos-spend.md delete mode 100644 .changeset/gentle-rivers-add.md delete mode 100644 .changeset/giant-eggs-stare.md delete mode 100644 .changeset/giant-parts-heal.md delete mode 100644 .changeset/giant-tires-argue.md delete mode 100644 .changeset/github-issue-offer.md delete mode 100644 .changeset/gold-spies-wait.md delete mode 100644 .changeset/good-ads-rule.md delete mode 100644 .changeset/good-cougars-serve.md delete mode 100644 .changeset/good-kiwis-tie.md delete mode 100644 .changeset/good-lemons-accept.md delete mode 100644 .changeset/good-steaks-slide.md delete mode 100644 .changeset/goofy-deer-pick.md delete mode 100644 .changeset/great-cobras-wave.md delete mode 100644 .changeset/great-singers-greet.md delete mode 100644 .changeset/green-banks-juggle.md delete mode 100644 .changeset/green-bottles-fly.md delete mode 100644 .changeset/green-cooks-double.md delete mode 100644 .changeset/happy-llamas-hope.md delete mode 100644 .changeset/happy-nights-talk.md delete mode 100644 .changeset/heavy-dots-doubt.md delete mode 100644 .changeset/heavy-tips-visit.md delete mode 100644 .changeset/hip-bugs-jam.md delete mode 100644 .changeset/honest-spiders-tickle.md delete mode 100644 .changeset/hot-teams-leave.md delete mode 100644 .changeset/huge-taxes-sin.md delete mode 100644 .changeset/humble-clouds-smell.md delete mode 100644 .changeset/hungry-bars-jump.md delete mode 100644 .changeset/hungry-feet-jump.md delete mode 100644 .changeset/icy-toes-pay.md delete mode 100644 .changeset/industry-gathering-slug-fix.md delete mode 100644 .changeset/jolly-chefs-knock.md delete mode 100644 .changeset/jolly-houses-shake.md delete mode 100644 .changeset/jolly-spoons-jump.md delete mode 100644 .changeset/kind-horses-fry.md delete mode 100644 .changeset/late-candles-invite.md delete mode 100644 .changeset/late-teeth-divide.md delete mode 100644 .changeset/lazy-doors-read.md delete mode 100644 .changeset/legal-dodos-rush.md delete mode 100644 .changeset/lemon-cats-brake.md delete mode 100644 .changeset/link-org-direct.md delete mode 100644 .changeset/loose-feet-live.md delete mode 100644 .changeset/loud-ducks-cough.md delete mode 100644 .changeset/loud-hairs-repeat.md delete mode 100644 .changeset/loud-pigs-heal.md delete mode 100644 .changeset/lovely-banks-argue.md delete mode 100644 .changeset/lovely-dragons-know.md delete mode 100644 .changeset/many-cups-teach.md delete mode 100644 .changeset/many-doors-shine.md delete mode 100644 .changeset/member-search-intros.md delete mode 100644 .changeset/metal-ravens-argue.md delete mode 100644 .changeset/mighty-needles-count.md delete mode 100644 .changeset/mighty-rings-add.md delete mode 100644 .changeset/modern-maps-happen.md delete mode 100644 .changeset/modern-planets-marry.md delete mode 100644 .changeset/neat-things-lay.md delete mode 100644 .changeset/new-coins-think.md delete mode 100644 .changeset/new-meals-battle.md delete mode 100644 .changeset/new-olives-play.md delete mode 100644 .changeset/new-suns-study.md delete mode 100644 .changeset/nice-camels-serve.md delete mode 100644 .changeset/nice-meals-help.md delete mode 100644 .changeset/nine-lies-raise.md delete mode 100644 .changeset/nine-rings-beam.md delete mode 100644 .changeset/nine-views-clap.md delete mode 100644 .changeset/ninety-oranges-rush.md delete mode 100644 .changeset/old-geese-dress.md delete mode 100644 .changeset/old-shirts-hammer.md delete mode 100644 .changeset/olive-buses-doubt.md delete mode 100644 .changeset/olive-coats-bathe.md delete mode 100644 .changeset/open-rooms-tap.md delete mode 100644 .changeset/open-squids-flow.md delete mode 100644 .changeset/orange-bottles-reply.md delete mode 100644 .changeset/orange-coins-wish.md delete mode 100644 .changeset/perky-deserts-matter.md delete mode 100644 .changeset/personal-workspace-enforcement.md delete mode 100644 .changeset/petite-eels-fly.md delete mode 100644 .changeset/pink-corners-allow.md delete mode 100644 .changeset/plain-needles-smell.md delete mode 100644 .changeset/plain-terms-sip.md delete mode 100644 .changeset/plenty-trees-relate.md delete mode 100644 .changeset/polite-candies-rescue.md delete mode 100644 .changeset/polite-candles-walk.md delete mode 100644 .changeset/polite-cycles-hang.md delete mode 100644 .changeset/polite-socks-swim.md delete mode 100644 .changeset/polite-tables-fail.md delete mode 100644 .changeset/polite-webs-cheat.md delete mode 100644 .changeset/posthog-analytics.md delete mode 100644 .changeset/pretty-cameras-jog.md delete mode 100644 .changeset/pretty-crabs-create.md delete mode 100644 .changeset/pretty-horses-add.md delete mode 100644 .changeset/proud-jars-attack.md delete mode 100644 .changeset/proud-pugs-punch.md delete mode 100644 .changeset/public-needles-argue.md delete mode 100644 .changeset/puny-terms-start.md delete mode 100644 .changeset/purple-rice-unite.md delete mode 100644 .changeset/quick-pandas-invent.md delete mode 100644 .changeset/ready-breads-matter.md delete mode 100644 .changeset/ready-kids-teach.md delete mode 100644 .changeset/real-cases-join.md delete mode 100644 .changeset/real-places-float.md delete mode 100644 .changeset/red-mails-do.md delete mode 100644 .changeset/release-relaxed-schemas.md delete mode 100644 .changeset/ripe-ghosts-bet.md delete mode 100644 .changeset/ripe-zebras-sell.md delete mode 100644 .changeset/salty-cooks-grin.md delete mode 100644 .changeset/seven-deer-marry.md delete mode 100644 .changeset/seven-heads-smile.md delete mode 100644 .changeset/seven-rules-invent.md delete mode 100644 .changeset/seven-squids-carry.md delete mode 100644 .changeset/shaggy-rocks-train.md delete mode 100644 .changeset/shaky-paths-attend.md delete mode 100644 .changeset/shiny-points-spend.md delete mode 100644 .changeset/short-clouds-feel.md delete mode 100644 .changeset/shy-bats-run.md delete mode 100644 .changeset/silent-cars-watch.md delete mode 100644 .changeset/silent-crews-lose.md delete mode 100644 .changeset/silly-readers-hunt.md delete mode 100644 .changeset/silver-goats-exist.md delete mode 100644 .changeset/silver-suns-sell.md delete mode 100644 .changeset/sixty-colts-bathe.md delete mode 100644 .changeset/sixty-ends-ask.md delete mode 100644 .changeset/sixty-paws-punch.md delete mode 100644 .changeset/skip-welcome-engaged-users.md delete mode 100644 .changeset/slack-engagement-scores.md delete mode 100644 .changeset/slick-camels-hide.md delete mode 100644 .changeset/slick-singers-prove.md delete mode 100644 .changeset/slick-tigers-invite.md delete mode 100644 .changeset/slimy-meteors-grab.md delete mode 100644 .changeset/slimy-singers-care.md delete mode 100644 .changeset/slow-snails-stand.md delete mode 100644 .changeset/slow-trains-agree.md delete mode 100644 .changeset/small-geese-leave.md delete mode 100644 .changeset/smart-olives-sort.md delete mode 100644 .changeset/smooth-rice-chew.md delete mode 100644 .changeset/soft-apples-play.md delete mode 100644 .changeset/soft-rooms-spend.md delete mode 100644 .changeset/solid-hotels-occur.md delete mode 100644 .changeset/solid-ideas-shake.md delete mode 100644 .changeset/some-phones-study.md delete mode 100644 .changeset/some-teeth-judge.md delete mode 100644 .changeset/sparkly-camels-dream.md delete mode 100644 .changeset/sparkly-onions-hug.md delete mode 100644 .changeset/spicy-animals-fail.md delete mode 100644 .changeset/spicy-doors-flow.md delete mode 100644 .changeset/spicy-plants-rescue.md delete mode 100644 .changeset/spicy-tigers-double.md delete mode 100644 .changeset/stale-cobras-smile.md delete mode 100644 .changeset/stale-hats-dance.md delete mode 100644 .changeset/stale-tips-drive.md delete mode 100644 .changeset/strict-foxes-wash.md delete mode 100644 .changeset/strict-lamps-turn.md delete mode 100644 .changeset/sunny-apples-jam.md delete mode 100644 .changeset/sunny-bears-stare.md delete mode 100644 .changeset/sweet-brooms-give.md delete mode 100644 .changeset/sweet-planes-follow.md delete mode 100644 .changeset/sweet-queens-send.md delete mode 100644 .changeset/sweet-taxis-act.md delete mode 100644 .changeset/swift-points-sniff.md delete mode 100644 .changeset/tame-hornets-peel.md delete mode 100644 .changeset/tame-hounds-swim.md delete mode 100644 .changeset/tame-llamas-shake.md delete mode 100644 .changeset/tasty-sails-tie.md delete mode 100644 .changeset/ten-bikes-shout.md delete mode 100644 .changeset/ten-crews-joke.md delete mode 100644 .changeset/ten-experts-fall.md delete mode 100644 .changeset/ten-groups-heal.md delete mode 100644 .changeset/ten-towns-admire.md delete mode 100644 .changeset/tender-experts-repeat.md delete mode 100644 .changeset/tender-jeans-double.md delete mode 100644 .changeset/tender-needles-beg.md delete mode 100644 .changeset/tender-pants-glow.md delete mode 100644 .changeset/thick-cows-add.md delete mode 100644 .changeset/thin-lions-trade.md delete mode 100644 .changeset/thirty-dots-feel.md delete mode 100644 .changeset/thirty-papayas-rescue.md delete mode 100644 .changeset/thirty-regions-dance.md delete mode 100644 .changeset/thirty-snails-kneel.md delete mode 100644 .changeset/thirty-snakes-fix.md delete mode 100644 .changeset/tiny-games-decide.md delete mode 100644 .changeset/tiny-news-smash.md delete mode 100644 .changeset/tired-rockets-lie.md delete mode 100644 .changeset/tricky-baths-obey.md delete mode 100644 .changeset/tricky-worms-push.md delete mode 100644 .changeset/twelve-baboons-smell.md delete mode 100644 .changeset/twelve-books-cross.md delete mode 100644 .changeset/twenty-ads-lay.md delete mode 100644 .changeset/twenty-bobcats-fall.md delete mode 100644 .changeset/wacky-seals-eat.md delete mode 100644 .changeset/wacky-things-refuse.md delete mode 100644 .changeset/wacky-worms-speak.md delete mode 100644 .changeset/warm-jars-attack.md delete mode 100644 .changeset/warm-lamps-accept.md delete mode 100644 .changeset/wet-keys-wash.md delete mode 100644 .changeset/wicked-islands-tickle.md delete mode 100644 .changeset/wicked-memes-act.md delete mode 100644 .changeset/wide-kiwis-say.md delete mode 100644 .changeset/wide-shirts-attack.md delete mode 100644 .changeset/wise-bags-invite.md delete mode 100644 .changeset/wise-places-invent.md delete mode 100644 .changeset/wise-tigers-lose.md delete mode 100644 .changeset/witty-teeth-live.md delete mode 100644 .changeset/yellow-ghosts-appear.md delete mode 100644 .changeset/yellow-knives-warn.md delete mode 100644 .changeset/yellow-llamas-lie.md delete mode 100644 .changeset/young-baths-kiss.md delete mode 100644 .changeset/young-masks-care.md delete mode 100644 .changeset/yummy-areas-bet.md create mode 100644 dist/schemas/2.5.3/adagents.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/build-creative-request.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/build-creative-response.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/create-media-buy-request.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/create-media-buy-response.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/get-media-buy-delivery-request.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/get-media-buy-delivery-response.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/get-products-request.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/get-products-response.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/list-authorized-properties-request.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/list-authorized-properties-response.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/list-creative-formats-request.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/list-creative-formats-response.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/list-creatives-request.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/list-creatives-response.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/package-request.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/provide-performance-feedback-request.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/provide-performance-feedback-response.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/sync-creatives-request.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/sync-creatives-response.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/update-media-buy-request.json create mode 100644 dist/schemas/2.5.3/bundled/media-buy/update-media-buy-response.json create mode 100644 dist/schemas/2.5.3/bundled/signals/activate-signal-request.json create mode 100644 dist/schemas/2.5.3/bundled/signals/activate-signal-response.json create mode 100644 dist/schemas/2.5.3/bundled/signals/get-signals-request.json create mode 100644 dist/schemas/2.5.3/bundled/signals/get-signals-response.json create mode 100644 dist/schemas/2.5.3/core/activation-key.json create mode 100644 dist/schemas/2.5.3/core/assets/audio-asset.json create mode 100644 dist/schemas/2.5.3/core/assets/css-asset.json create mode 100644 dist/schemas/2.5.3/core/assets/daast-asset.json create mode 100644 dist/schemas/2.5.3/core/assets/html-asset.json create mode 100644 dist/schemas/2.5.3/core/assets/image-asset.json create mode 100644 dist/schemas/2.5.3/core/assets/javascript-asset.json create mode 100644 dist/schemas/2.5.3/core/assets/markdown-asset.json create mode 100644 dist/schemas/2.5.3/core/assets/text-asset.json create mode 100644 dist/schemas/2.5.3/core/assets/url-asset.json create mode 100644 dist/schemas/2.5.3/core/assets/vast-asset.json create mode 100644 dist/schemas/2.5.3/core/assets/video-asset.json create mode 100644 dist/schemas/2.5.3/core/assets/webhook-asset.json create mode 100644 dist/schemas/2.5.3/core/async-response-data.json create mode 100644 dist/schemas/2.5.3/core/brand-manifest-ref.json create mode 100644 dist/schemas/2.5.3/core/brand-manifest.json create mode 100644 dist/schemas/2.5.3/core/context.json create mode 100644 dist/schemas/2.5.3/core/creative-asset.json create mode 100644 dist/schemas/2.5.3/core/creative-assignment.json create mode 100644 dist/schemas/2.5.3/core/creative-filters.json create mode 100644 dist/schemas/2.5.3/core/creative-manifest.json create mode 100644 dist/schemas/2.5.3/core/creative-policy.json create mode 100644 dist/schemas/2.5.3/core/delivery-metrics.json create mode 100644 dist/schemas/2.5.3/core/deployment.json create mode 100644 dist/schemas/2.5.3/core/destination.json create mode 100644 dist/schemas/2.5.3/core/error.json create mode 100644 dist/schemas/2.5.3/core/ext.json create mode 100644 dist/schemas/2.5.3/core/format-id.json create mode 100644 dist/schemas/2.5.3/core/format.json create mode 100644 dist/schemas/2.5.3/core/frequency-cap.json create mode 100644 dist/schemas/2.5.3/core/mcp-webhook-payload.json create mode 100644 dist/schemas/2.5.3/core/measurement.json create mode 100644 dist/schemas/2.5.3/core/media-buy.json create mode 100644 dist/schemas/2.5.3/core/package.json create mode 100644 dist/schemas/2.5.3/core/performance-feedback.json create mode 100644 dist/schemas/2.5.3/core/placement.json create mode 100644 dist/schemas/2.5.3/core/pricing-option.json create mode 100644 dist/schemas/2.5.3/core/product-filters.json create mode 100644 dist/schemas/2.5.3/core/product.json create mode 100644 dist/schemas/2.5.3/core/promoted-offerings.json create mode 100644 dist/schemas/2.5.3/core/promoted-products.json create mode 100644 dist/schemas/2.5.3/core/property-id.json create mode 100644 dist/schemas/2.5.3/core/property-tag.json create mode 100644 dist/schemas/2.5.3/core/property.json create mode 100644 dist/schemas/2.5.3/core/protocol-envelope.json create mode 100644 dist/schemas/2.5.3/core/publisher-property-selector.json create mode 100644 dist/schemas/2.5.3/core/push-notification-config.json create mode 100644 dist/schemas/2.5.3/core/reporting-capabilities.json create mode 100644 dist/schemas/2.5.3/core/response.json create mode 100644 dist/schemas/2.5.3/core/signal-filters.json create mode 100644 dist/schemas/2.5.3/core/start-timing.json create mode 100644 dist/schemas/2.5.3/core/sub-asset.json create mode 100644 dist/schemas/2.5.3/core/targeting.json create mode 100644 dist/schemas/2.5.3/core/tasks-get-request.json create mode 100644 dist/schemas/2.5.3/core/tasks-get-response.json create mode 100644 dist/schemas/2.5.3/core/tasks-list-request.json create mode 100644 dist/schemas/2.5.3/core/tasks-list-response.json create mode 100644 dist/schemas/2.5.3/creative/asset-types/index.json create mode 100644 dist/schemas/2.5.3/creative/list-creative-formats-request.json create mode 100644 dist/schemas/2.5.3/creative/list-creative-formats-response.json create mode 100644 dist/schemas/2.5.3/creative/preview-creative-request.json create mode 100644 dist/schemas/2.5.3/creative/preview-creative-response.json create mode 100644 dist/schemas/2.5.3/creative/preview-render.json create mode 100644 dist/schemas/2.5.3/enums/adcp-domain.json create mode 100644 dist/schemas/2.5.3/enums/asset-content-type.json create mode 100644 dist/schemas/2.5.3/enums/auth-scheme.json create mode 100644 dist/schemas/2.5.3/enums/available-metric.json create mode 100644 dist/schemas/2.5.3/enums/channels.json create mode 100644 dist/schemas/2.5.3/enums/co-branding-requirement.json create mode 100644 dist/schemas/2.5.3/enums/creative-action.json create mode 100644 dist/schemas/2.5.3/enums/creative-agent-capability.json create mode 100644 dist/schemas/2.5.3/enums/creative-sort-field.json create mode 100644 dist/schemas/2.5.3/enums/creative-status.json create mode 100644 dist/schemas/2.5.3/enums/daast-tracking-event.json create mode 100644 dist/schemas/2.5.3/enums/daast-version.json create mode 100644 dist/schemas/2.5.3/enums/delivery-type.json create mode 100644 dist/schemas/2.5.3/enums/dimension-unit.json create mode 100644 dist/schemas/2.5.3/enums/feed-format.json create mode 100644 dist/schemas/2.5.3/enums/feedback-source.json create mode 100644 dist/schemas/2.5.3/enums/format-category.json create mode 100644 dist/schemas/2.5.3/enums/format-id-parameter.json create mode 100644 dist/schemas/2.5.3/enums/frequency-cap-scope.json create mode 100644 dist/schemas/2.5.3/enums/history-entry-type.json create mode 100644 dist/schemas/2.5.3/enums/http-method.json create mode 100644 dist/schemas/2.5.3/enums/identifier-types.json create mode 100644 dist/schemas/2.5.3/enums/javascript-module-type.json create mode 100644 dist/schemas/2.5.3/enums/landing-page-requirement.json create mode 100644 dist/schemas/2.5.3/enums/markdown-flavor.json create mode 100644 dist/schemas/2.5.3/enums/media-buy-status.json create mode 100644 dist/schemas/2.5.3/enums/metric-type.json create mode 100644 dist/schemas/2.5.3/enums/notification-type.json create mode 100644 dist/schemas/2.5.3/enums/pacing.json create mode 100644 dist/schemas/2.5.3/enums/preview-output-format.json create mode 100644 dist/schemas/2.5.3/enums/pricing-model.json create mode 100644 dist/schemas/2.5.3/enums/property-type.json create mode 100644 dist/schemas/2.5.3/enums/publisher-identifier-types.json create mode 100644 dist/schemas/2.5.3/enums/reporting-frequency.json create mode 100644 dist/schemas/2.5.3/enums/signal-catalog-type.json create mode 100644 dist/schemas/2.5.3/enums/sort-direction.json create mode 100644 dist/schemas/2.5.3/enums/standard-format-ids.json create mode 100644 dist/schemas/2.5.3/enums/task-status.json create mode 100644 dist/schemas/2.5.3/enums/task-type.json create mode 100644 dist/schemas/2.5.3/enums/update-frequency.json create mode 100644 dist/schemas/2.5.3/enums/url-asset-type.json create mode 100644 dist/schemas/2.5.3/enums/validation-mode.json create mode 100644 dist/schemas/2.5.3/enums/vast-tracking-event.json create mode 100644 dist/schemas/2.5.3/enums/vast-version.json create mode 100644 dist/schemas/2.5.3/enums/webhook-response-type.json create mode 100644 dist/schemas/2.5.3/enums/webhook-security-method.json create mode 100644 dist/schemas/2.5.3/index.json create mode 100644 dist/schemas/2.5.3/media-buy/build-creative-request.json create mode 100644 dist/schemas/2.5.3/media-buy/build-creative-response.json create mode 100644 dist/schemas/2.5.3/media-buy/create-media-buy-async-response-input-required.json create mode 100644 dist/schemas/2.5.3/media-buy/create-media-buy-async-response-submitted.json create mode 100644 dist/schemas/2.5.3/media-buy/create-media-buy-async-response-working.json create mode 100644 dist/schemas/2.5.3/media-buy/create-media-buy-request.json create mode 100644 dist/schemas/2.5.3/media-buy/create-media-buy-response.json create mode 100644 dist/schemas/2.5.3/media-buy/get-media-buy-delivery-request.json create mode 100644 dist/schemas/2.5.3/media-buy/get-media-buy-delivery-response.json create mode 100644 dist/schemas/2.5.3/media-buy/get-products-async-response-input-required.json create mode 100644 dist/schemas/2.5.3/media-buy/get-products-async-response-submitted.json create mode 100644 dist/schemas/2.5.3/media-buy/get-products-async-response-working.json create mode 100644 dist/schemas/2.5.3/media-buy/get-products-request.json create mode 100644 dist/schemas/2.5.3/media-buy/get-products-response.json create mode 100644 dist/schemas/2.5.3/media-buy/list-authorized-properties-request.json create mode 100644 dist/schemas/2.5.3/media-buy/list-authorized-properties-response.json create mode 100644 dist/schemas/2.5.3/media-buy/list-creative-formats-request.json create mode 100644 dist/schemas/2.5.3/media-buy/list-creative-formats-response.json create mode 100644 dist/schemas/2.5.3/media-buy/list-creatives-request.json create mode 100644 dist/schemas/2.5.3/media-buy/list-creatives-response.json create mode 100644 dist/schemas/2.5.3/media-buy/package-request.json create mode 100644 dist/schemas/2.5.3/media-buy/provide-performance-feedback-request.json create mode 100644 dist/schemas/2.5.3/media-buy/provide-performance-feedback-response.json create mode 100644 dist/schemas/2.5.3/media-buy/sync-creatives-async-response-input-required.json create mode 100644 dist/schemas/2.5.3/media-buy/sync-creatives-async-response-submitted.json create mode 100644 dist/schemas/2.5.3/media-buy/sync-creatives-async-response-working.json create mode 100644 dist/schemas/2.5.3/media-buy/sync-creatives-request.json create mode 100644 dist/schemas/2.5.3/media-buy/sync-creatives-response.json create mode 100644 dist/schemas/2.5.3/media-buy/update-media-buy-async-response-input-required.json create mode 100644 dist/schemas/2.5.3/media-buy/update-media-buy-async-response-submitted.json create mode 100644 dist/schemas/2.5.3/media-buy/update-media-buy-async-response-working.json create mode 100644 dist/schemas/2.5.3/media-buy/update-media-buy-request.json create mode 100644 dist/schemas/2.5.3/media-buy/update-media-buy-response.json create mode 100644 dist/schemas/2.5.3/pricing-options/cpc-option.json create mode 100644 dist/schemas/2.5.3/pricing-options/cpcv-option.json create mode 100644 dist/schemas/2.5.3/pricing-options/cpm-auction-option.json create mode 100644 dist/schemas/2.5.3/pricing-options/cpm-fixed-option.json create mode 100644 dist/schemas/2.5.3/pricing-options/cpp-option.json create mode 100644 dist/schemas/2.5.3/pricing-options/cpv-option.json create mode 100644 dist/schemas/2.5.3/pricing-options/flat-rate-option.json create mode 100644 dist/schemas/2.5.3/pricing-options/vcpm-auction-option.json create mode 100644 dist/schemas/2.5.3/pricing-options/vcpm-fixed-option.json create mode 100644 dist/schemas/2.5.3/protocols/adcp-extension.json create mode 100644 dist/schemas/2.5.3/signals/activate-signal-request.json create mode 100644 dist/schemas/2.5.3/signals/activate-signal-response.json create mode 100644 dist/schemas/2.5.3/signals/get-signals-request.json create mode 100644 dist/schemas/2.5.3/signals/get-signals-response.json diff --git a/.changeset/account-lifecycle-stage.md b/.changeset/account-lifecycle-stage.md deleted file mode 100644 index 1a3d295713..0000000000 --- a/.changeset/account-lifecycle-stage.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Internal tooling: Add unified account lifecycle stage to admin tools diff --git a/.changeset/activity-user-names.md b/.changeset/activity-user-names.md deleted file mode 100644 index 3b1026e46e..0000000000 --- a/.changeset/activity-user-names.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Display user names instead of Slack IDs in proactive outreach history diff --git a/.changeset/addie-bot-token-fix.md b/.changeset/addie-bot-token-fix.md deleted file mode 100644 index b527df12db..0000000000 --- a/.changeset/addie-bot-token-fix.md +++ /dev/null @@ -1,8 +0,0 @@ ---- ---- - -Server-only: Fix Addie using wrong bot token to send messages. - -Addie was using SLACK_BOT_TOKEN (AAO bot) to send messages, which -failed with channel_not_found because Addie's DM channels are only -accessible via ADDIE_BOT_TOKEN. diff --git a/.changeset/addie-dm-no-threading.md b/.changeset/addie-dm-no-threading.md deleted file mode 100644 index 7c2dd05f25..0000000000 --- a/.changeset/addie-dm-no-threading.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix DM response posting as thread instead of regular message diff --git a/.changeset/addie-email-domain-linking.md b/.changeset/addie-email-domain-linking.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/addie-email-domain-linking.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/addie-first-dm-fix.md b/.changeset/addie-first-dm-fix.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/addie-first-dm-fix.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/addie-home-nav-fix.md b/.changeset/addie-home-nav-fix.md deleted file mode 100644 index c9428e1ddd..0000000000 --- a/.changeset/addie-home-nav-fix.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix Addie chat home button navigation - clicking Home now properly shows the dashboard diff --git a/.changeset/addie-knowledge-improvements.md b/.changeset/addie-knowledge-improvements.md deleted file mode 100644 index 75e77a5735..0000000000 --- a/.changeset/addie-knowledge-improvements.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Addie admin improvements: Unified Knowledge Base tab, Slack message indexing, and curated content system. Server-only changes, no protocol impact. diff --git a/.changeset/addie-newline-fix.md b/.changeset/addie-newline-fix.md deleted file mode 100644 index 1658dde4dd..0000000000 --- a/.changeset/addie-newline-fix.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix missing newlines between context sections in Addie message formatting diff --git a/.changeset/addie-news-feeds.md b/.changeset/addie-news-feeds.md deleted file mode 100644 index babf21bb3d..0000000000 --- a/.changeset/addie-news-feeds.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add get_recent_news tool to Addie for browsing curated industry feeds diff --git a/.changeset/addie-role-suggestions.md b/.changeset/addie-role-suggestions.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/addie-role-suggestions.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/addie-slack-endpoint.md b/.changeset/addie-slack-endpoint.md deleted file mode 100644 index ce0693d8b8..0000000000 --- a/.changeset/addie-slack-endpoint.md +++ /dev/null @@ -1,18 +0,0 @@ ---- ---- - -Server-only changes: Refactor Slack endpoints with consistent URL structure. - -**BREAKING CHANGE:** Slack endpoint URLs changed: -- AAO bot: `/api/slack/events` → `/api/slack/aaobot/events` -- AAO bot: `/api/slack/commands` → `/api/slack/aaobot/commands` -- Addie: Added `/api/slack/addie/events` - -Update Slack app Event Subscription URLs in production. - -Also: -- Extract Slack middleware to `server/src/middleware/slack.ts` -- Extract public Slack routes to `server/src/routes/slack.ts` -- Extract admin Slack routes to `server/src/routes/admin/slack.ts` -- Extract admin Email routes to `server/src/routes/admin/email.ts` -- Create shared unified users cache in `server/src/cache/unified-users.ts` diff --git a/.changeset/addie-thread-reply-fix.md b/.changeset/addie-thread-reply-fix.md deleted file mode 100644 index 88e73d82fb..0000000000 --- a/.changeset/addie-thread-reply-fix.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix Addie mentions to reply in threads with full context diff --git a/.changeset/addie-thread-sharing.md b/.changeset/addie-thread-sharing.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/addie-thread-sharing.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/addie-user-simulation.md b/.changeset/addie-user-simulation.md deleted file mode 100644 index 2e2228725e..0000000000 --- a/.changeset/addie-user-simulation.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add admin user simulation support for Addie web chat debugging (server-side implementation only, no protocol changes). diff --git a/.changeset/afraid-ends-mate.md b/.changeset/afraid-ends-mate.md deleted file mode 100644 index 6dcf0b4089..0000000000 --- a/.changeset/afraid-ends-mate.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix missing column references in admin stats queries (engagement_reasons, next_step, subscription_status) diff --git a/.changeset/afraid-hairs-add.md b/.changeset/afraid-hairs-add.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/afraid-hairs-add.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/afraid-pigs-sit.md b/.changeset/afraid-pigs-sit.md deleted file mode 100644 index c4d6ca9058..0000000000 --- a/.changeset/afraid-pigs-sit.md +++ /dev/null @@ -1,8 +0,0 @@ ---- ---- - -Add member welcome and user signup email notifications via Resend. - -- New member thank you email sent after Stripe subscription created -- User signup email with conditional content based on org subscription status -- Updated naming from "Alliance for Agentic Advertising" to "AgenticAdvertising.org" diff --git a/.changeset/all-dolls-beam.md b/.changeset/all-dolls-beam.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/all-dolls-beam.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/all-llamas-sin.md b/.changeset/all-llamas-sin.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/all-llamas-sin.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/allow-extra-fields.md b/.changeset/allow-extra-fields.md deleted file mode 100644 index 85eea668ec..0000000000 --- a/.changeset/allow-extra-fields.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"adcontextprotocol": patch ---- - -Allow additional properties in all JSON schemas for forward compatibility - -Changes all schemas from `"additionalProperties": false` to `"additionalProperties": true`. This enables clients running older schema versions to accept responses from servers with newer schemas without breaking validation - a standard practice for protocol evolution in distributed systems. diff --git a/.changeset/anthropic-timeout.md b/.changeset/anthropic-timeout.md deleted file mode 100644 index 18d0d43420..0000000000 --- a/.changeset/anthropic-timeout.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add timeout to Anthropic API calls in webhook handler (no protocol changes) diff --git a/.changeset/better-papayas-attack.md b/.changeset/better-papayas-attack.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/better-papayas-attack.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/big-lies-end.md b/.changeset/big-lies-end.md deleted file mode 100644 index b916c68583..0000000000 --- a/.changeset/big-lies-end.md +++ /dev/null @@ -1,6 +0,0 @@ ---- ---- - -Fix duplicate migration version 039 and add CI check. - -This is an internal fix for database migration numbering and does not affect the protocol specification or public API. diff --git a/.changeset/block-gmail-corp-domain.md b/.changeset/block-gmail-corp-domain.md deleted file mode 100644 index aefb92e1e1..0000000000 --- a/.changeset/block-gmail-corp-domain.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix domain health page to exclude free email providers (gmail.com, etc.) from "Related Organizations" section diff --git a/.changeset/block-personal-workspace-merge.md b/.changeset/block-personal-workspace-merge.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/block-personal-workspace-merge.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/blue-tables-agree.md b/.changeset/blue-tables-agree.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/blue-tables-agree.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/bold-hornets-behave.md b/.changeset/bold-hornets-behave.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/bold-hornets-behave.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/brave-emus-invite.md b/.changeset/brave-emus-invite.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/brave-emus-invite.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/brave-forks-crash.md b/.changeset/brave-forks-crash.md deleted file mode 100644 index 186b5d34c5..0000000000 --- a/.changeset/brave-forks-crash.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix Addie's response to empty mentions - when mentioned with just "@Addie" (no question), she no longer incorrectly welcomes the mentioner as if they were new to the channel. diff --git a/.changeset/breezy-cats-own.md b/.changeset/breezy-cats-own.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/breezy-cats-own.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/breezy-pens-kick.md b/.changeset/breezy-pens-kick.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/breezy-pens-kick.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/breezy-zoos-hope.md b/.changeset/breezy-zoos-hope.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/breezy-zoos-hope.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/bright-kids-say.md b/.changeset/bright-kids-say.md deleted file mode 100644 index d8ebeebfc6..0000000000 --- a/.changeset/bright-kids-say.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -fix: correct GitHub issue drafting repo and add confidentiality guidelines for Addie diff --git a/.changeset/brown-doors-fly.md b/.changeset/brown-doors-fly.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/brown-doors-fly.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/bumpy-carrots-change.md b/.changeset/bumpy-carrots-change.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/bumpy-carrots-change.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/bumpy-cycles-hear.md b/.changeset/bumpy-cycles-hear.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/bumpy-cycles-hear.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/bumpy-kings-follow.md b/.changeset/bumpy-kings-follow.md deleted file mode 100644 index 2a9f4be3f0..0000000000 --- a/.changeset/bumpy-kings-follow.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix Docker image to include docs directory and git for Addie knowledge search and external repo indexing. diff --git a/.changeset/busy-swans-judge.md b/.changeset/busy-swans-judge.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/busy-swans-judge.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/calm-lamps-doubt.md b/.changeset/calm-lamps-doubt.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/calm-lamps-doubt.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/chatty-tools-lead.md b/.changeset/chatty-tools-lead.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/chatty-tools-lead.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/chubby-baboons-kick.md b/.changeset/chubby-baboons-kick.md deleted file mode 100644 index 4d017f0a0e..0000000000 --- a/.changeset/chubby-baboons-kick.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix invoice modal positioning: modals now appear centered on screen instead of at page bottom. diff --git a/.changeset/chubby-books-film.md b/.changeset/chubby-books-film.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/chubby-books-film.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/chubby-pumas-smoke.md b/.changeset/chubby-pumas-smoke.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/chubby-pumas-smoke.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/clean-ghosts-beam.md b/.changeset/clean-ghosts-beam.md deleted file mode 100644 index 87a9ee43f8..0000000000 --- a/.changeset/clean-ghosts-beam.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Documentation cleanup - streamlined CLAUDE.md to workspace-specific guidance only. No protocol changes. diff --git a/.changeset/clean-groups-press.md b/.changeset/clean-groups-press.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/clean-groups-press.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/clean-jokes-invent.md b/.changeset/clean-jokes-invent.md deleted file mode 100644 index 3d7d2144d3..0000000000 --- a/.changeset/clean-jokes-invent.md +++ /dev/null @@ -1,10 +0,0 @@ ---- ---- - -Add multi-select company types with "Data & Measurement" and "AI & Tech Platforms" categories (registry only) - -- Organizations can now have multiple company types (e.g., Microsoft can be both "brand" and "ai") -- Added new company type categories: "data" (Data & Measurement) and "ai" (AI & Tech Platforms) -- Renamed "AI Infrastructure" to "AI & Tech Platforms" to include agent builders -- Created centralized company-types config for frontend and backend consistency -- Migration preserves existing single-value data while enabling multi-select diff --git a/.changeset/clean-pumas-open.md b/.changeset/clean-pumas-open.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/clean-pumas-open.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/clear-colts-wink.md b/.changeset/clear-colts-wink.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/clear-colts-wink.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/clever-pets-brush.md b/.changeset/clever-pets-brush.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/clever-pets-brush.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/clickable-user-name.md b/.changeset/clickable-user-name.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/clickable-user-name.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/cold-shrimps-taste.md b/.changeset/cold-shrimps-taste.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/cold-shrimps-taste.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/common-cougars-unite.md b/.changeset/common-cougars-unite.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/common-cougars-unite.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/common-geckos-wash.md b/.changeset/common-geckos-wash.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/common-geckos-wash.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/cool-memes-wear.md b/.changeset/cool-memes-wear.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/cool-memes-wear.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/crisp-moments-wear.md b/.changeset/crisp-moments-wear.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/crisp-moments-wear.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/crisp-pets-tease.md b/.changeset/crisp-pets-tease.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/crisp-pets-tease.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/crisp-readers-rule.md b/.changeset/crisp-readers-rule.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/crisp-readers-rule.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/cuddly-pets-sort.md b/.changeset/cuddly-pets-sort.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/cuddly-pets-sort.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/cuddly-windows-accept.md b/.changeset/cuddly-windows-accept.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/cuddly-windows-accept.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/curvy-rice-bake.md b/.changeset/curvy-rice-bake.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/curvy-rice-bake.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/cute-taxes-boil.md b/.changeset/cute-taxes-boil.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/cute-taxes-boil.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/cyan-cooks-hang.md b/.changeset/cyan-cooks-hang.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/cyan-cooks-hang.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/dashboard-user-menu.md b/.changeset/dashboard-user-menu.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/dashboard-user-menu.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/david-event-mgmt-fix.md b/.changeset/david-event-mgmt-fix.md deleted file mode 100644 index b0344a2e57..0000000000 --- a/.changeset/david-event-mgmt-fix.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix chapter leader event management permission checks for users added via Slack diff --git a/.changeset/dirty-kiwis-cheer.md b/.changeset/dirty-kiwis-cheer.md deleted file mode 100644 index 172b54291e..0000000000 --- a/.changeset/dirty-kiwis-cheer.md +++ /dev/null @@ -1,46 +0,0 @@ ---- ---- - -Improve Addie router intelligence and search observability - -**Router improvements:** -- Add `usage_hints` field to AddieTool interface for router-specific guidance -- Router now builds tool descriptions from tool definitions (no duplication) -- Distinguish "how does X work?" (search_docs) from "validate my X" (validate_adagents) -- Separate expertise areas for validation vs learning questions - -**Docs indexing:** -- Extract markdown headings as separate searchable artifacts -- Generate anchor links for deep linking to specific sections -- Build headings index alongside docs index (1659 headings from 100 docs) - -**Search tracking:** -- Log all search queries for pattern analysis -- Track results count, latency, and tool used -- Enable content gap detection via zero-result query analysis - -**Prompt improvements:** -- Strengthen GitHub issue drafting instructions - users cannot see tool outputs -- Add conversation context maintenance guidance to prevent entity substitution - -**Bug fixes:** -- Fix DM Assistant thread context loss - now fetches conversation history from database -- Previous messages are passed to Claude so it maintains context across turns - -**Member insights integration:** -- Router now uses member insights (role, interests, pain points) for smarter tool selection -- Fetch member context and insights in parallel for better performance -- Add in-memory cache with 30-minute TTL (long since we invalidate on writes) -- Prefetch insights when user opens Addie (before first message) -- Auto-invalidate cache when new insights are extracted or added via admin API - -**Performance optimizations:** -- Add 30-minute cache for admin status checks (isSlackUserAdmin) -- Admin status rarely changes and was being checked multiple times per conversation -- Add 30-minute cache for active insight goals (only 2 possible variants: mapped/unmapped) -- Auto-invalidate goals cache on goal create/update/delete via admin API -- Add 30-minute cache for Slack channel info (names/purposes rarely change) - -**Previous work (already in PR):** -- Log router decisions to unified thread messages -- Add config versioning for feedback analysis by configuration diff --git a/.changeset/dirty-meteors-sell.md b/.changeset/dirty-meteors-sell.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/dirty-meteors-sell.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/dirty-spies-burn.md b/.changeset/dirty-spies-burn.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/dirty-spies-burn.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/docs-versioning.md b/.changeset/docs-versioning.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/docs-versioning.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/dry-singers-pull.md b/.changeset/dry-singers-pull.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/dry-singers-pull.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/dull-dogs-live.md b/.changeset/dull-dogs-live.md deleted file mode 100644 index 724918eea8..0000000000 --- a/.changeset/dull-dogs-live.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add organization_domains table and WorkOS webhook handlers for domain sync diff --git a/.changeset/dull-rooms-battle.md b/.changeset/dull-rooms-battle.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/dull-rooms-battle.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/dull-singers-mate.md b/.changeset/dull-singers-mate.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/dull-singers-mate.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/eager-eels-sneeze.md b/.changeset/eager-eels-sneeze.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/eager-eels-sneeze.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/eager-humans-change.md b/.changeset/eager-humans-change.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/eager-humans-change.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/easy-lizards-hug.md b/.changeset/easy-lizards-hug.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/easy-lizards-hug.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/eighty-onions-leave.md b/.changeset/eighty-onions-leave.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/eighty-onions-leave.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/eighty-sloths-feel.md b/.changeset/eighty-sloths-feel.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/eighty-sloths-feel.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/eleven-rabbits-enter.md b/.changeset/eleven-rabbits-enter.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/eleven-rabbits-enter.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/eleven-wolves-return.md b/.changeset/eleven-wolves-return.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/eleven-wolves-return.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/email-tracking-logging.md b/.changeset/email-tracking-logging.md deleted file mode 100644 index 431f9dde3d..0000000000 --- a/.changeset/email-tracking-logging.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add diagnostic logging to email webhook handler (no protocol changes) diff --git a/.changeset/enable-addie-public.md b/.changeset/enable-addie-public.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/enable-addie-public.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/engagement-scoring-job.md b/.changeset/engagement-scoring-job.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/engagement-scoring-job.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/error-details-type.md b/.changeset/error-details-type.md deleted file mode 100644 index c9ee80212e..0000000000 --- a/.changeset/error-details-type.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"adcontextprotocol": patch ---- - -Add explicit type definition to error.json details property - -The `details` property in core/error.json now explicitly declares `"type": "object"` and `"additionalProperties": true`, consistent with other error details definitions in the codebase. This addresses issue #343 where the data type was unspecified. diff --git a/.changeset/event-attendance-tracking.md b/.changeset/event-attendance-tracking.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/event-attendance-tracking.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/event-visibility-fix.md b/.changeset/event-visibility-fix.md deleted file mode 100644 index 17c91e4e55..0000000000 --- a/.changeset/event-visibility-fix.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Internal: Fix event visibility and registration matching (no protocol changes) diff --git a/.changeset/every-falcons-peel.md b/.changeset/every-falcons-peel.md deleted file mode 100644 index a0791a3b6b..0000000000 --- a/.changeset/every-falcons-peel.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Update Addie's core thesis to incorporate the allocation vs efficiency framing. Advertisers are stuck at 3-5 platforms due to execution costs - the opportunity is scaling to 20+ partners, not optimizing the existing 3-5. Walled gardens benefit from AdCP because it lets them capture new allocation budgets without commoditizing their differentiation. diff --git a/.changeset/every-moose-kick.md b/.changeset/every-moose-kick.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/every-moose-kick.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fair-plants-reply.md b/.changeset/fair-plants-reply.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fair-plants-reply.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fair-turkeys-sell.md b/.changeset/fair-turkeys-sell.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fair-turkeys-sell.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/famous-rockets-report.md b/.changeset/famous-rockets-report.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/famous-rockets-report.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/famous-sides-hide.md b/.changeset/famous-sides-hide.md deleted file mode 100644 index 25bd707397..0000000000 --- a/.changeset/famous-sides-hide.md +++ /dev/null @@ -1,7 +0,0 @@ ---- ---- - -feat: Add membership metrics to admin analytics dashboard - -Adds new admin analytics endpoints and UI tables showing membership counts -broken down by company type and revenue tier. Includes CSV export functionality. diff --git a/.changeset/fast-hounds-lick.md b/.changeset/fast-hounds-lick.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fast-hounds-lick.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fast-sloths-press.md b/.changeset/fast-sloths-press.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fast-sloths-press.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fiery-actors-show.md b/.changeset/fiery-actors-show.md deleted file mode 100644 index 450cd1d6c0..0000000000 --- a/.changeset/fiery-actors-show.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add unified user counts (members + Slack-only) to accounts list and detail pages. Add Addie tools for listing organizations by user count and listing Slack users by organization. diff --git a/.changeset/fiery-days-roll.md b/.changeset/fiery-days-roll.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fiery-days-roll.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fiery-maps-tan.md b/.changeset/fiery-maps-tan.md deleted file mode 100644 index 79826f3d12..0000000000 --- a/.changeset/fiery-maps-tan.md +++ /dev/null @@ -1,12 +0,0 @@ ---- ---- - -Add call_adcp_agent tool and Claude Skills for full AdCP protocol access. - -This enables clients to execute the full AdCP spec (not just testing API) via: -- `call_adcp_agent` tool: Low-level proxy to any AdCP-compliant sales agent -- Claude Skills: Protocol knowledge for media-buy, signals, and creative - -Skills teach Claude the protocol schemas and workflows; the tool routes to -whatever agent the user specifies. Auth tokens are looked up from saved -agent context or can be provided directly. diff --git a/.changeset/fiery-sides-allow.md b/.changeset/fiery-sides-allow.md deleted file mode 100644 index c4472c0b66..0000000000 --- a/.changeset/fiery-sides-allow.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Admin AI analysis UX improvements - no protocol changes diff --git a/.changeset/fifty-bars-pump.md b/.changeset/fifty-bars-pump.md deleted file mode 100644 index d6ac9145fb..0000000000 --- a/.changeset/fifty-bars-pump.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Remove insight_goals table and consolidate goal management into outreach_goals. Admin insight goals CRUD endpoints and UI have been removed. The insight extractor and passive extraction now read from outreach_goals. diff --git a/.changeset/fifty-pandas-sleep.md b/.changeset/fifty-pandas-sleep.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fifty-pandas-sleep.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fix-addie-github-link.md b/.changeset/fix-addie-github-link.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/fix-addie-github-link.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/fix-admin-checks-mobile-pane.md b/.changeset/fix-admin-checks-mobile-pane.md deleted file mode 100644 index 8c994eaea1..0000000000 --- a/.changeset/fix-admin-checks-mobile-pane.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -No protocol changes - internal admin check consistency fix and mobile CSS improvement. diff --git a/.changeset/fix-admin-member-links.md b/.changeset/fix-admin-member-links.md deleted file mode 100644 index 7a519b2209..0000000000 --- a/.changeset/fix-admin-member-links.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix broken admin organization links and make member names clickable. diff --git a/.changeset/fix-analytics-views.md b/.changeset/fix-analytics-views.md deleted file mode 100644 index e28feb4dae..0000000000 --- a/.changeset/fix-analytics-views.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix analytics dashboard: rename "Revenue by Month" to "Bookings by Month" and fix empty "Revenue by Product" view by querying revenue_events instead of subscription_line_items. diff --git a/.changeset/fix-broken-readme-links.md b/.changeset/fix-broken-readme-links.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/fix-broken-readme-links.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/fix-company-type-validation.md b/.changeset/fix-company-type-validation.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/fix-company-type-validation.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/fix-daily-digest.md b/.changeset/fix-daily-digest.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/fix-daily-digest.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/fix-domain-health-orgs.md b/.changeset/fix-domain-health-orgs.md deleted file mode 100644 index 233db1edd7..0000000000 --- a/.changeset/fix-domain-health-orgs.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Improve admin domain health page to show existing organizations for unlinked domains instead of only proposing new prospects. diff --git a/.changeset/fix-duplicate-members.md b/.changeset/fix-duplicate-members.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fix-duplicate-members.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fix-email-preferences-table.md b/.changeset/fix-email-preferences-table.md deleted file mode 100644 index 02940fd408..0000000000 --- a/.changeset/fix-email-preferences-table.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix table name in getMemberCapabilities: email_preferences → user_email_preferences diff --git a/.changeset/fix-feed-email-slugs.md b/.changeset/fix-feed-email-slugs.md deleted file mode 100644 index 21b8491247..0000000000 --- a/.changeset/fix-feed-email-slugs.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix feed email slug lookup - prepend 'feed-' prefix to seeded email slugs to match webhook extraction diff --git a/.changeset/fix-feedback-modal.md b/.changeset/fix-feedback-modal.md deleted file mode 100644 index aa53c2615c..0000000000 --- a/.changeset/fix-feedback-modal.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix feedback modal not displaying saved feedback when viewing threads diff --git a/.changeset/fix-html-entities-news.md b/.changeset/fix-html-entities-news.md deleted file mode 100644 index 745e2fe647..0000000000 --- a/.changeset/fix-html-entities-news.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix HTML entity encoding in news feed API responses. RSS feed titles with entities like `'` are now properly decoded before being returned in API responses. diff --git a/.changeset/fix-industry-alerts-channel-filter.md b/.changeset/fix-industry-alerts-channel-filter.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/fix-industry-alerts-channel-filter.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/fix-industry-alerts-token.md b/.changeset/fix-industry-alerts-token.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/fix-industry-alerts-token.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/fix-interaction-analyzer-column.md b/.changeset/fix-interaction-analyzer-column.md deleted file mode 100644 index faab1dc733..0000000000 --- a/.changeset/fix-interaction-analyzer-column.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix organization_domains column name in interaction-analyzer query diff --git a/.changeset/fix-invoice-products.md b/.changeset/fix-invoice-products.md deleted file mode 100644 index aaa9496d8e..0000000000 --- a/.changeset/fix-invoice-products.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Filter invoice modal dropdown to show only subscription products, excluding legacy one-time invoice products. diff --git a/.changeset/fix-login-display-and-stats.md b/.changeset/fix-login-display-and-stats.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/fix-login-display-and-stats.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/fix-member-name-click.md b/.changeset/fix-member-name-click.md deleted file mode 100644 index 9aa2754cbc..0000000000 --- a/.changeset/fix-member-name-click.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -fix: escape apostrophes in user names for onclick handlers diff --git a/.changeset/fix-migration-151.md b/.changeset/fix-migration-151.md deleted file mode 100644 index 4ec327c8b6..0000000000 --- a/.changeset/fix-migration-151.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"adcontextprotocol": patch ---- - -Fix migration 151 to delete duplicates before updating Slack IDs to WorkOS IDs diff --git a/.changeset/fix-org-memberships-table.md b/.changeset/fix-org-memberships-table.md deleted file mode 100644 index fbe7f3984a..0000000000 --- a/.changeset/fix-org-memberships-table.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix organization member lookup table name in inbound email webhook diff --git a/.changeset/fix-outreach-stats.md b/.changeset/fix-outreach-stats.md deleted file mode 100644 index 8a7bfeefe1..0000000000 --- a/.changeset/fix-outreach-stats.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -fix: align frontend outreach stats field names with backend API response diff --git a/.changeset/fix-package-request-fields.md b/.changeset/fix-package-request-fields.md deleted file mode 100644 index d8b68492b7..0000000000 --- a/.changeset/fix-package-request-fields.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"adcontextprotocol": patch ---- - -Add missing fields to package request schemas for consistency with core/package.json. - -**Schema Changes:** - -- `media-buy/package-request.json`: Added `impressions` and `paused` fields -- `media-buy/update-media-buy-request.json`: Added `impressions` field to package updates - -**Details:** - -- `impressions`: Impression goal for the package (optional, minimum: 0) -- `paused`: Create package in paused state (optional, default: false) - -These fields were defined in `core/package.json` but missing from the request schemas, making it impossible to set impression goals or initial paused state when creating/updating media buys. - -**Documentation:** - -- Updated `create_media_buy` task reference with new package parameters -- Updated `update_media_buy` task reference with impressions parameter diff --git a/.changeset/fix-pending-articles.md b/.changeset/fix-pending-articles.md deleted file mode 100644 index 122252461e..0000000000 --- a/.changeset/fix-pending-articles.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix industry feeds pending article count mismatch diff --git a/.changeset/fix-perspectives-body.md b/.changeset/fix-perspectives-body.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/fix-perspectives-body.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/fix-perspectives-link.md b/.changeset/fix-perspectives-link.md deleted file mode 100644 index 6c8028d3d0..0000000000 --- a/.changeset/fix-perspectives-link.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix broken working group post links in Slack notifications and frontend diff --git a/.changeset/fix-planner-column-names.md b/.changeset/fix-planner-column-names.md deleted file mode 100644 index 3f9e4cd8d0..0000000000 --- a/.changeset/fix-planner-column-names.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix column name mismatch in getMemberCapabilities query (messages_sent → message_count, date → activity_date) diff --git a/.changeset/fix-resend-inbound-webhook.md b/.changeset/fix-resend-inbound-webhook.md deleted file mode 100644 index 678a402d5e..0000000000 --- a/.changeset/fix-resend-inbound-webhook.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix Resend inbound email webhook timeout by skipping global JSON parser diff --git a/.changeset/fix-slack-auth-security.md b/.changeset/fix-slack-auth-security.md deleted file mode 100644 index dd0727322d..0000000000 --- a/.changeset/fix-slack-auth-security.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Update dependencies to address security vulnerabilities. diff --git a/.changeset/fix-slack-leader-display.md b/.changeset/fix-slack-leader-display.md deleted file mode 100644 index 3620cb18ae..0000000000 --- a/.changeset/fix-slack-leader-display.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"adcontextprotocol": patch ---- - -fix: display Slack profile name for chapter leaders without WorkOS accounts - -Leaders added via Slack ID that haven't linked their WorkOS account now display -their Slack profile name (real_name or display_name) instead of the raw Slack -user ID (e.g., U09BEKNJ3GB). - -The getLeaders and getLeadersBatch queries now include slack_user_mappings as an -additional name source in the COALESCE chain. diff --git a/.changeset/flat-candies-crash.md b/.changeset/flat-candies-crash.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/flat-candies-crash.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/flat-friends-learn.md b/.changeset/flat-friends-learn.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/flat-friends-learn.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/flat-frogs-go.md b/.changeset/flat-frogs-go.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/flat-frogs-go.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/flat-turkeys-tell.md b/.changeset/flat-turkeys-tell.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/flat-turkeys-tell.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/floppy-apples-live.md b/.changeset/floppy-apples-live.md deleted file mode 100644 index 8134d5058f..0000000000 --- a/.changeset/floppy-apples-live.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Update @adcp/client to 3.5.2 to fix schema validation for empty publisher_domains arrays. diff --git a/.changeset/floppy-crews-greet.md b/.changeset/floppy-crews-greet.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/floppy-crews-greet.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/forty-emus-sniff.md b/.changeset/forty-emus-sniff.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/forty-emus-sniff.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/forty-symbols-kick.md b/.changeset/forty-symbols-kick.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/forty-symbols-kick.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/four-forks-go.md b/.changeset/four-forks-go.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/four-forks-go.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/four-ways-yawn.md b/.changeset/four-ways-yawn.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/four-ways-yawn.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/frank-pets-tan.md b/.changeset/frank-pets-tan.md deleted file mode 100644 index 7d477a6dad..0000000000 --- a/.changeset/frank-pets-tan.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -No protocol changes - website improvements only. diff --git a/.changeset/free-chicken-write.md b/.changeset/free-chicken-write.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/free-chicken-write.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/free-terms-send.md b/.changeset/free-terms-send.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/free-terms-send.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fresh-crews-wash.md b/.changeset/fresh-crews-wash.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fresh-crews-wash.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fresh-numbers-hide.md b/.changeset/fresh-numbers-hide.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fresh-numbers-hide.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fruity-papayas-act.md b/.changeset/fruity-papayas-act.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fruity-papayas-act.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fruity-states-spend.md b/.changeset/fruity-states-spend.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fruity-states-spend.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fruity-wasps-hope.md b/.changeset/fruity-wasps-hope.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fruity-wasps-hope.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/funky-bars-chew.md b/.changeset/funky-bars-chew.md deleted file mode 100644 index 92afc14bf7..0000000000 --- a/.changeset/funky-bars-chew.md +++ /dev/null @@ -1,9 +0,0 @@ ---- ---- - -Improve industry news alert format for engagement - -- Replace priority headers with article title in Slack alerts -- Add "Addie's take" with emoji and discussion prompt to drive engagement -- Decode HTML entities in RSS feed titles -- Reduce false positives in agentic mention detection by only checking original content diff --git a/.changeset/funny-jeans-attack.md b/.changeset/funny-jeans-attack.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/funny-jeans-attack.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/funny-sheep-remain.md b/.changeset/funny-sheep-remain.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/funny-sheep-remain.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fuzzy-showers-know.md b/.changeset/fuzzy-showers-know.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fuzzy-showers-know.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fuzzy-streets-call.md b/.changeset/fuzzy-streets-call.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/fuzzy-streets-call.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/gentle-coins-live.md b/.changeset/gentle-coins-live.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/gentle-coins-live.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/gentle-moose-greet.md b/.changeset/gentle-moose-greet.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/gentle-moose-greet.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/gentle-pianos-spend.md b/.changeset/gentle-pianos-spend.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/gentle-pianos-spend.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/gentle-rivers-add.md b/.changeset/gentle-rivers-add.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/gentle-rivers-add.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/giant-eggs-stare.md b/.changeset/giant-eggs-stare.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/giant-eggs-stare.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/giant-parts-heal.md b/.changeset/giant-parts-heal.md deleted file mode 100644 index 8dfffdc2cb..0000000000 --- a/.changeset/giant-parts-heal.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add Addie Desktop app with native OAuth flow and multi-thread chat UI for web. diff --git a/.changeset/giant-tires-argue.md b/.changeset/giant-tires-argue.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/giant-tires-argue.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/github-issue-offer.md b/.changeset/github-issue-offer.md deleted file mode 100644 index a780d5e737..0000000000 --- a/.changeset/github-issue-offer.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add GitHub issue offer for open-source agent test failures diff --git a/.changeset/gold-spies-wait.md b/.changeset/gold-spies-wait.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/gold-spies-wait.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/good-ads-rule.md b/.changeset/good-ads-rule.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/good-ads-rule.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/good-cougars-serve.md b/.changeset/good-cougars-serve.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/good-cougars-serve.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/good-kiwis-tie.md b/.changeset/good-kiwis-tie.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/good-kiwis-tie.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/good-lemons-accept.md b/.changeset/good-lemons-accept.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/good-lemons-accept.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/good-steaks-slide.md b/.changeset/good-steaks-slide.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/good-steaks-slide.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/goofy-deer-pick.md b/.changeset/goofy-deer-pick.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/goofy-deer-pick.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/great-cobras-wave.md b/.changeset/great-cobras-wave.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/great-cobras-wave.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/great-singers-greet.md b/.changeset/great-singers-greet.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/great-singers-greet.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/green-banks-juggle.md b/.changeset/green-banks-juggle.md deleted file mode 100644 index 26569a2f78..0000000000 --- a/.changeset/green-banks-juggle.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add static admin API key for programmatic access (ADMIN_API_KEY env var) diff --git a/.changeset/green-bottles-fly.md b/.changeset/green-bottles-fly.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/green-bottles-fly.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/green-cooks-double.md b/.changeset/green-cooks-double.md deleted file mode 100644 index bed9202737..0000000000 --- a/.changeset/green-cooks-double.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Consolidate check_agent_health and get_agent_capabilities into probe_adcp_agent tool diff --git a/.changeset/happy-llamas-hope.md b/.changeset/happy-llamas-hope.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/happy-llamas-hope.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/happy-nights-talk.md b/.changeset/happy-nights-talk.md deleted file mode 100644 index 4c3ad70f2f..0000000000 --- a/.changeset/happy-nights-talk.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add committee leadership management tools for Addie and display industry gatherings in the dashboard committees section. diff --git a/.changeset/heavy-dots-doubt.md b/.changeset/heavy-dots-doubt.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/heavy-dots-doubt.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/heavy-tips-visit.md b/.changeset/heavy-tips-visit.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/heavy-tips-visit.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/hip-bugs-jam.md b/.changeset/hip-bugs-jam.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/hip-bugs-jam.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/honest-spiders-tickle.md b/.changeset/honest-spiders-tickle.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/honest-spiders-tickle.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/hot-teams-leave.md b/.changeset/hot-teams-leave.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/hot-teams-leave.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/huge-taxes-sin.md b/.changeset/huge-taxes-sin.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/huge-taxes-sin.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/humble-clouds-smell.md b/.changeset/humble-clouds-smell.md deleted file mode 100644 index f4fc7b5d9b..0000000000 --- a/.changeset/humble-clouds-smell.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix chat page showing logged-out state for authenticated users. diff --git a/.changeset/hungry-bars-jump.md b/.changeset/hungry-bars-jump.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/hungry-bars-jump.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/hungry-feet-jump.md b/.changeset/hungry-feet-jump.md deleted file mode 100644 index fff40c1626..0000000000 --- a/.changeset/hungry-feet-jump.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Ignore Slackbot system messages in Addie handlers (no protocol changes) diff --git a/.changeset/icy-toes-pay.md b/.changeset/icy-toes-pay.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/icy-toes-pay.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/industry-gathering-slug-fix.md b/.changeset/industry-gathering-slug-fix.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/industry-gathering-slug-fix.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/jolly-chefs-knock.md b/.changeset/jolly-chefs-knock.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/jolly-chefs-knock.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/jolly-houses-shake.md b/.changeset/jolly-houses-shake.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/jolly-houses-shake.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/jolly-spoons-jump.md b/.changeset/jolly-spoons-jump.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/jolly-spoons-jump.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/kind-horses-fry.md b/.changeset/kind-horses-fry.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/kind-horses-fry.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/late-candles-invite.md b/.changeset/late-candles-invite.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/late-candles-invite.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/late-teeth-divide.md b/.changeset/late-teeth-divide.md deleted file mode 100644 index 836280767f..0000000000 --- a/.changeset/late-teeth-divide.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Improve domain health admin page UX: inline domain linking, www normalization, and verify button for existing domains. diff --git a/.changeset/lazy-doors-read.md b/.changeset/lazy-doors-read.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/lazy-doors-read.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/legal-dodos-rush.md b/.changeset/legal-dodos-rush.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/legal-dodos-rush.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/lemon-cats-brake.md b/.changeset/lemon-cats-brake.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/lemon-cats-brake.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/link-org-direct.md b/.changeset/link-org-direct.md deleted file mode 100644 index 6a38402a25..0000000000 --- a/.changeset/link-org-direct.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Link domain directly when clicking "Link to Org" on domain health page instead of showing modal diff --git a/.changeset/loose-feet-live.md b/.changeset/loose-feet-live.md deleted file mode 100644 index a9f04a1ea0..0000000000 --- a/.changeset/loose-feet-live.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add heading-level indexing for external repos (Addie knowledge base improvement - no protocol changes) diff --git a/.changeset/loud-ducks-cough.md b/.changeset/loud-ducks-cough.md deleted file mode 100644 index 0e7d225748..0000000000 --- a/.changeset/loud-ducks-cough.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix ambiguous ORDER BY column in getMembershipsByWorkingGroup query. diff --git a/.changeset/loud-hairs-repeat.md b/.changeset/loud-hairs-repeat.md deleted file mode 100644 index 76db2d2794..0000000000 --- a/.changeset/loud-hairs-repeat.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Filter RSS and email content from research section to show only member perspectives. diff --git a/.changeset/loud-pigs-heal.md b/.changeset/loud-pigs-heal.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/loud-pigs-heal.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/lovely-banks-argue.md b/.changeset/lovely-banks-argue.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/lovely-banks-argue.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/lovely-dragons-know.md b/.changeset/lovely-dragons-know.md deleted file mode 100644 index 74fa3d2804..0000000000 --- a/.changeset/lovely-dragons-know.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix profile modal Continue button not working on dashboard. Added proper change event listeners to radio buttons for more reliable selection handling across browsers and mobile devices. diff --git a/.changeset/many-cups-teach.md b/.changeset/many-cups-teach.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/many-cups-teach.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/many-doors-shine.md b/.changeset/many-doors-shine.md deleted file mode 100644 index 8973ad0967..0000000000 --- a/.changeset/many-doors-shine.md +++ /dev/null @@ -1,10 +0,0 @@ ---- ---- - -Add unified account management system with action items, user stakeholders, and momentum tracking. - -- New migration for user_stakeholders and action_items tables -- Database service for managing account assignments and action items -- Momentum check job for analyzing outreach history and creating action items -- Admin UI with action items panel and My Accounts tab -- Reorganized admin sidebar with Account Management section diff --git a/.changeset/member-search-intros.md b/.changeset/member-search-intros.md deleted file mode 100644 index 11dfde4a52..0000000000 --- a/.changeset/member-search-intros.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Member search analytics and introduction email improvements (no protocol changes) diff --git a/.changeset/metal-ravens-argue.md b/.changeset/metal-ravens-argue.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/metal-ravens-argue.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/mighty-needles-count.md b/.changeset/mighty-needles-count.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/mighty-needles-count.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/mighty-rings-add.md b/.changeset/mighty-rings-add.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/mighty-rings-add.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/modern-maps-happen.md b/.changeset/modern-maps-happen.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/modern-maps-happen.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/modern-planets-marry.md b/.changeset/modern-planets-marry.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/modern-planets-marry.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/neat-things-lay.md b/.changeset/neat-things-lay.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/neat-things-lay.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/new-coins-think.md b/.changeset/new-coins-think.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/new-coins-think.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/new-meals-battle.md b/.changeset/new-meals-battle.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/new-meals-battle.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/new-olives-play.md b/.changeset/new-olives-play.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/new-olives-play.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/new-suns-study.md b/.changeset/new-suns-study.md deleted file mode 100644 index 781b121132..0000000000 --- a/.changeset/new-suns-study.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Server-only audit logging changes - adds comprehensive audit logging for organization lifecycle events and admin viewer. diff --git a/.changeset/nice-camels-serve.md b/.changeset/nice-camels-serve.md deleted file mode 100644 index 4c5176df57..0000000000 --- a/.changeset/nice-camels-serve.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add collapsible sidebar to chat interface with localStorage persistence. Fix external link handling in desktop app iframe. diff --git a/.changeset/nice-meals-help.md b/.changeset/nice-meals-help.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/nice-meals-help.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/nine-lies-raise.md b/.changeset/nine-lies-raise.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/nine-lies-raise.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/nine-rings-beam.md b/.changeset/nine-rings-beam.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/nine-rings-beam.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/nine-views-clap.md b/.changeset/nine-views-clap.md deleted file mode 100644 index bd889bacfb..0000000000 --- a/.changeset/nine-views-clap.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add committee types system: Evolves working groups into multi-type committees (Working Groups, Industry Councils, Regional Chapters, Governance). Includes new navigation dropdown, landing pages for councils/chapters, admin panel updates, and seed data for initial committees. diff --git a/.changeset/ninety-oranges-rush.md b/.changeset/ninety-oranges-rush.md deleted file mode 100644 index 8bb529999c..0000000000 --- a/.changeset/ninety-oranges-rush.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Consolidate Contacts page into Users page - engagement scores, goals, and insights now shown in Users table and context modal. Remove redundant Contacts page and APIs. diff --git a/.changeset/old-geese-dress.md b/.changeset/old-geese-dress.md deleted file mode 100644 index 7bd09068db..0000000000 --- a/.changeset/old-geese-dress.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add industry gathering management for Addie admin and improve admin form UX. diff --git a/.changeset/old-shirts-hammer.md b/.changeset/old-shirts-hammer.md deleted file mode 100644 index 2e662e7335..0000000000 --- a/.changeset/old-shirts-hammer.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix outreach preview showing Slack-linking messages for already-linked users and remove guilt-inducing language from outreach variants. diff --git a/.changeset/olive-buses-doubt.md b/.changeset/olive-buses-doubt.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/olive-buses-doubt.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/olive-coats-bathe.md b/.changeset/olive-coats-bathe.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/olive-coats-bathe.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/open-rooms-tap.md b/.changeset/open-rooms-tap.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/open-rooms-tap.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/open-squids-flow.md b/.changeset/open-squids-flow.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/open-squids-flow.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/orange-bottles-reply.md b/.changeset/orange-bottles-reply.md deleted file mode 100644 index 741650006f..0000000000 --- a/.changeset/orange-bottles-reply.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Clarify AdCP/MCP relationship in docs: AdCP works *over* MCP and A2A as transports, not "built on" MCP. diff --git a/.changeset/orange-coins-wish.md b/.changeset/orange-coins-wish.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/orange-coins-wish.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/perky-deserts-matter.md b/.changeset/perky-deserts-matter.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/perky-deserts-matter.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/personal-workspace-enforcement.md b/.changeset/personal-workspace-enforcement.md deleted file mode 100644 index ec380ec43f..0000000000 --- a/.changeset/personal-workspace-enforcement.md +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- - diff --git a/.changeset/petite-eels-fly.md b/.changeset/petite-eels-fly.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/petite-eels-fly.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/pink-corners-allow.md b/.changeset/pink-corners-allow.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/pink-corners-allow.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/plain-needles-smell.md b/.changeset/plain-needles-smell.md deleted file mode 100644 index d55d877750..0000000000 --- a/.changeset/plain-needles-smell.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix ON CONFLICT clause in domain insert queries to match database schema constraint. diff --git a/.changeset/plain-terms-sip.md b/.changeset/plain-terms-sip.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/plain-terms-sip.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/plenty-trees-relate.md b/.changeset/plenty-trees-relate.md deleted file mode 100644 index a10b808acf..0000000000 --- a/.changeset/plenty-trees-relate.md +++ /dev/null @@ -1,28 +0,0 @@ ---- ---- - -Server/dashboard changes: Add Luma integration for AAO events management. - -**Luma API Client** (`server/src/luma/client.ts`): -- Full Luma API client with typed interfaces -- Event operations: create, get, update, delete -- Guest/registration operations: list guests, approve, decline, check-in -- Calendar operations: list calendars, list calendar events -- Webhook payload parsing and validation - -**Luma Webhook Handler** (`POST /api/webhooks/luma`): -- Handles `guest.created` - Syncs new registrations from Luma to AAO database -- Handles `guest.updated` - Updates registration status (approved/declined/checked-in) -- Handles `event.updated` - Syncs event changes from Luma back to AAO - -**Addie Event Tools** for natural language event management: -- `create_event` - Create events in both Luma and AAO database -- `list_upcoming_events` - List upcoming events with filtering -- `get_event_details` - Get event details with registration counts -- `manage_event_registrations` - List, approve waitlist, export registrations -- `update_event` - Update event details - -**Admin Navigation**: -- Added "Events" link to admin sidebar in the Community section - -Requires `LUMA_API_KEY` environment variable for Luma integration. diff --git a/.changeset/polite-candies-rescue.md b/.changeset/polite-candies-rescue.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/polite-candies-rescue.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/polite-candles-walk.md b/.changeset/polite-candles-walk.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/polite-candles-walk.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/polite-cycles-hang.md b/.changeset/polite-cycles-hang.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/polite-cycles-hang.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/polite-socks-swim.md b/.changeset/polite-socks-swim.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/polite-socks-swim.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/polite-tables-fail.md b/.changeset/polite-tables-fail.md deleted file mode 100644 index 8469f93f36..0000000000 --- a/.changeset/polite-tables-fail.md +++ /dev/null @@ -1,11 +0,0 @@ ---- ---- - -Add comprehensive prospect ownership and management system: - -- Database migration to convert legacy prospect_owner text to org_stakeholders records -- 5 new Addie admin tools: my_engaged_prospects, my_followups_needed, unassigned_prospects, claim_prospect, suggest_prospects -- "My Prospects" section at top of admin dashboard with hot/followup lists -- Prospect stats in Slack App Home admin panel -- mine=true filter on prospects API -- Fix org owner display to fall back to legacy prospect_owner field when no stakeholder owner exists diff --git a/.changeset/polite-webs-cheat.md b/.changeset/polite-webs-cheat.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/polite-webs-cheat.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/posthog-analytics.md b/.changeset/posthog-analytics.md deleted file mode 100644 index 4f21593d06..0000000000 --- a/.changeset/posthog-analytics.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add PostHog analytics integration for tracking rage clicks, session recordings, and heatmaps. diff --git a/.changeset/pretty-cameras-jog.md b/.changeset/pretty-cameras-jog.md deleted file mode 100644 index 3c864ac716..0000000000 --- a/.changeset/pretty-cameras-jog.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Admin UI improvements: dynamic owner dropdowns with "(me)" indicator, quick follow-up form. diff --git a/.changeset/pretty-crabs-create.md b/.changeset/pretty-crabs-create.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/pretty-crabs-create.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/pretty-horses-add.md b/.changeset/pretty-horses-add.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/pretty-horses-add.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/proud-jars-attack.md b/.changeset/proud-jars-attack.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/proud-jars-attack.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/proud-pugs-punch.md b/.changeset/proud-pugs-punch.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/proud-pugs-punch.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/public-needles-argue.md b/.changeset/public-needles-argue.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/public-needles-argue.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/puny-terms-start.md b/.changeset/puny-terms-start.md deleted file mode 100644 index bafe4fd5eb..0000000000 --- a/.changeset/puny-terms-start.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add AXE (Agentic eXecution Engine) documentation with real-time targeting, brand safety, and frequency management details. diff --git a/.changeset/purple-rice-unite.md b/.changeset/purple-rice-unite.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/purple-rice-unite.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/quick-pandas-invent.md b/.changeset/quick-pandas-invent.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/quick-pandas-invent.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/ready-breads-matter.md b/.changeset/ready-breads-matter.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/ready-breads-matter.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/ready-kids-teach.md b/.changeset/ready-kids-teach.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/ready-kids-teach.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/real-cases-join.md b/.changeset/real-cases-join.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/real-cases-join.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/real-places-float.md b/.changeset/real-places-float.md deleted file mode 100644 index 23458d9db1..0000000000 --- a/.changeset/real-places-float.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -CI fix: Add publish step to release workflow to enable GitHub release creation. diff --git a/.changeset/red-mails-do.md b/.changeset/red-mails-do.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/red-mails-do.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/release-relaxed-schemas.md b/.changeset/release-relaxed-schemas.md deleted file mode 100644 index fb0609cfce..0000000000 --- a/.changeset/release-relaxed-schemas.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"adcontextprotocol": patch ---- - -Release schemas with `additionalProperties: true` for forward compatibility - -This releases `dist/schemas/2.5.2/` containing the relaxed schema validation -introduced in #646. Clients can now safely ignore unknown fields when parsing -API responses, allowing the API to evolve without breaking existing integrations. diff --git a/.changeset/ripe-ghosts-bet.md b/.changeset/ripe-ghosts-bet.md deleted file mode 100644 index 50f3463080..0000000000 --- a/.changeset/ripe-ghosts-bet.md +++ /dev/null @@ -1,9 +0,0 @@ ---- ---- - -Add escalation and learning capture tools for Addie - -- New `escalate_to_admin` tool for capability gaps and requests needing human action -- New `capture_learning` tool to flag valuable user insights for synthesis -- Admin API endpoints for managing escalations -- System prompt updates to prevent Addie from promising actions without tools diff --git a/.changeset/ripe-zebras-sell.md b/.changeset/ripe-zebras-sell.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/ripe-zebras-sell.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/salty-cooks-grin.md b/.changeset/salty-cooks-grin.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/salty-cooks-grin.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/seven-deer-marry.md b/.changeset/seven-deer-marry.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/seven-deer-marry.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/seven-heads-smile.md b/.changeset/seven-heads-smile.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/seven-heads-smile.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/seven-rules-invent.md b/.changeset/seven-rules-invent.md deleted file mode 100644 index 37a3d4f1bc..0000000000 --- a/.changeset/seven-rules-invent.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Allow working group leaders to schedule meetings for groups they lead via Addie. Added meeting and event tools documentation to Addie's system prompt. diff --git a/.changeset/seven-squids-carry.md b/.changeset/seven-squids-carry.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/seven-squids-carry.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/shaggy-rocks-train.md b/.changeset/shaggy-rocks-train.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/shaggy-rocks-train.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/shaky-paths-attend.md b/.changeset/shaky-paths-attend.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/shaky-paths-attend.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/shiny-points-spend.md b/.changeset/shiny-points-spend.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/shiny-points-spend.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/short-clouds-feel.md b/.changeset/short-clouds-feel.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/short-clouds-feel.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/shy-bats-run.md b/.changeset/shy-bats-run.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/shy-bats-run.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/silent-cars-watch.md b/.changeset/silent-cars-watch.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/silent-cars-watch.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/silent-crews-lose.md b/.changeset/silent-crews-lose.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/silent-crews-lose.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/silly-readers-hunt.md b/.changeset/silly-readers-hunt.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/silly-readers-hunt.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/silver-goats-exist.md b/.changeset/silver-goats-exist.md deleted file mode 100644 index e454a20f63..0000000000 --- a/.changeset/silver-goats-exist.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Internal server improvements: static fs import and proper 404 handling for HTML serving. diff --git a/.changeset/silver-suns-sell.md b/.changeset/silver-suns-sell.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/silver-suns-sell.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/sixty-colts-bathe.md b/.changeset/sixty-colts-bathe.md deleted file mode 100644 index a9682143b2..0000000000 --- a/.changeset/sixty-colts-bathe.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add company type and revenue tier collection during onboarding flow. diff --git a/.changeset/sixty-ends-ask.md b/.changeset/sixty-ends-ask.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/sixty-ends-ask.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/sixty-paws-punch.md b/.changeset/sixty-paws-punch.md deleted file mode 100644 index 9105fb5231..0000000000 --- a/.changeset/sixty-paws-punch.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add OpenTelemetry logging integration for PostHog and fix client-side JavaScript errors. diff --git a/.changeset/skip-welcome-engaged-users.md b/.changeset/skip-welcome-engaged-users.md deleted file mode 100644 index 01554d803f..0000000000 --- a/.changeset/skip-welcome-engaged-users.md +++ /dev/null @@ -1,6 +0,0 @@ ---- ---- - -No protocol impact - internal behavior change only. - -Skip intro/welcome goals for highly engaged users (committee leaders, working group members, council members, active Slack users). diff --git a/.changeset/slack-engagement-scores.md b/.changeset/slack-engagement-scores.md deleted file mode 100644 index 2a2fb92cb5..0000000000 --- a/.changeset/slack-engagement-scores.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Website only: Compute engagement scores for Slack-only contacts in admin dashboard diff --git a/.changeset/slick-camels-hide.md b/.changeset/slick-camels-hide.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/slick-camels-hide.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/slick-singers-prove.md b/.changeset/slick-singers-prove.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/slick-singers-prove.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/slick-tigers-invite.md b/.changeset/slick-tigers-invite.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/slick-tigers-invite.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/slimy-meteors-grab.md b/.changeset/slimy-meteors-grab.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/slimy-meteors-grab.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/slimy-singers-care.md b/.changeset/slimy-singers-care.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/slimy-singers-care.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/slow-snails-stand.md b/.changeset/slow-snails-stand.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/slow-snails-stand.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/slow-trains-agree.md b/.changeset/slow-trains-agree.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/slow-trains-agree.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/small-geese-leave.md b/.changeset/small-geese-leave.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/small-geese-leave.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/smart-olives-sort.md b/.changeset/smart-olives-sort.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/smart-olives-sort.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/smooth-rice-chew.md b/.changeset/smooth-rice-chew.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/smooth-rice-chew.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/soft-apples-play.md b/.changeset/soft-apples-play.md deleted file mode 100644 index 6bf8a359b8..0000000000 --- a/.changeset/soft-apples-play.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix profile completion goal to only show for paid members, handle personal workspaces diff --git a/.changeset/soft-rooms-spend.md b/.changeset/soft-rooms-spend.md deleted file mode 100644 index 968502484e..0000000000 --- a/.changeset/soft-rooms-spend.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Internal improvements to industry news page: HTML entity parsing, article sorting by publication date, and URL deduplication. diff --git a/.changeset/solid-hotels-occur.md b/.changeset/solid-hotels-occur.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/solid-hotels-occur.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/solid-ideas-shake.md b/.changeset/solid-ideas-shake.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/solid-ideas-shake.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/some-phones-study.md b/.changeset/some-phones-study.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/some-phones-study.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/some-teeth-judge.md b/.changeset/some-teeth-judge.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/some-teeth-judge.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/sparkly-camels-dream.md b/.changeset/sparkly-camels-dream.md deleted file mode 100644 index 648b907b34..0000000000 --- a/.changeset/sparkly-camels-dream.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Homepage layout refinement: tightened hero sections, moved Go Agentic section to prominent position after release banner, updated prompts to be more actionable, and removed mission statement band from org-index.html. diff --git a/.changeset/sparkly-onions-hug.md b/.changeset/sparkly-onions-hug.md deleted file mode 100644 index d9927220e2..0000000000 --- a/.changeset/sparkly-onions-hug.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add Addie email response capability - allows Addie to reply to email threads when CC'd and explicitly invoked diff --git a/.changeset/spicy-animals-fail.md b/.changeset/spicy-animals-fail.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/spicy-animals-fail.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/spicy-doors-flow.md b/.changeset/spicy-doors-flow.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/spicy-doors-flow.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/spicy-plants-rescue.md b/.changeset/spicy-plants-rescue.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/spicy-plants-rescue.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/spicy-tigers-double.md b/.changeset/spicy-tigers-double.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/spicy-tigers-double.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/stale-cobras-smile.md b/.changeset/stale-cobras-smile.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/stale-cobras-smile.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/stale-hats-dance.md b/.changeset/stale-hats-dance.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/stale-hats-dance.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/stale-tips-drive.md b/.changeset/stale-tips-drive.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/stale-tips-drive.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/strict-foxes-wash.md b/.changeset/strict-foxes-wash.md deleted file mode 100644 index 64c4740cc9..0000000000 --- a/.changeset/strict-foxes-wash.md +++ /dev/null @@ -1,8 +0,0 @@ ---- ---- - -fix: simplify engagement scoring and fix working group leader detection - -- Simplified engagement scoring: 10 points per Slack action + 20 points per event (capped at 100) -- Fixed working group count to include leaders as implicit members -- Committee leaders no longer receive "Discover Working Groups" recommendations diff --git a/.changeset/strict-lamps-turn.md b/.changeset/strict-lamps-turn.md deleted file mode 100644 index aab5a8b79f..0000000000 --- a/.changeset/strict-lamps-turn.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix Slack user linking in bulk prospect creation from domain discovery. diff --git a/.changeset/sunny-apples-jam.md b/.changeset/sunny-apples-jam.md deleted file mode 100644 index 3146a532c7..0000000000 --- a/.changeset/sunny-apples-jam.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Improve Addie's GitHub issue detection for client library bugs vs agent bugs diff --git a/.changeset/sunny-bears-stare.md b/.changeset/sunny-bears-stare.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/sunny-bears-stare.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/sweet-brooms-give.md b/.changeset/sweet-brooms-give.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/sweet-brooms-give.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/sweet-planes-follow.md b/.changeset/sweet-planes-follow.md deleted file mode 100644 index 6aa9556f09..0000000000 --- a/.changeset/sweet-planes-follow.md +++ /dev/null @@ -1,10 +0,0 @@ ---- ---- - -Improve Addie's Slack message understanding - -- Fix Addie not seeing forwarded Slack messages (content is in `attachments`, not `text`) -- Add reaction-based confirmations: thumbs up on "should I proceed?" means yes -- Add file share awareness: Addie now sees file metadata when users share files -- Add `fetch_url` tool: Addie can read content from URLs shared in messages -- Add `read_slack_file` tool: Addie can read text files shared in Slack diff --git a/.changeset/sweet-queens-send.md b/.changeset/sweet-queens-send.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/sweet-queens-send.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/sweet-taxis-act.md b/.changeset/sweet-taxis-act.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/sweet-taxis-act.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/swift-points-sniff.md b/.changeset/swift-points-sniff.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/swift-points-sniff.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tame-hornets-peel.md b/.changeset/tame-hornets-peel.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/tame-hornets-peel.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tame-hounds-swim.md b/.changeset/tame-hounds-swim.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/tame-hounds-swim.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tame-llamas-shake.md b/.changeset/tame-llamas-shake.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/tame-llamas-shake.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tasty-sails-tie.md b/.changeset/tasty-sails-tie.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/tasty-sails-tie.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/ten-bikes-shout.md b/.changeset/ten-bikes-shout.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/ten-bikes-shout.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/ten-crews-joke.md b/.changeset/ten-crews-joke.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/ten-crews-joke.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/ten-experts-fall.md b/.changeset/ten-experts-fall.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/ten-experts-fall.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/ten-groups-heal.md b/.changeset/ten-groups-heal.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/ten-groups-heal.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/ten-towns-admire.md b/.changeset/ten-towns-admire.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/ten-towns-admire.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tender-experts-repeat.md b/.changeset/tender-experts-repeat.md deleted file mode 100644 index 4ceb3aa470..0000000000 --- a/.changeset/tender-experts-repeat.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Remove insight_goal_id references following migration 166 column drop. diff --git a/.changeset/tender-jeans-double.md b/.changeset/tender-jeans-double.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/tender-jeans-double.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tender-needles-beg.md b/.changeset/tender-needles-beg.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/tender-needles-beg.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tender-pants-glow.md b/.changeset/tender-pants-glow.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/tender-pants-glow.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/thick-cows-add.md b/.changeset/thick-cows-add.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/thick-cows-add.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/thin-lions-trade.md b/.changeset/thin-lions-trade.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/thin-lions-trade.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/thirty-dots-feel.md b/.changeset/thirty-dots-feel.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/thirty-dots-feel.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/thirty-papayas-rescue.md b/.changeset/thirty-papayas-rescue.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/thirty-papayas-rescue.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/thirty-regions-dance.md b/.changeset/thirty-regions-dance.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/thirty-regions-dance.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/thirty-snails-kneel.md b/.changeset/thirty-snails-kneel.md deleted file mode 100644 index efbd790a09..0000000000 --- a/.changeset/thirty-snails-kneel.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add token-aware context limiting for Addie to prevent prompt overflow errors in long conversations. diff --git a/.changeset/thirty-snakes-fix.md b/.changeset/thirty-snakes-fix.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/thirty-snakes-fix.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tiny-games-decide.md b/.changeset/tiny-games-decide.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/tiny-games-decide.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tiny-news-smash.md b/.changeset/tiny-news-smash.md deleted file mode 100644 index d453bc8770..0000000000 --- a/.changeset/tiny-news-smash.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add mobile app-like experience with slide-in navigation, safe area handling, touch-optimized targets, and PWA support. diff --git a/.changeset/tired-rockets-lie.md b/.changeset/tired-rockets-lie.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/tired-rockets-lie.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tricky-baths-obey.md b/.changeset/tricky-baths-obey.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/tricky-baths-obey.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tricky-worms-push.md b/.changeset/tricky-worms-push.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/tricky-worms-push.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/twelve-baboons-smell.md b/.changeset/twelve-baboons-smell.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/twelve-baboons-smell.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/twelve-books-cross.md b/.changeset/twelve-books-cross.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/twelve-books-cross.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/twenty-ads-lay.md b/.changeset/twenty-ads-lay.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/twenty-ads-lay.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/twenty-bobcats-fall.md b/.changeset/twenty-bobcats-fall.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/twenty-bobcats-fall.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/wacky-seals-eat.md b/.changeset/wacky-seals-eat.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/wacky-seals-eat.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/wacky-things-refuse.md b/.changeset/wacky-things-refuse.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/wacky-things-refuse.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/wacky-worms-speak.md b/.changeset/wacky-worms-speak.md deleted file mode 100644 index 42ecbe41cd..0000000000 --- a/.changeset/wacky-worms-speak.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Fix admin tools not being available in Slack DM threads, add timeframe filtering to conversation stats, and fix feedback UI re-rendering after save diff --git a/.changeset/warm-jars-attack.md b/.changeset/warm-jars-attack.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/warm-jars-attack.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/warm-lamps-accept.md b/.changeset/warm-lamps-accept.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/warm-lamps-accept.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/wet-keys-wash.md b/.changeset/wet-keys-wash.md deleted file mode 100644 index 2405357f90..0000000000 --- a/.changeset/wet-keys-wash.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Add brand_manifest parameter to test_adcp_agent tool, allowing users to specify custom brands when testing agents. Defaults to Nike if not provided. diff --git a/.changeset/wicked-islands-tickle.md b/.changeset/wicked-islands-tickle.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/wicked-islands-tickle.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/wicked-memes-act.md b/.changeset/wicked-memes-act.md deleted file mode 100644 index 7596795353..0000000000 --- a/.changeset/wicked-memes-act.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Admin collaboration features for outreach: add insight button, year reference cleanup, vendor membership goal, admin nudge/override diff --git a/.changeset/wide-kiwis-say.md b/.changeset/wide-kiwis-say.md deleted file mode 100644 index 34a6a9b184..0000000000 --- a/.changeset/wide-kiwis-say.md +++ /dev/null @@ -1,11 +0,0 @@ ---- ---- - -Consolidate rating system to thumbs up/down and add rating_source to distinguish user vs admin feedback. - -Add eval framework for testing rule changes against historical interactions: -- New tables: addie_eval_runs, addie_eval_results -- Re-execute historical messages with proposed rules using real Claude calls -- Compare original vs new responses (routing, tools, response text) -- Human review with verdicts (improved/same/worse/uncertain) -- API endpoints: POST/GET /api/admin/addie/eval/runs, GET /results, PUT /review diff --git a/.changeset/wide-shirts-attack.md b/.changeset/wide-shirts-attack.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/wide-shirts-attack.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/wise-bags-invite.md b/.changeset/wise-bags-invite.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/wise-bags-invite.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/wise-places-invent.md b/.changeset/wise-places-invent.md deleted file mode 100644 index 1a9d8eea67..0000000000 --- a/.changeset/wise-places-invent.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -Documentation improvements: Added testable JSON examples with $schema declarations to formats.mdx, consolidated list_creative_formats docs, and split preview_creative.mdx into core reference and advanced guide for better readability. diff --git a/.changeset/wise-tigers-lose.md b/.changeset/wise-tigers-lose.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/wise-tigers-lose.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/witty-teeth-live.md b/.changeset/witty-teeth-live.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/witty-teeth-live.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/yellow-ghosts-appear.md b/.changeset/yellow-ghosts-appear.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/yellow-ghosts-appear.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/yellow-knives-warn.md b/.changeset/yellow-knives-warn.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/yellow-knives-warn.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/yellow-llamas-lie.md b/.changeset/yellow-llamas-lie.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/yellow-llamas-lie.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/young-baths-kiss.md b/.changeset/young-baths-kiss.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/young-baths-kiss.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/young-masks-care.md b/.changeset/young-masks-care.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/young-masks-care.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/yummy-areas-bet.md b/.changeset/yummy-areas-bet.md deleted file mode 100644 index 99bd059314..0000000000 --- a/.changeset/yummy-areas-bet.md +++ /dev/null @@ -1,9 +0,0 @@ ---- ---- - -Add regional chapters and industry event presence features: -- User location tracking (city, country) for chapter matching -- Event groups (committee_type: 'event') linked to industry events -- Slack channel auto-sync: join channel = join group -- Admin UI for event groups and chapters -- Addie tools for member-driven chapter creation diff --git a/CHANGELOG.md b/CHANGELOG.md index 7093223e02..64c5dc3b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## 2.5.3 + +### Patch Changes + +- 309a880: Allow additional properties in all JSON schemas for forward compatibility + + Changes all schemas from `"additionalProperties": false` to `"additionalProperties": true`. This enables clients running older schema versions to accept responses from servers with newer schemas without breaking validation - a standard practice for protocol evolution in distributed systems. + +- 5d0ce75: Add explicit type definition to error.json details property + + The `details` property in core/error.json now explicitly declares `"type": "object"` and `"additionalProperties": true`, consistent with other error details definitions in the codebase. This addresses issue #343 where the data type was unspecified. + +- cdcd70f: Fix migration 151 to delete duplicates before updating Slack IDs to WorkOS IDs +- 39abf79: Add missing fields to package request schemas for consistency with core/package.json. + + **Schema Changes:** + + - `media-buy/package-request.json`: Added `impressions` and `paused` fields + - `media-buy/update-media-buy-request.json`: Added `impressions` field to package updates + + **Details:** + + - `impressions`: Impression goal for the package (optional, minimum: 0) + - `paused`: Create package in paused state (optional, default: false) + + These fields were defined in `core/package.json` but missing from the request schemas, making it impossible to set impression goals or initial paused state when creating/updating media buys. + + **Documentation:** + + - Updated `create_media_buy` task reference with new package parameters + - Updated `update_media_buy` task reference with impressions parameter + +- fa68588: fix: display Slack profile name for chapter leaders without WorkOS accounts + + Leaders added via Slack ID that haven't linked their WorkOS account now display + their Slack profile name (real_name or display_name) instead of the raw Slack + user ID (e.g., U09BEKNJ3GB). + + The getLeaders and getLeadersBatch queries now include slack_user_mappings as an + additional name source in the COALESCE chain. + +- 9315247: Release schemas with `additionalProperties: true` for forward compatibility + + This releases `dist/schemas/2.5.2/` containing the relaxed schema validation + introduced in #646. Clients can now safely ignore unknown fields when parsing + API responses, allowing the API to evolve without breaking existing integrations. + ## 2.5.2 ### Patch Changes diff --git a/dist/schemas/2.5.3/adagents.json b/dist/schemas/2.5.3/adagents.json new file mode 100644 index 0000000000..f36134db23 --- /dev/null +++ b/dist/schemas/2.5.3/adagents.json @@ -0,0 +1,494 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/2.5.3/adagents.json", + "title": "Authorized Sales Agents", + "description": "Declaration of authorized sales agents for advertising inventory. Hosted at /.well-known/adagents.json on publisher domains. Can either contain the full structure inline or reference an authoritative URL.", + "oneOf": [ + { + "type": "object", + "description": "URL reference variant - points to the authoritative location of the adagents.json file", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema identifier for this adagents.json file" + }, + "authoritative_location": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "description": "HTTPS URL of the authoritative adagents.json file. When present, this file is a reference and the authoritative location contains the actual agent authorization data." + }, + "last_updated": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp indicating when this reference was last updated" + } + }, + "required": [ + "authoritative_location" + ], + "additionalProperties": true + }, + { + "type": "object", + "description": "Inline structure variant - contains full agent authorization data", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema identifier for this adagents.json file" + }, + "contact": { + "type": "object", + "description": "Contact information for the entity managing this adagents.json file (may be publisher or third-party operator)", + "properties": { + "name": { + "type": "string", + "description": "Name of the entity managing this file (e.g., 'Meta Advertising Operations', 'Clear Channel Digital')", + "minLength": 1, + "maxLength": 255 + }, + "email": { + "type": "string", + "format": "email", + "description": "Contact email for questions or issues with this authorization file", + "minLength": 1, + "maxLength": 255 + }, + "domain": { + "type": "string", + "description": "Primary domain of the entity managing this file", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, + "seller_id": { + "type": "string", + "description": "Seller ID from IAB Tech Lab sellers.json (if applicable)", + "minLength": 1, + "maxLength": 255 + }, + "tag_id": { + "type": "string", + "description": "TAG Certified Against Fraud ID for verification (if applicable)", + "minLength": 1, + "maxLength": 100 + } + }, + "required": [ + "name" + ], + "additionalProperties": true + }, + "properties": { + "type": "array", + "description": "Array of all properties covered by this adagents.json file. Defines the canonical property list that authorized agents reference.", + "items": { + "$ref": "/schemas/2.5.3/core/property.json" + }, + "minItems": 1 + }, + "tags": { + "type": "object", + "description": "Metadata for each tag referenced by properties. Provides human-readable context for property tag values.", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable name for this tag" + }, + "description": { + "type": "string", + "description": "Description of what this tag represents" + } + }, + "required": [ + "name", + "description" + ], + "additionalProperties": true + } + }, + "authorized_agents": { + "type": "array", + "description": "Array of sales agents authorized to sell inventory for properties in this file", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The authorized agent's API endpoint URL" + }, + "authorized_for": { + "type": "string", + "description": "Human-readable description of what this agent is authorized to sell", + "minLength": 1, + "maxLength": 500 + }, + "authorization_type": { + "type": "string", + "const": "property_ids", + "description": "Discriminator indicating authorization by specific property IDs" + }, + "property_ids": { + "type": "array", + "description": "Property IDs this agent is authorized for. Resolved against the top-level properties array in this file", + "items": { + "$ref": "/schemas/2.5.3/core/property-id.json" + }, + "minItems": 1 + } + }, + "required": [ + "url", + "authorized_for", + "authorization_type", + "property_ids" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The authorized agent's API endpoint URL" + }, + "authorized_for": { + "type": "string", + "description": "Human-readable description of what this agent is authorized to sell", + "minLength": 1, + "maxLength": 500 + }, + "authorization_type": { + "type": "string", + "const": "property_tags", + "description": "Discriminator indicating authorization by property tags" + }, + "property_tags": { + "type": "array", + "description": "Tags identifying which properties this agent is authorized for. Resolved against the top-level properties array in this file using tag matching", + "items": { + "$ref": "/schemas/2.5.3/core/property-tag.json" + }, + "minItems": 1 + } + }, + "required": [ + "url", + "authorized_for", + "authorization_type", + "property_tags" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The authorized agent's API endpoint URL" + }, + "authorized_for": { + "type": "string", + "description": "Human-readable description of what this agent is authorized to sell", + "minLength": 1, + "maxLength": 500 + }, + "authorization_type": { + "type": "string", + "const": "inline_properties", + "description": "Discriminator indicating authorization by inline property definitions" + }, + "properties": { + "type": "array", + "description": "Specific properties this agent is authorized for (alternative to property_ids/property_tags)", + "items": { + "$ref": "/schemas/2.5.3/core/property.json" + }, + "minItems": 1 + } + }, + "required": [ + "url", + "authorized_for", + "authorization_type", + "properties" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The authorized agent's API endpoint URL" + }, + "authorized_for": { + "type": "string", + "description": "Human-readable description of what this agent is authorized to sell", + "minLength": 1, + "maxLength": 500 + }, + "authorization_type": { + "type": "string", + "const": "publisher_properties", + "description": "Discriminator indicating authorization for properties from other publisher domains" + }, + "publisher_properties": { + "type": "array", + "description": "Properties from other publisher domains this agent is authorized for. Each entry specifies a publisher domain and which of their properties this agent can sell", + "items": { + "$ref": "/schemas/2.5.3/core/publisher-property-selector.json" + }, + "minItems": 1 + } + }, + "required": [ + "url", + "authorized_for", + "authorization_type", + "publisher_properties" + ], + "additionalProperties": true + } + ] + }, + "minItems": 1 + }, + "last_updated": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp indicating when this file was last updated" + } + }, + "required": [ + "authorized_agents" + ], + "additionalProperties": true + } + ], + "examples": [ + { + "$schema": "/schemas/2.5.3/adagents.json", + "authoritative_location": "https://cdn.example.com/adagents/v2/adagents.json", + "last_updated": "2025-01-15T10:00:00Z" + }, + { + "$schema": "/schemas/2.5.3/adagents.json", + "properties": [ + { + "property_type": "website", + "name": "Example Site", + "identifiers": [ + { + "type": "domain", + "value": "example.com" + } + ], + "publisher_domain": "example.com" + } + ], + "authorized_agents": [ + { + "url": "https://agent.example.com", + "authorized_for": "Official sales agent", + "authorization_type": "property_tags", + "property_tags": [ + "all" + ] + } + ], + "tags": { + "all": { + "name": "All Properties", + "description": "All properties in this file" + } + }, + "last_updated": "2025-01-10T12:00:00Z" + }, + { + "$schema": "/schemas/2.5.3/adagents.json", + "contact": { + "name": "Meta Advertising Operations", + "email": "adops@meta.com", + "domain": "meta.com", + "seller_id": "pub-meta-12345", + "tag_id": "12345" + }, + "properties": [ + { + "property_type": "mobile_app", + "name": "Instagram", + "identifiers": [ + { + "type": "ios_bundle", + "value": "com.burbn.instagram" + }, + { + "type": "android_package", + "value": "com.instagram.android" + } + ], + "tags": [ + "meta_network", + "social_media" + ], + "publisher_domain": "instagram.com" + }, + { + "property_type": "mobile_app", + "name": "Facebook", + "identifiers": [ + { + "type": "ios_bundle", + "value": "com.facebook.Facebook" + }, + { + "type": "android_package", + "value": "com.facebook.katana" + } + ], + "tags": [ + "meta_network", + "social_media" + ], + "publisher_domain": "facebook.com" + }, + { + "property_type": "mobile_app", + "name": "WhatsApp", + "identifiers": [ + { + "type": "ios_bundle", + "value": "net.whatsapp.WhatsApp" + }, + { + "type": "android_package", + "value": "com.whatsapp" + } + ], + "tags": [ + "meta_network", + "messaging" + ], + "publisher_domain": "whatsapp.com" + } + ], + "tags": { + "meta_network": { + "name": "Meta Network", + "description": "All Meta-owned properties" + }, + "social_media": { + "name": "Social Media Apps", + "description": "Social networking applications" + }, + "messaging": { + "name": "Messaging Apps", + "description": "Messaging and communication apps" + } + }, + "authorized_agents": [ + { + "url": "https://meta-ads.com", + "authorized_for": "All Meta properties", + "authorization_type": "property_tags", + "property_tags": [ + "meta_network" + ] + } + ], + "last_updated": "2025-01-10T15:30:00Z" + }, + { + "$schema": "/schemas/2.5.3/adagents.json", + "contact": { + "name": "Tumblr Advertising" + }, + "properties": [ + { + "property_type": "website", + "name": "Tumblr Corporate", + "identifiers": [ + { + "type": "domain", + "value": "tumblr.com" + } + ], + "tags": [ + "corporate" + ], + "publisher_domain": "tumblr.com" + } + ], + "tags": { + "corporate": { + "name": "Corporate Properties", + "description": "Tumblr-owned corporate properties (not user blogs)" + } + }, + "authorized_agents": [ + { + "url": "https://tumblr-sales.com", + "authorized_for": "Tumblr corporate properties only", + "authorization_type": "property_tags", + "property_tags": [ + "corporate" + ] + } + ], + "last_updated": "2025-01-10T16:00:00Z" + }, + { + "$schema": "/schemas/2.5.3/adagents.json", + "contact": { + "name": "Example Third-Party Sales Agent", + "email": "sales@agent.example", + "domain": "agent.example" + }, + "authorized_agents": [ + { + "url": "https://agent.example/api", + "authorized_for": "CNN CTV properties via publisher authorization", + "authorization_type": "publisher_properties", + "publisher_properties": [ + { + "publisher_domain": "cnn.com", + "selection_type": "by_id", + "property_ids": [ + "cnn_ctv_app" + ] + } + ] + }, + { + "url": "https://agent.example/api", + "authorized_for": "All CTV properties from multiple publishers", + "authorization_type": "publisher_properties", + "publisher_properties": [ + { + "publisher_domain": "cnn.com", + "selection_type": "by_tag", + "property_tags": [ + "ctv" + ] + }, + { + "publisher_domain": "espn.com", + "selection_type": "by_tag", + "property_tags": [ + "ctv" + ] + } + ] + } + ], + "last_updated": "2025-01-10T17:00:00Z" + } + ] +} diff --git a/dist/schemas/2.5.3/bundled/media-buy/build-creative-request.json b/dist/schemas/2.5.3/bundled/media-buy/build-creative-request.json new file mode 100644 index 0000000000..f915a27362 --- /dev/null +++ b/dist/schemas/2.5.3/bundled/media-buy/build-creative-request.json @@ -0,0 +1,1349 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/2.5.3/bundled/media-buy/build-creative-request.json", + "title": "Build Creative Request", + "description": "Request to transform or generate a creative manifest. Takes a source manifest (which may be minimal for pure generation) and produces a target manifest in the specified format. The source manifest should include all assets required by the target format (e.g., promoted_offerings for generative formats).", + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Natural language instructions for the transformation or generation. For pure generation, this is the creative brief. For transformation, this provides guidance on how to adapt the creative." + }, + "creative_manifest": { + "title": "Creative Manifest", + "description": "Creative manifest to transform or generate from. For pure generation, this should include the target format_id and any required input assets (e.g., promoted_offerings for generative formats). For transformation (e.g., resizing, reformatting), this is the complete creative to adapt.", + "type": "object", + "properties": { + "format_id": { + "title": "Format ID", + "description": "Format identifier this manifest is for. Can be a template format (id only) or a deterministic format (id + dimensions/duration). For dimension-specific creatives, include width/height/unit in the format_id to create a unique identifier (e.g., {id: 'display_static', width: 300, height: 250, unit: 'px'}).", + "type": "object", + "properties": { + "agent_url": { + "type": "string", + "format": "uri", + "description": "URL of the agent that defines this format (e.g., 'https://creatives.adcontextprotocol.org' for standard formats, or 'https://publisher.com/.well-known/adcp/sales' for custom formats)" + }, + "id": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "description": "Format identifier within the agent's namespace (e.g., 'display_static', 'video_hosted', 'audio_standard'). When used alone, references a template format. When combined with dimension/duration fields, creates a parameterized format ID for a specific variant." + }, + "width": { + "type": "integer", + "minimum": 1, + "description": "Width in pixels for visual formats. When specified, height must also be specified. Both fields together create a parameterized format ID for dimension-specific variants." + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Height in pixels for visual formats. When specified, width must also be specified. Both fields together create a parameterized format ID for dimension-specific variants." + }, + "duration_ms": { + "type": "number", + "minimum": 1, + "description": "Duration in milliseconds for time-based formats (video, audio). When specified, creates a parameterized format ID. Omit to reference a template format without parameters." + } + }, + "required": [ + "agent_url", + "id" + ], + "additionalProperties": true, + "dependencies": { + "width": [ + "height" + ], + "height": [ + "width" + ] + } + }, + "promoted_offering": { + "type": "string", + "description": "Product name or offering being advertised. Maps to promoted_offerings in create_media_buy request to associate creative with the product being promoted." + }, + "assets": { + "type": "object", + "description": "Map of asset IDs to actual asset content. Each key MUST match an asset_id from the format's assets_required array (e.g., 'banner_image', 'clickthrough_url', 'video_file', 'vast_tag'). The asset_id is the technical identifier used to match assets to format requirements.\n\nIMPORTANT: Creative manifest validation MUST be performed in the context of the format specification. The format defines what type each asset_id should be, which eliminates any validation ambiguity.", + "patternProperties": { + "^[a-z0-9_]+$": { + "oneOf": [ + { + "title": "Image Asset", + "description": "Image asset with URL and dimensions", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL to the image asset" + }, + "width": { + "type": "integer", + "minimum": 1, + "description": "Width in pixels" + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Height in pixels" + }, + "format": { + "type": "string", + "description": "Image file format (jpg, png, gif, webp, etc.)" + }, + "alt_text": { + "type": "string", + "description": "Alternative text for accessibility" + } + }, + "required": [ + "url", + "width", + "height" + ], + "additionalProperties": true + }, + { + "title": "Video Asset", + "description": "Video asset with URL and specifications", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL to the video asset" + }, + "width": { + "type": "integer", + "minimum": 1, + "description": "Width in pixels" + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Height in pixels" + }, + "duration_ms": { + "type": "integer", + "description": "Video duration in milliseconds", + "minimum": 1 + }, + "format": { + "type": "string", + "description": "Video file format (mp4, webm, mov, etc.)" + }, + "bitrate_kbps": { + "type": "integer", + "description": "Video bitrate in kilobits per second", + "minimum": 1 + } + }, + "required": [ + "url", + "width", + "height" + ], + "additionalProperties": true + }, + { + "title": "Audio Asset", + "description": "Audio asset with URL and specifications", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL to the audio asset" + }, + "duration_ms": { + "type": "integer", + "description": "Audio duration in milliseconds", + "minimum": 0 + }, + "format": { + "type": "string", + "description": "Audio file format (mp3, wav, aac, etc.)" + }, + "bitrate_kbps": { + "type": "integer", + "description": "Audio bitrate in kilobits per second", + "minimum": 1 + } + }, + "required": [ + "url" + ], + "additionalProperties": true + }, + { + "title": "VAST Asset", + "description": "VAST (Video Ad Serving Template) tag for third-party video ad serving", + "oneOf": [ + { + "type": "object", + "properties": { + "delivery_type": { + "type": "string", + "const": "url", + "description": "Discriminator indicating VAST is delivered via URL endpoint" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL endpoint that returns VAST XML" + }, + "vast_version": { + "title": "VAST Version", + "description": "VAST specification version", + "type": "string", + "enum": [ + "2.0", + "3.0", + "4.0", + "4.1", + "4.2" + ] + }, + "vpaid_enabled": { + "type": "boolean", + "description": "Whether VPAID (Video Player-Ad Interface Definition) is supported" + }, + "duration_ms": { + "type": "integer", + "description": "Expected video duration in milliseconds (if known)", + "minimum": 0 + }, + "tracking_events": { + "type": "array", + "items": { + "title": "VAST Tracking Event", + "description": "Standard VAST tracking events for video ad playback and interaction", + "type": "string", + "enum": [ + "start", + "firstQuartile", + "midpoint", + "thirdQuartile", + "complete", + "impression", + "click", + "pause", + "resume", + "skip", + "mute", + "unmute", + "fullscreen", + "exitFullscreen", + "playerExpand", + "playerCollapse" + ] + }, + "description": "Tracking events supported by this VAST tag" + } + }, + "required": [ + "delivery_type", + "url" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "delivery_type": { + "type": "string", + "const": "inline", + "description": "Discriminator indicating VAST is delivered as inline XML content" + }, + "content": { + "type": "string", + "description": "Inline VAST XML content" + }, + "vast_version": { + "title": "VAST Version", + "description": "VAST specification version", + "type": "string", + "enum": [ + "2.0", + "3.0", + "4.0", + "4.1", + "4.2" + ] + }, + "vpaid_enabled": { + "type": "boolean", + "description": "Whether VPAID (Video Player-Ad Interface Definition) is supported" + }, + "duration_ms": { + "type": "integer", + "description": "Expected video duration in milliseconds (if known)", + "minimum": 0 + }, + "tracking_events": { + "type": "array", + "items": { + "title": "VAST Tracking Event", + "description": "Standard VAST tracking events for video ad playback and interaction", + "type": "string", + "enum": [ + "start", + "firstQuartile", + "midpoint", + "thirdQuartile", + "complete", + "impression", + "click", + "pause", + "resume", + "skip", + "mute", + "unmute", + "fullscreen", + "exitFullscreen", + "playerExpand", + "playerCollapse" + ] + }, + "description": "Tracking events supported by this VAST tag" + } + }, + "required": [ + "delivery_type", + "content" + ], + "additionalProperties": true + } + ] + }, + { + "title": "Text Asset", + "description": "Text content asset", + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Text content" + }, + "language": { + "type": "string", + "description": "Language code (e.g., 'en', 'es', 'fr')" + } + }, + "required": [ + "content" + ], + "additionalProperties": true + }, + { + "title": "URL Asset", + "description": "URL reference asset", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL reference" + }, + "url_type": { + "title": "URL Asset Type", + "description": "Type of URL asset: 'clickthrough' for user click destination (landing page), 'tracker_pixel' for impression/event tracking via HTTP request (fires GET, expects pixel/204 response), 'tracker_script' for measurement SDKs that must load as + + + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/server/public/admin-settings.html b/server/public/admin-settings.html index d7b9e6ce67..e639b4a6e4 100644 --- a/server/public/admin-settings.html +++ b/server/public/admin-settings.html @@ -192,6 +192,32 @@

Billing Notifications Channel

+ +
+

Escalation Notifications Channel

+

+ Choose which Slack channel receives escalation notifications when Addie cannot fulfill a request. + This allows admins to be notified when users need human assistance. +

+ +
+ Loading... +
+ +
+
+ + + Select a channel where admins can see and respond to escalations. View escalations +
+ +
+
+ @@ -214,6 +240,7 @@

Billing Notifications Channel

currentSettings = await response.json(); updateCurrentChannelDisplay(); + updateCurrentEscalationChannelDisplay(); document.getElementById('loading').style.display = 'none'; document.getElementById('content').style.display = 'block'; @@ -229,7 +256,8 @@

Billing Notifications Channel

// Load Slack channels for picker async function loadSlackChannels() { - const select = document.getElementById('billingChannel'); + const billingSelect = document.getElementById('billingChannel'); + const escalationSelect = document.getElementById('escalationChannel'); try { const response = await fetch('/api/admin/settings/slack-channels'); @@ -241,24 +269,34 @@

Billing Notifications Channel

const data = await response.json(); slackChannels = data.channels; - // Build options - let html = ''; + // Build options for both dropdowns + let billingHtml = ''; + let escalationHtml = ''; + if (slackChannels.length === 0) { - html = ''; - select.innerHTML = html; - showStatusMessage('Addie is not in any private channels. Invite @Addie to your billing channel and refresh.', 'error'); + const emptyOption = ''; + billingSelect.innerHTML = emptyOption; + escalationSelect.innerHTML = emptyOption; + showStatusMessage('Addie is not in any private channels. Invite @Addie to your channels and refresh.', 'error'); } else { slackChannels.forEach(ch => { - const selected = currentSettings?.billing_channel?.channel_id === ch.id ? 'selected' : ''; - html += ``; + const billingSelected = currentSettings?.billing_channel?.channel_id === ch.id ? 'selected' : ''; + const escalationSelected = currentSettings?.escalation_channel?.channel_id === ch.id ? 'selected' : ''; + billingHtml += ``; + escalationHtml += ``; }); - select.innerHTML = html; - select.disabled = false; + billingSelect.innerHTML = billingHtml; + billingSelect.disabled = false; document.getElementById('saveBtn').disabled = false; + + escalationSelect.innerHTML = escalationHtml; + escalationSelect.disabled = false; + document.getElementById('saveEscalationBtn').disabled = false; } } catch (error) { console.error('Error loading Slack channels:', error); - select.innerHTML = ''; + billingSelect.innerHTML = ''; + escalationSelect.innerHTML = ''; showStatusMessage('Failed to load Slack channels: ' + error.message, 'error'); } } @@ -277,6 +315,20 @@

Billing Notifications Channel

} } + // Update the current escalation channel display + function updateCurrentEscalationChannelDisplay() { + const el = document.getElementById('currentEscalationChannel'); + const channel = currentSettings?.escalation_channel; + + if (channel?.channel_id && channel?.channel_name) { + el.className = 'current-value'; + el.innerHTML = `Current: #${escapeHtml(channel.channel_name)}`; + } else { + el.className = 'current-value not-set'; + el.innerHTML = 'Current: Not configured (escalation notifications disabled)'; + } + } + // Save billing channel async function saveBillingChannel() { const select = document.getElementById('billingChannel'); @@ -326,6 +378,55 @@

Billing Notifications Channel

} } + // Save escalation channel + async function saveEscalationChannel() { + const select = document.getElementById('escalationChannel'); + const saveBtn = document.getElementById('saveEscalationBtn'); + const channelId = select.value || null; + + // Find channel name + let channelName = null; + if (channelId) { + const channel = slackChannels.find(c => c.id === channelId); + channelName = channel?.name || null; + } + + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + const response = await fetch('/api/admin/settings/escalation-channel', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channel_id: channelId, channel_name: channelName }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.message || 'Failed to save'); + } + + const data = await response.json(); + currentSettings.escalation_channel = data.escalation_channel; + updateCurrentEscalationChannelDisplay(); + + saveBtn.textContent = 'Saved!'; + saveBtn.classList.add('btn-success'); + showStatusMessage('Escalation channel updated successfully', 'success'); + + setTimeout(() => { + saveBtn.textContent = 'Save'; + saveBtn.classList.remove('btn-success'); + saveBtn.disabled = false; + }, 2000); + } catch (error) { + console.error('Error saving escalation channel:', error); + showStatusMessage('Failed to save: ' + error.message, 'error'); + saveBtn.textContent = 'Save'; + saveBtn.disabled = false; + } + } + // Status message helpers function showStatusMessage(message, type) { const el = document.getElementById('statusMessage'); diff --git a/server/public/admin-sidebar.js b/server/public/admin-sidebar.js index 3dc75dce5c..9f650fe630 100644 --- a/server/public/admin-sidebar.js +++ b/server/public/admin-sidebar.js @@ -46,6 +46,7 @@ { href: '/admin/agreements', label: 'Agreements', icon: '📋' }, { href: '/admin/email', label: 'Email', icon: '📧' }, { href: '/admin/addie', label: 'Addie', icon: '🤖' }, + { href: '/admin/escalations', label: 'Escalations', icon: '🚨' }, { href: '/admin/feeds', label: 'Industry Feeds', icon: '📰' }, { href: '/admin/notification-channels', label: 'Alert Channels', icon: '📢' }, { href: '/admin/api-keys', label: 'API Keys', icon: '🔑' }, diff --git a/server/src/addie/mcp/escalation-tools.ts b/server/src/addie/mcp/escalation-tools.ts index d27b5343b3..abb1518661 100644 --- a/server/src/addie/mcp/escalation-tools.ts +++ b/server/src/addie/mcp/escalation-tools.ts @@ -17,7 +17,7 @@ import { } from '../../db/escalation-db.js'; import { getThreadService } from '../thread-service.js'; import { sendChannelMessage } from '../../slack/client.js'; -import { getActiveChannels } from '../../db/notification-channels-db.js'; +import { getEscalationChannel } from '../../db/system-settings-db.js'; import { AddieDatabase } from '../../db/addie-db.js'; const logger = createLogger('addie-escalation-tools'); @@ -112,15 +112,11 @@ DO NOT use for: ]; /** - * Find the escalation notification channel + * Get the configured escalation notification channel from system settings */ async function getEscalationChannelId(): Promise { - const channels = await getActiveChannels(); - // Look for a channel named "Addie Escalations" or similar - const escalationChannel = channels.find( - (c) => c.name.toLowerCase().includes('escalation') || c.name.toLowerCase().includes('addie-admin') - ); - return escalationChannel?.slack_channel_id || null; + const setting = await getEscalationChannel(); + return setting.channel_id; } /** diff --git a/server/src/db/system-settings-db.ts b/server/src/db/system-settings-db.ts index d63af1a3c7..1a0521da2f 100644 --- a/server/src/db/system-settings-db.ts +++ b/server/src/db/system-settings-db.ts @@ -20,10 +20,16 @@ export interface BillingChannelSetting { channel_name: string | null; } +export interface EscalationChannelSetting { + channel_id: string | null; + channel_name: string | null; +} + // ============== Setting Keys ============== export const SETTING_KEYS = { BILLING_SLACK_CHANNEL: 'billing_slack_channel', + ESCALATION_SLACK_CHANNEL: 'escalation_slack_channel', } as const; // ============== Generic Operations ============== @@ -90,3 +96,28 @@ export async function setBillingChannel( updatedBy ); } + +// ============== Escalation Channel Operations ============== + +/** + * Get the configured escalation notification Slack channel + */ +export async function getEscalationChannel(): Promise { + const result = await getSetting(SETTING_KEYS.ESCALATION_SLACK_CHANNEL); + return result ?? { channel_id: null, channel_name: null }; +} + +/** + * Set the escalation notification Slack channel + */ +export async function setEscalationChannel( + channelId: string | null, + channelName: string | null, + updatedBy?: string +): Promise { + await setSetting( + SETTING_KEYS.ESCALATION_SLACK_CHANNEL, + { channel_id: channelId, channel_name: channelName }, + updatedBy + ); +} diff --git a/server/src/http.ts b/server/src/http.ts index 259362b6d9..a6225995d1 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -3529,6 +3529,10 @@ Disallow: /api/admin/ await this.serveHtmlWithConfig(req, res, 'admin-settings.html'); }); + this.app.get('/admin/escalations', requireAuth, requireAdmin, async (req, res) => { + await this.serveHtmlWithConfig(req, res, 'admin-escalations.html'); + }); + // Registry API endpoints (consolidated agents, publishers, lookups) this.setupRegistryRoutes(); } diff --git a/server/src/routes/admin/settings.ts b/server/src/routes/admin/settings.ts index 08a8ec539b..ac95e81e47 100644 --- a/server/src/routes/admin/settings.ts +++ b/server/src/routes/admin/settings.ts @@ -13,6 +13,8 @@ import { getAllSettings, getBillingChannel, setBillingChannel, + getEscalationChannel, + setEscalationChannel, } from '../../db/system-settings-db.js'; import { getSlackChannels, getChannelInfo, isSlackConfigured } from '../../slack/client.js'; @@ -26,10 +28,12 @@ export function createAdminSettingsRouter(): Router { try { const settings = await getAllSettings(); const billingChannel = await getBillingChannel(); + const escalationChannel = await getEscalationChannel(); res.json({ settings, billing_channel: billingChannel, + escalation_channel: escalationChannel, }); } catch (error) { logger.error({ err: error }, 'Failed to get system settings'); @@ -136,5 +140,64 @@ export function createAdminSettingsRouter(): Router { } }); + // PUT /api/admin/settings/escalation-channel - Update escalation notification channel + router.put('/escalation-channel', requireAuth, requireAdmin, async (req: Request, res: Response) => { + try { + const { channel_id, channel_name } = req.body; + + // Allow null to clear the channel + if (channel_id !== null && channel_id !== undefined) { + // Validate channel ID format + if (typeof channel_id !== 'string' || !/^[CG][A-Z0-9]+$/.test(channel_id)) { + res.status(400).json({ + error: 'Invalid channel ID format', + message: 'Channel ID should start with C or G followed by alphanumeric characters', + }); + return; + } + + // Verify the channel is private (escalation info may contain sensitive user data) + if (isSlackConfigured()) { + const channelInfo = await getChannelInfo(channel_id); + if (channelInfo && !channelInfo.is_private) { + res.status(400).json({ + error: 'Invalid channel', + message: 'Only private channels are allowed for escalation notifications', + }); + return; + } + } + } + + // Validate channel name if provided + if (channel_name !== null && channel_name !== undefined) { + if (typeof channel_name !== 'string' || channel_name.length > 200) { + res.status(400).json({ + error: 'Invalid channel name', + message: 'Channel name must be a string under 200 characters', + }); + return; + } + } + + const userId = req.user?.id; + await setEscalationChannel(channel_id ?? null, channel_name ?? null, userId); + + logger.info({ channel_id, channel_name, userId }, 'Escalation channel updated'); + + const updated = await getEscalationChannel(); + res.json({ + success: true, + escalation_channel: updated, + }); + } catch (error) { + logger.error({ err: error }, 'Failed to update escalation channel'); + res.status(500).json({ + error: 'Failed to update escalation channel', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + return router; } From 422fe9bf67545703e6efd968c138e19a474124e0 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 08:06:12 -0500 Subject: [PATCH 15/58] feat: Zoom meeting webhooks and user timezone support (#775) * feat: Add Zoom meeting webhooks and user timezone support - Handle meeting.started/ended webhooks to update meeting status - Handle recording.completed to store transcripts and Zoom AI summaries - Send Slack notifications when meetings start/end - Add user timezone column with migration - Add helper functions for timezone management Co-Authored-By: Claude Opus 4.5 * fix: Rename user_timezone migration to 171 to avoid conflict Migration 170 already exists in main (slack_activity_index). Co-Authored-By: Claude Opus 4.5 * fix: Use empty changeset for server-only changes Server implementation changes (webhooks, migrations) don't require a protocol version bump. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/nice-kings-add.md | 12 +- server/src/db/meetings-db.ts | 11 ++ .../src/db/migrations/171_user_timezone.sql | 11 ++ server/src/db/users-db.ts | 54 +++++++ server/src/integrations/zoom.ts | 98 ++++++++++++ server/src/notifications/slack.ts | 135 +++++++++++++++++ server/src/routes/webhooks.ts | 26 +++- server/src/services/meeting-service.ts | 142 +++++++++++++++--- server/src/types.ts | 1 + 9 files changed, 466 insertions(+), 24 deletions(-) create mode 100644 server/src/db/migrations/171_user_timezone.sql diff --git a/.changeset/nice-kings-add.md b/.changeset/nice-kings-add.md index 794035520f..80516bcb48 100644 --- a/.changeset/nice-kings-add.md +++ b/.changeset/nice-kings-add.md @@ -1,4 +1,14 @@ --- --- -Exclude Zoom webhook from JSON body parser to enable signature verification +### Zoom Meeting Webhooks & User Timezone + +**Zoom Webhook Integration:** +- Handle `meeting.started` and `meeting.ended` webhooks to update meeting status +- Handle `recording.completed` webhook to store transcripts and Zoom AI Companion summaries +- Send Slack notifications to working group channels when meetings start/end +- Fix JSON body parsing to allow webhook signature verification + +**User Timezone:** +- Add timezone column to users table for scheduling preferences +- Add helper functions for timezone management (getUserTimezone, updateUserTimezone, etc.) diff --git a/server/src/db/meetings-db.ts b/server/src/db/meetings-db.ts index c2ef08b425..42997d6dda 100644 --- a/server/src/db/meetings-db.ts +++ b/server/src/db/meetings-db.ts @@ -201,6 +201,17 @@ export class MeetingsDatabase { return result.rows[0] || null; } + /** + * Get meeting by Zoom meeting ID + */ + async getMeetingByZoomId(zoomMeetingId: string): Promise { + const result = await query( + 'SELECT * FROM meetings WHERE zoom_meeting_id = $1', + [zoomMeetingId] + ); + return result.rows[0] || null; + } + /** * Get meeting with working group info */ diff --git a/server/src/db/migrations/171_user_timezone.sql b/server/src/db/migrations/171_user_timezone.sql new file mode 100644 index 0000000000..a800b14849 --- /dev/null +++ b/server/src/db/migrations/171_user_timezone.sql @@ -0,0 +1,11 @@ +-- Add timezone column to users table +-- This stores the user's preferred timezone for scheduling meetings and notifications +-- Standard IANA timezone format (e.g., 'America/New_York', 'Europe/London') + +ALTER TABLE users +ADD COLUMN IF NOT EXISTS timezone VARCHAR(100); + +-- Index for finding users by timezone (useful for scheduling) +CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone) WHERE timezone IS NOT NULL; + +COMMENT ON COLUMN users.timezone IS 'User preferred timezone in IANA format (e.g., America/New_York)'; diff --git a/server/src/db/users-db.ts b/server/src/db/users-db.ts index 9ba7b3ae52..c5cb0cdee0 100644 --- a/server/src/db/users-db.ts +++ b/server/src/db/users-db.ts @@ -17,6 +17,7 @@ export interface User { country?: string; location_source?: string; location_updated_at?: Date; + timezone?: string; primary_slack_user_id?: string; primary_organization_id?: string; created_at: Date; @@ -121,4 +122,57 @@ export class UsersDatabase { ); return result.rows; } + + /** + * Get user's timezone + */ + async getUserTimezone(workosUserId: string): Promise { + const result = await query<{ timezone: string }>( + `SELECT timezone FROM users WHERE workos_user_id = $1`, + [workosUserId] + ); + return result.rows[0]?.timezone || null; + } + + /** + * Update user's timezone + */ + async updateUserTimezone(workosUserId: string, timezone: string): Promise { + const result = await query( + `UPDATE users + SET timezone = $2, + updated_at = NOW() + WHERE workos_user_id = $1 + RETURNING *`, + [workosUserId, timezone] + ); + return result.rows[0] || null; + } + + /** + * Find users by timezone + */ + async findUsersByTimezone(timezone: string): Promise { + const result = await query( + `SELECT * FROM users + WHERE timezone = $1 + ORDER BY engagement_score DESC`, + [timezone] + ); + return result.rows; + } + + /** + * Get users without timezone set (useful for prompting them to set it) + */ + async findUsersWithoutTimezone(limit = 100): Promise { + const result = await query( + `SELECT * FROM users + WHERE timezone IS NULL + ORDER BY engagement_score DESC + LIMIT $1`, + [limit] + ); + return result.rows; + } } diff --git a/server/src/integrations/zoom.ts b/server/src/integrations/zoom.ts index 8104a5986b..0b297a284f 100644 --- a/server/src/integrations/zoom.ts +++ b/server/src/integrations/zoom.ts @@ -329,6 +329,104 @@ export function parseVttToText(vttContent: string): string { return textLines.join(' '); } +/** + * Zoom AI Companion meeting summary response + */ +export interface ZoomMeetingSummary { + meeting_host_id: string; + meeting_host_email: string; + meeting_uuid: string; + meeting_id: number; + meeting_topic: string; + meeting_start_time: string; + meeting_end_time: string; + summary_start_time: string; + summary_end_time: string; + summary_overview: string; + summary_details: Array<{ + summary_title?: string; + summary_content?: string; + }>; + next_steps?: string[]; + edited_summary?: { + summary_overview?: string; + summary_details?: Array<{ + summary_title?: string; + summary_content?: string; + }>; + next_steps?: string[]; + }; +} + +/** + * Get meeting summary from Zoom AI Companion + * Requires Meeting Summary with AI Companion feature enabled + */ +export async function getMeetingSummary(meetingId: string | number): Promise { + try { + // Double-encode UUID if it contains slashes (Zoom API quirk) + const encodedId = String(meetingId).includes('/') + ? encodeURIComponent(encodeURIComponent(String(meetingId))) + : String(meetingId); + + const summary = await zoomRequest( + 'GET', + `/meetings/${encodedId}/meeting_summary` + ); + + logger.info({ meetingId, hasSummary: !!summary.summary_overview }, 'Retrieved Zoom meeting summary'); + return summary; + } catch (error) { + // 404 means no summary available (meeting hasn't ended, AI Companion not enabled, etc.) + if (error instanceof Error && error.message.includes('404')) { + logger.info({ meetingId }, 'No Zoom meeting summary available'); + return null; + } + logger.error({ err: error, meetingId }, 'Error getting Zoom meeting summary'); + return null; + } +} + +/** + * Format Zoom meeting summary to markdown + */ +export function formatMeetingSummaryAsMarkdown(summary: ZoomMeetingSummary): string { + const parts: string[] = []; + + // Use edited summary if available, otherwise use original + const overview = summary.edited_summary?.summary_overview || summary.summary_overview; + const details = summary.edited_summary?.summary_details || summary.summary_details; + const nextSteps = summary.edited_summary?.next_steps || summary.next_steps; + + if (overview) { + parts.push('## Overview'); + parts.push(overview); + parts.push(''); + } + + if (details && details.length > 0) { + for (const detail of details) { + if (detail.summary_title) { + parts.push(`## ${detail.summary_title}`); + } + if (detail.summary_content) { + parts.push(detail.summary_content); + } + parts.push(''); + } + } + + if (nextSteps && nextSteps.length > 0) { + parts.push('## Next Steps'); + for (const step of nextSteps) { + parts.push(`- ${step}`); + } + parts.push(''); + } + + return parts.join('\n').trim(); +} + /** * Zoom webhook event types we handle */ diff --git a/server/src/notifications/slack.ts b/server/src/notifications/slack.ts index b29126cfb0..57dcda3ce0 100644 --- a/server/src/notifications/slack.ts +++ b/server/src/notifications/slack.ts @@ -516,3 +516,138 @@ export async function notifyPublishedPost(data: { category: data.category, }); } + +/** + * Notify a working group's Slack channel when a meeting starts + */ +export async function notifyMeetingStarted(data: { + slackChannelId: string; + meetingTitle: string; + workingGroupName: string; + zoomJoinUrl?: string; +}): Promise { + if (!isSlackConfigured()) { + logger.debug('Slack not configured, skipping meeting started notification'); + return false; + } + + const message: SlackBlockMessage = { + text: `📹 Meeting starting now: ${data.meetingTitle}`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text' as const, + text: '📹 Meeting Starting Now', + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `*${data.meetingTitle}*\n${data.workingGroupName}`, + }, + }, + ...(data.zoomJoinUrl ? [{ + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `<${data.zoomJoinUrl}|Join Zoom Meeting>`, + }, + }] : []), + ], + }; + + try { + const result = await sendChannelMessage(data.slackChannelId, message); + if (result.ok) { + logger.info( + { channelId: data.slackChannelId, meetingTitle: data.meetingTitle }, + 'Sent meeting started notification to Slack channel' + ); + return true; + } else { + logger.warn( + { channelId: data.slackChannelId, error: result.error }, + 'Failed to send meeting started notification' + ); + return false; + } + } catch (error) { + logger.error({ error, channelId: data.slackChannelId }, 'Error sending meeting started notification'); + return false; + } +} + +/** + * Notify a working group's Slack channel when a meeting ends + */ +export async function notifyMeetingEnded(data: { + slackChannelId: string; + meetingTitle: string; + workingGroupName: string; + durationMinutes?: number; +}): Promise { + if (!isSlackConfigured()) { + logger.debug('Slack not configured, skipping meeting ended notification'); + return false; + } + + const durationText = data.durationMinutes + ? `Duration: ${Math.floor(data.durationMinutes / 60)}h ${data.durationMinutes % 60}m` + : ''; + + const message: SlackBlockMessage = { + text: `✅ Meeting ended: ${data.meetingTitle}`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text' as const, + text: '✅ Meeting Ended', + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `*${data.meetingTitle}*\n${data.workingGroupName}${durationText ? `\n${durationText}` : ''}`, + }, + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: { + type: 'mrkdwn' as const, + text: 'Recording and transcript will be available soon.', + }, + }, + ], + }, + ], + }; + + try { + const result = await sendChannelMessage(data.slackChannelId, message); + if (result.ok) { + logger.info( + { channelId: data.slackChannelId, meetingTitle: data.meetingTitle }, + 'Sent meeting ended notification to Slack channel' + ); + return true; + } else { + logger.warn( + { channelId: data.slackChannelId, error: result.error }, + 'Failed to send meeting ended notification' + ); + return false; + } + } catch (error) { + logger.error({ error, channelId: data.slackChannelId }, 'Error sending meeting ended notification'); + return false; + } +} diff --git a/server/src/routes/webhooks.ts b/server/src/routes/webhooks.ts index b37c4cbeb8..bf9bef9899 100644 --- a/server/src/routes/webhooks.ts +++ b/server/src/routes/webhooks.ts @@ -13,7 +13,11 @@ import { createLogger } from '../logger.js'; import { getPool } from '../db/client.js'; import { ModelConfig } from '../config/models.js'; import { verifyWebhookSignature as verifyZoomSignature } from '../integrations/zoom.js'; -import { handleRecordingCompleted } from '../services/meeting-service.js'; +import { + handleRecordingCompleted, + handleMeetingStarted, + handleMeetingEnded, +} from '../services/meeting-service.js'; import { getFeedByEmailSlug, createEmailPerspective, @@ -1344,21 +1348,31 @@ export function createWebhooksRouter(): Router { case 'recording.completed': case 'recording.transcript_completed': { const meetingUuid = body.payload?.object?.uuid; + const meetingId = body.payload?.object?.id; if (meetingUuid && typeof meetingUuid === 'string' && meetingUuid.length < 256) { - await handleRecordingCompleted(meetingUuid); + // Pass both UUID and numeric ID - we store the numeric ID in our DB + await handleRecordingCompleted(meetingUuid, meetingId?.toString()); } else if (meetingUuid) { logger.warn({ meetingUuidType: typeof meetingUuid }, 'Invalid meetingUuid format'); } break; } - case 'meeting.started': - logger.info({ meetingId: body.payload?.object?.id }, 'Meeting started'); + case 'meeting.started': { + const startedMeetingId = body.payload?.object?.id; + if (startedMeetingId) { + await handleMeetingStarted(startedMeetingId.toString()); + } break; + } - case 'meeting.ended': - logger.info({ meetingId: body.payload?.object?.id }, 'Meeting ended'); + case 'meeting.ended': { + const endedMeetingId = body.payload?.object?.id; + if (endedMeetingId) { + await handleMeetingEnded(endedMeetingId.toString()); + } break; + } default: logger.debug({ event: body.event }, 'Unhandled Zoom webhook event'); diff --git a/server/src/services/meeting-service.ts b/server/src/services/meeting-service.ts index ad7ec349bb..e226fa44b2 100644 --- a/server/src/services/meeting-service.ts +++ b/server/src/services/meeting-service.ts @@ -13,6 +13,10 @@ import { MeetingsDatabase } from '../db/meetings-db.js'; import { WorkingGroupDatabase } from '../db/working-group-db.js'; import * as zoom from '../integrations/zoom.js'; import * as calendar from '../integrations/google-calendar.js'; +import { + notifyMeetingStarted, + notifyMeetingEnded, +} from '../notifications/slack.js'; import type { CreateMeetingInput, UpdateMeetingInput, @@ -163,6 +167,8 @@ export async function scheduleMeeting(options: ScheduleMeetingOptions): Promise< timeZone: timezone, }, attendees: attendeeEmails, + // Set location to Zoom join URL so it appears in calendar invite + location: zoomMeeting?.join_url, }; // Add Zoom link as conference data @@ -291,31 +297,51 @@ export async function addAttendeesToMeeting( /** * Handle Zoom recording completed webhook - * Stores transcript and generates summary + * Stores transcript and fetches Zoom AI Companion summary */ -export async function handleRecordingCompleted(meetingUuid: string): Promise { - logger.info({ meetingUuid }, 'Processing recording completed'); +export async function handleRecordingCompleted(meetingUuid: string, zoomMeetingId?: string): Promise { + logger.info({ meetingUuid, zoomMeetingId }, 'Processing recording completed'); - // Find meeting in database by Zoom meeting ID - // Note: We store the numeric ID, but webhooks use UUID - // For now, we'd need to query by zoom_meeting_id + // Find meeting in database - try zoom_meeting_id first (numeric ID), fall back to UUID lookup + let meeting: Meeting | null = null; + if (zoomMeetingId) { + meeting = await meetingsDb.getMeetingByZoomId(zoomMeetingId); + } - // Get transcript - const transcriptText = await zoom.getTranscriptText(meetingUuid); - if (!transcriptText) { - logger.info({ meetingUuid }, 'No transcript available'); + if (!meeting) { + logger.warn({ meetingUuid, zoomMeetingId }, 'Meeting not found in database - transcript will not be stored'); return; } - // Parse VTT to plain text - const plainText = zoom.parseVttToText(transcriptText); + // Get transcript + const transcriptText = await zoom.getTranscriptText(meetingUuid); + if (transcriptText) { + // Parse VTT to plain text and store + const plainText = zoom.parseVttToText(transcriptText); + logger.info({ meetingId: meeting.id, transcriptLength: plainText.length }, 'Transcript retrieved'); - // TODO: Find the meeting in our database by zoom_meeting_id - // TODO: Store transcript_text - // TODO: Generate AI summary using Claude - // TODO: Store summary + await meetingsDb.updateMeeting(meeting.id, { + transcript_text: plainText, + }); + } else { + logger.info({ meetingUuid, meetingId: meeting.id }, 'No transcript available'); + } + + // Fetch Zoom AI Companion meeting summary + try { + const zoomSummary = await zoom.getMeetingSummary(meetingUuid); + if (zoomSummary) { + const summary = zoom.formatMeetingSummaryAsMarkdown(zoomSummary); + await meetingsDb.updateMeeting(meeting.id, { summary }); + logger.info({ meetingId: meeting.id }, 'Zoom AI Companion summary stored'); + } else { + logger.info({ meetingId: meeting.id }, 'No Zoom AI Companion summary available'); + } + } catch (error) { + logger.error({ err: error, meetingId: meeting.id }, 'Failed to fetch Zoom meeting summary'); + } - logger.info({ meetingUuid, transcriptLength: plainText.length }, 'Transcript processed'); + logger.info({ meetingUuid, meetingId: meeting.id }, 'Recording processing completed'); } /** @@ -496,3 +522,85 @@ function getNextOccurrence(from: Date, rule: RecurrenceRule): Date { return next; } + +/** + * Handle Zoom meeting started webhook + * Updates meeting status and sends Slack notification + */ +export async function handleMeetingStarted(zoomMeetingId: string): Promise { + logger.info({ zoomMeetingId }, 'Processing meeting started'); + + const meeting = await meetingsDb.getMeetingByZoomId(zoomMeetingId); + if (!meeting) { + logger.warn({ zoomMeetingId }, 'Meeting not found in database - skipping started notification'); + return; + } + + // Update meeting status + await meetingsDb.updateMeeting(meeting.id, { status: 'in_progress' }); + + // Get working group for Slack channel + const workingGroup = await workingGroupDb.getWorkingGroupById(meeting.working_group_id); + if (!workingGroup) { + logger.warn({ meetingId: meeting.id }, 'Working group not found - skipping Slack notification'); + return; + } + + // Send Slack notification if channel is configured + if (workingGroup.slack_channel_id) { + await notifyMeetingStarted({ + slackChannelId: workingGroup.slack_channel_id, + meetingTitle: meeting.title, + workingGroupName: workingGroup.name, + zoomJoinUrl: meeting.zoom_join_url, + }); + } + + logger.info({ meetingId: meeting.id, zoomMeetingId }, 'Meeting started processed'); +} + +/** + * Handle Zoom meeting ended webhook + * Updates meeting status and sends Slack notification + */ +export async function handleMeetingEnded(zoomMeetingId: string): Promise { + logger.info({ zoomMeetingId }, 'Processing meeting ended'); + + const meeting = await meetingsDb.getMeetingByZoomId(zoomMeetingId); + if (!meeting) { + logger.warn({ zoomMeetingId }, 'Meeting not found in database - skipping ended notification'); + return; + } + + // Calculate duration if we have start time + let durationMinutes: number | undefined; + if (meeting.start_time) { + const now = new Date(); + durationMinutes = Math.round((now.getTime() - meeting.start_time.getTime()) / 60000); + } + + // Update meeting status + await meetingsDb.updateMeeting(meeting.id, { + status: 'completed', + end_time: new Date(), + }); + + // Get working group for Slack channel + const workingGroup = await workingGroupDb.getWorkingGroupById(meeting.working_group_id); + if (!workingGroup) { + logger.warn({ meetingId: meeting.id }, 'Working group not found - skipping Slack notification'); + return; + } + + // Send Slack notification if channel is configured + if (workingGroup.slack_channel_id) { + await notifyMeetingEnded({ + slackChannelId: workingGroup.slack_channel_id, + meetingTitle: meeting.title, + workingGroupName: workingGroup.name, + durationMinutes, + }); + } + + logger.info({ meetingId: meeting.id, zoomMeetingId, durationMinutes }, 'Meeting ended processed'); +} diff --git a/server/src/types.ts b/server/src/types.ts index 2a5c660af7..d313231c77 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -323,6 +323,7 @@ export type LocationSource = 'manual' | 'outreach' | 'inferred'; export interface UserLocation { city?: string; country?: string; + timezone?: string; location_source?: LocationSource; location_updated_at?: Date; } From 30c9630e345fbab9adadbea20ca734bd441dc51e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 09:39:49 -0500 Subject: [PATCH 16/58] fix: Meeting scheduling timezone comparison bug (#776) * fix: Meeting scheduling timezone comparison bug When Claude provides a datetime like "2026-01-15T09:15:00" without timezone info, JavaScript's Date parses it as server local time (UTC). This caused Addie to incorrectly reject meeting times as "already passed" when users specified times in their local timezone. The fix compares times within the specified timezone context by: 1. Getting current time formatted in the target timezone 2. Comparing ISO strings directly (lexicographically sortable) This ensures "9:15 AM ET" is compared against "current time in ET", not UTC. Co-Authored-By: Claude Opus 4.5 * chore: Add changeset for timezone fix --------- Co-authored-by: Claude Opus 4.5 --- .changeset/timezone-meeting-fix.md | 8 +++++++ server/src/addie/mcp/meeting-tools.ts | 34 ++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 .changeset/timezone-meeting-fix.md diff --git a/.changeset/timezone-meeting-fix.md b/.changeset/timezone-meeting-fix.md new file mode 100644 index 0000000000..e9ce99d6fd --- /dev/null +++ b/.changeset/timezone-meeting-fix.md @@ -0,0 +1,8 @@ +--- +--- + +fix: Meeting scheduling timezone comparison bug + +Fixed Addie incorrectly rejecting meeting times as "already passed" when users +specified times in their local timezone. The datetime comparison now correctly +interprets times in the specified timezone context. diff --git a/server/src/addie/mcp/meeting-tools.ts b/server/src/addie/mcp/meeting-tools.ts index 3bb7503d1a..f32fc22eeb 100644 --- a/server/src/addie/mcp/meeting-tools.ts +++ b/server/src/addie/mcp/meeting-tools.ts @@ -71,6 +71,33 @@ function formatTime(date: Date, timezone = 'America/New_York'): string { }); } +/** + * Get the current time as an ISO string in a specific timezone (for comparison). + * Returns format like "2026-01-15T09:30:00" + */ +function getNowInTimezone(timezone: string): string { + // 'sv-SE' locale gives us ISO-like format: "2026-01-15 09:30:00" + return new Date().toLocaleString('sv-SE', { timeZone: timezone }).replace(' ', 'T'); +} + +/** + * Check if a datetime string (without timezone) represents a future time + * in the specified timezone. + */ +function isFutureTimeInTimezone(isoString: string, timezone: string): boolean { + // If the string has timezone info, convert to the target timezone for comparison + if (/Z|[+-]\d{2}:\d{2}$/.test(isoString)) { + const date = new Date(isoString); + const dateInTz = date.toLocaleString('sv-SE', { timeZone: timezone }).replace(' ', 'T'); + return dateInTz > getNowInTimezone(timezone); + } + + // No timezone info - treat the string as already being in the target timezone + // Just compare strings directly (ISO format is lexicographically sortable) + const normalizedInput = isoString.substring(0, 19); // Take just "YYYY-MM-DDTHH:MM:SS" + return normalizedInput > getNowInTimezone(timezone); +} + /** * Format recurrence rule for display */ @@ -440,9 +467,10 @@ export function createMeetingToolHandlers( return `❌ Invalid start time format. Please use ISO 8601 format (e.g., "2026-01-15T14:00:00").`; } - // Check if meeting is in the future - if (startTime <= new Date()) { - return `❌ Meeting time must be in the future.`; + // Check if meeting is in the future (comparing in the specified timezone) + if (!isFutureTimeInTimezone(startTimeStr, timezone)) { + const nowInTz = getNowInTimezone(timezone); + return `❌ Meeting time must be in the future. Current time in ${timezone}: ${formatTime(new Date(), timezone)}`; } const durationMinutes = (input.duration_minutes as number) || 60; From 2df7c044e870477f7c39776169ee1db811150715 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 09:56:43 -0500 Subject: [PATCH 17/58] fix: Clarify start_time format - no Z suffix (#777) * fix: Clarify start_time format - no Z suffix Claude was interpreting "11 AM ET" as "11 AM UTC" and adding Z suffix, causing meetings to be rejected. Updated schema description to explicitly tell Claude to NOT add Z or timezone offsets - the timezone parameter handles that. Co-Authored-By: Claude Opus 4.5 * fix: Remove CONCURRENTLY from migration 170 CREATE INDEX CONCURRENTLY cannot run inside a transaction block. Migrations run in transactions, so we need to use regular CREATE INDEX. The slack_user_mappings table is small enough that the brief lock is fine. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/fix-meeting-z-suffix.md | 8 ++++++++ server/src/addie/mcp/meeting-tools.ts | 6 ++++-- server/src/db/migrations/170_slack_activity_index.sql | 4 +++- 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-meeting-z-suffix.md diff --git a/.changeset/fix-meeting-z-suffix.md b/.changeset/fix-meeting-z-suffix.md new file mode 100644 index 0000000000..f086904417 --- /dev/null +++ b/.changeset/fix-meeting-z-suffix.md @@ -0,0 +1,8 @@ +--- +--- + +fix: Clarify start_time format in meeting scheduling schema + +Updated schema description to explicitly tell Claude NOT to add Z suffix to +start_time. Claude was interpreting "11 AM ET" as "11 AM UTC" with Z suffix, +causing meetings to be rejected as "already passed". diff --git a/server/src/addie/mcp/meeting-tools.ts b/server/src/addie/mcp/meeting-tools.ts index f32fc22eeb..caae338d51 100644 --- a/server/src/addie/mcp/meeting-tools.ts +++ b/server/src/addie/mcp/meeting-tools.ts @@ -179,9 +179,11 @@ If the user is in a channel associated with a working group, you can omit workin For recurring meetings, use the recurrence parameter with freq, interval, count, and byDay. -Required: title, start_time (ISO format) +Required: title, start_time (ISO format without timezone suffix - use timezone parameter for that) Optional: working_group_slug (auto-detected from channel), description, agenda, duration_minutes, timezone, topic_slugs, recurrence +IMPORTANT: For start_time, provide the time in the user's timezone WITHOUT a Z suffix. For example, if user says "2pm ET", use "2026-01-15T14:00:00" (not "2026-01-15T14:00:00Z"). The timezone parameter (default: America/New_York) specifies what timezone the start_time is in. + Example prompts this handles: - "Schedule a technical working group call for next Tuesday at 2pm ET" - "Set up a bylaws subcommittee meeting for Jan 15 at 3pm PT" @@ -208,7 +210,7 @@ Example prompts this handles: }, start_time: { type: 'string', - description: 'Start time in ISO 8601 format (e.g., "2026-01-15T14:00:00")', + description: 'Start time in ISO 8601 format WITHOUT timezone suffix. The time should be in the timezone specified by the timezone parameter (default ET). Example: "2026-01-15T14:00:00" for 2pm. Do NOT add "Z" or timezone offsets - the timezone parameter handles that.', }, duration_minutes: { type: 'number', diff --git a/server/src/db/migrations/170_slack_activity_index.sql b/server/src/db/migrations/170_slack_activity_index.sql index b2bb9748c1..9a5b778657 100644 --- a/server/src/db/migrations/170_slack_activity_index.sql +++ b/server/src/db/migrations/170_slack_activity_index.sql @@ -5,6 +5,8 @@ -- comparison to identify organizations with recent Slack activity. Without an -- index, this query scans all slack_user_mappings rows. -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_slack_user_mappings_last_activity +-- Note: Not using CONCURRENTLY because migrations run in transactions +-- This table is small enough that the brief lock is acceptable +CREATE INDEX IF NOT EXISTS idx_slack_user_mappings_last_activity ON slack_user_mappings(last_slack_activity_at) WHERE last_slack_activity_at IS NOT NULL; From 54c13b4203994ab37873f0d10860c58295308aa9 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 10:29:55 -0500 Subject: [PATCH 18/58] fix: Get meeting attendee emails from users table (#778) The working_group_memberships.user_email field is often NULL because it's not always populated when members are added. Updated the query to join with the users table to get the actual email. This fixes calendar invites not being sent because attendeeCount was 0. Co-authored-by: Claude Opus 4.5 --- .changeset/fix-meeting-attendee-emails.md | 10 ++++++++++ server/src/db/meetings-db.ts | 18 +++++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-meeting-attendee-emails.md diff --git a/.changeset/fix-meeting-attendee-emails.md b/.changeset/fix-meeting-attendee-emails.md new file mode 100644 index 0000000000..a06cbcfa4b --- /dev/null +++ b/.changeset/fix-meeting-attendee-emails.md @@ -0,0 +1,10 @@ +--- +--- + +fix: Get meeting attendee emails from users table + +The working_group_memberships.user_email field is often NULL because it's not +always populated when members are added. Updated the query to join with the +users table to get the actual email, falling back to the cached value if needed. + +This fixes calendar invites not being sent because attendeeCount was 0. diff --git a/server/src/db/meetings-db.ts b/server/src/db/meetings-db.ts index 42997d6dda..38f18eec47 100644 --- a/server/src/db/meetings-db.ts +++ b/server/src/db/meetings-db.ts @@ -560,12 +560,14 @@ export class MeetingsDatabase { if (topicSlugs && topicSlugs.length > 0) { // Invite only members subscribed to these topics + // Join with users table to get email (working_group_memberships.user_email is often NULL) memberQuery = ` SELECT wgm.workos_user_id, - wgm.user_email as email, - wgm.user_name as name + COALESCE(u.email, wgm.user_email) as email, + COALESCE(u.first_name || ' ' || u.last_name, wgm.user_name) as name FROM working_group_memberships wgm + LEFT JOIN users u ON u.workos_user_id = wgm.workos_user_id LEFT JOIN working_group_topic_subscriptions wgts ON wgts.working_group_id = wgm.working_group_id AND wgts.workos_user_id = wgm.workos_user_id @@ -576,13 +578,15 @@ export class MeetingsDatabase { memberParams = [workingGroupId, topicSlugs]; } else { // Invite all members + // Join with users table to get email (working_group_memberships.user_email is often NULL) memberQuery = ` SELECT - workos_user_id, - user_email as email, - user_name as name - FROM working_group_memberships - WHERE working_group_id = $1 AND status = 'active' + wgm.workos_user_id, + COALESCE(u.email, wgm.user_email) as email, + COALESCE(u.first_name || ' ' || u.last_name, wgm.user_name) as name + FROM working_group_memberships wgm + LEFT JOIN users u ON u.workos_user_id = wgm.workos_user_id + WHERE wgm.working_group_id = $1 AND wgm.status = 'active' `; memberParams = [workingGroupId]; } From 7abdb761eb55f788f39c71355e618dc12a2fc76f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 11:59:16 -0500 Subject: [PATCH 19/58] fix: Strip Z suffix from Zoom start_time to fix timezone (#779) When creating Zoom meetings, toISOString() was adding a Z suffix which made Zoom interpret times as UTC instead of the specified timezone. Example: "11 AM ET" was scheduled for 11:00 UTC = 6:00 AM ET Fix: Strip the Z suffix so Zoom interprets the time using the timezone parameter (America/New_York). Co-authored-by: Claude Opus 4.5 --- .changeset/fix-meeting-attendee-emails.md | 10 ---------- .changeset/fix-zoom-meeting-timezone.md | 11 +++++++++++ server/src/services/meeting-service.ts | 19 ++++++++++++++++++- 3 files changed, 29 insertions(+), 11 deletions(-) delete mode 100644 .changeset/fix-meeting-attendee-emails.md create mode 100644 .changeset/fix-zoom-meeting-timezone.md diff --git a/.changeset/fix-meeting-attendee-emails.md b/.changeset/fix-meeting-attendee-emails.md deleted file mode 100644 index a06cbcfa4b..0000000000 --- a/.changeset/fix-meeting-attendee-emails.md +++ /dev/null @@ -1,10 +0,0 @@ ---- ---- - -fix: Get meeting attendee emails from users table - -The working_group_memberships.user_email field is often NULL because it's not -always populated when members are added. Updated the query to join with the -users table to get the actual email, falling back to the cached value if needed. - -This fixes calendar invites not being sent because attendeeCount was 0. diff --git a/.changeset/fix-zoom-meeting-timezone.md b/.changeset/fix-zoom-meeting-timezone.md new file mode 100644 index 0000000000..c3ffd19f75 --- /dev/null +++ b/.changeset/fix-zoom-meeting-timezone.md @@ -0,0 +1,11 @@ +--- +--- + +fix: Strip Z suffix from Zoom meeting start_time + +When creating Zoom meetings, toISOString() was adding a Z suffix which made +Zoom interpret times as UTC instead of the specified timezone. A meeting for +"11 AM ET" was being scheduled for 11:00 UTC = 6:00 AM ET. + +Fixed by stripping the Z suffix so Zoom interprets the time in the timezone +parameter (e.g., America/New_York). diff --git a/server/src/services/meeting-service.ts b/server/src/services/meeting-service.ts index e226fa44b2..a6a26c906f 100644 --- a/server/src/services/meeting-service.ts +++ b/server/src/services/meeting-service.ts @@ -31,6 +31,22 @@ const logger = createLogger('meeting-service'); // Host email for Zoom meetings const ZOOM_HOST_EMAIL = process.env.ZOOM_HOST_EMAIL || 'addie@agenticadvertising.org'; +/** + * Format a Date as ISO string WITHOUT the Z suffix. + * This is needed for Zoom API which interprets times with Z as UTC, + * but times without Z in the specified timezone parameter. + * + * When Claude sends "2026-01-15T11:00:00" for 11 AM ET, JavaScript parses it + * as 11:00 UTC (server local time). By stripping the Z suffix, we tell Zoom + * to interpret "2026-01-15T11:00:00" in the timezone we specify (America/New_York), + * resulting in the correct 11:00 AM ET meeting time. + */ +function formatDateForZoom(date: Date): string { + // toISOString() returns "2026-01-15T11:00:00.000Z" + // We strip the milliseconds and Z to get "2026-01-15T11:00:00" + return date.toISOString().replace(/\.\d{3}Z$/, ''); +} + const meetingsDb = new MeetingsDatabase(); const workingGroupDb = new WorkingGroupDatabase(); @@ -86,7 +102,8 @@ export async function scheduleMeeting(options: ScheduleMeetingOptions): Promise< try { zoomMeeting = await zoom.createMeeting(ZOOM_HOST_EMAIL, { topic: `${workingGroup.name}: ${options.title}`, - start_time: options.startTime.toISOString(), + // Format without Z suffix so Zoom interprets in the specified timezone + start_time: formatDateForZoom(options.startTime), duration: durationMinutes, timezone, agenda: options.agenda || options.description, From c311cd8d561080a9bab8bf7d793cb269ff93d84e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 12:33:35 -0500 Subject: [PATCH 20/58] fix: Accept both UUID and Zoom meeting IDs in meeting tools (#780) * fix: Accept both UUID and Zoom meeting IDs in meeting tools - Show meeting ID in list_upcoming_meetings output so Claude can see it - Update get_meeting_details to try UUID first, then Zoom meeting ID - Update rsvp_to_meeting to try UUID first, then Zoom meeting ID - Update cancel_meeting to try UUID first, then Zoom meeting ID - Update add_meeting_attendee to try UUID first, then Zoom meeting ID This fixes the issue where Claude would try to use Zoom meeting IDs from meeting links but the tools only accepted our internal UUIDs. Co-Authored-By: Claude Opus 4.5 * fix: Strip Z suffix from Google Calendar dateTime to fix timezone Applied the same timezone fix to Google Calendar events that was applied to Zoom meetings in PR #779. The Z suffix was causing Google Calendar to interpret times as UTC, resulting in meetings showing 5 hours earlier than intended for ET. Renamed formatDateForZoom to formatDateWithoutZ since it's now used for both Zoom and Google Calendar. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/fix-calendar-timezone.md | 11 +++++ .changeset/fix-meeting-id-lookup.md | 12 ++++++ server/src/addie/mcp/meeting-tools.ts | 60 ++++++++++++++++++-------- server/src/services/meeting-service.ts | 25 +++++------ 4 files changed, 78 insertions(+), 30 deletions(-) create mode 100644 .changeset/fix-calendar-timezone.md create mode 100644 .changeset/fix-meeting-id-lookup.md diff --git a/.changeset/fix-calendar-timezone.md b/.changeset/fix-calendar-timezone.md new file mode 100644 index 0000000000..32b4b61c6b --- /dev/null +++ b/.changeset/fix-calendar-timezone.md @@ -0,0 +1,11 @@ +--- +--- + +fix: Strip Z suffix from Google Calendar dateTime to fix timezone + +Applied the same timezone fix to Google Calendar events that was applied to +Zoom meetings. The Z suffix was causing Google Calendar to interpret times +as UTC, resulting in meetings showing 5 hours earlier than intended for ET. + +For example, "1 PM ET" was showing as "8 AM ET" because the Z suffix told +Google Calendar the time was 13:00 UTC (which is 8 AM ET). diff --git a/.changeset/fix-meeting-id-lookup.md b/.changeset/fix-meeting-id-lookup.md new file mode 100644 index 0000000000..40bbb03b93 --- /dev/null +++ b/.changeset/fix-meeting-id-lookup.md @@ -0,0 +1,12 @@ +--- +--- + +fix: Accept both UUID and Zoom meeting IDs in meeting tools + +Updated meeting tools to accept both internal UUIDs and Zoom meeting IDs: +- Show meeting ID in list_upcoming_meetings output +- get_meeting_details, cancel_meeting, rsvp_to_meeting, add_meeting_attendee + now try UUID first, then fall back to Zoom meeting ID lookup + +This fixes the issue where Claude would see Zoom meeting IDs in meeting links +but the tools only accepted internal UUIDs. diff --git a/server/src/addie/mcp/meeting-tools.ts b/server/src/addie/mcp/meeting-tools.ts index caae338d51..7df8b9a81b 100644 --- a/server/src/addie/mcp/meeting-tools.ts +++ b/server/src/addie/mcp/meeting-tools.ts @@ -649,6 +649,7 @@ export function createMeetingToolHandlers( for (const meeting of meetings) { response += `📅 **${meeting.title}**\n`; + response += ` ID: ${meeting.id}\n`; response += ` ${formatDate(meeting.start_time)} at ${formatTime(meeting.start_time, meeting.timezone)}\n`; if (!groupName) { response += ` Group: ${meeting.working_group_name}\n`; @@ -707,14 +708,22 @@ export function createMeetingToolHandlers( // Get meeting details handlers.set('get_meeting_details', async (input) => { - const meetingId = input.meeting_id as string; + const inputId = input.meeting_id as string; - const meeting = await meetingsDb.getMeetingWithGroup(meetingId); + // Try to find by our UUID first, then by Zoom meeting ID + let meeting = await meetingsDb.getMeetingWithGroup(inputId); + if (!meeting) { + // Maybe it's a Zoom meeting ID - look that up first + const meetingByZoom = await meetingsDb.getMeetingByZoomId(inputId); + if (meetingByZoom) { + meeting = await meetingsDb.getMeetingWithGroup(meetingByZoom.id); + } + } if (!meeting) { - return `❌ Meeting not found: "${meetingId}"`; + return `❌ Meeting not found: "${inputId}". Use list_upcoming_meetings to find the correct meeting ID.`; } - const attendees = await meetingsDb.getAttendeesForMeeting(meetingId); + const attendees = await meetingsDb.getAttendeesForMeeting(meeting.id); const accepted = attendees.filter(a => a.rsvp_status === 'accepted'); const declined = attendees.filter(a => a.rsvp_status === 'declined'); const pending = attendees.filter(a => a.rsvp_status === 'pending'); @@ -759,21 +768,25 @@ export function createMeetingToolHandlers( return '❌ Unable to identify you. Please make sure you\'re logged in.'; } - const meetingId = input.meeting_id as string; + const inputId = input.meeting_id as string; const response = input.response as 'accepted' | 'declined' | 'tentative'; const note = input.note as string | undefined; - const meeting = await meetingsDb.getMeetingById(meetingId); + // Try to find by our UUID first, then by Zoom meeting ID + let meeting = await meetingsDb.getMeetingById(inputId); + if (!meeting) { + meeting = await meetingsDb.getMeetingByZoomId(inputId); + } if (!meeting) { - return `❌ Meeting not found: "${meetingId}"`; + return `❌ Meeting not found: "${inputId}". Use list_upcoming_meetings to find the correct meeting ID.`; } // Check if user is already an attendee - let attendee = await meetingsDb.getAttendee(meetingId, userId); + let attendee = await meetingsDb.getAttendee(meeting.id, userId); if (attendee) { // Update existing RSVP - attendee = await meetingsDb.updateAttendee(meetingId, userId, { + attendee = await meetingsDb.updateAttendee(meeting.id, userId, { rsvp_status: response, rsvp_note: note, }); @@ -785,7 +798,7 @@ export function createMeetingToolHandlers( : userEmail; attendee = await meetingsDb.addAttendee({ - meeting_id: meetingId, + meeting_id: meeting.id, workos_user_id: userId, email: userEmail, name: userName, @@ -810,9 +823,14 @@ export function createMeetingToolHandlers( const meetingId = input.meeting_id as string; - const meeting = await meetingsDb.getMeetingById(meetingId); + // Try to find by our UUID first, then by Zoom meeting ID + let meeting = await meetingsDb.getMeetingById(meetingId); + if (!meeting) { + // Maybe it's a Zoom meeting ID instead of our UUID + meeting = await meetingsDb.getMeetingByZoomId(meetingId); + } if (!meeting) { - return `❌ Meeting not found: "${meetingId}"`; + return `❌ Meeting not found: "${meetingId}". Use list_upcoming_meetings to find the correct meeting ID.`; } if (meeting.status === 'cancelled') { @@ -820,7 +838,8 @@ export function createMeetingToolHandlers( } try { - const result = await meetingService.cancelMeeting(meetingId); + // Use meeting.id (our UUID) not the input meetingId (might be Zoom ID) + const result = await meetingService.cancelMeeting(meeting.id); let response = `✅ Cancelled: **${meeting.title}**\n`; response += `Cancellation notices have been sent to attendees.`; @@ -830,7 +849,7 @@ export function createMeetingToolHandlers( response += result.errors.map(e => `• ${e}`).join('\n'); } - logger.info({ meetingId, cancelledBy: getUserId() }, 'Meeting cancelled via Addie'); + logger.info({ meetingId: meeting.id, cancelledBy: getUserId() }, 'Meeting cancelled via Addie'); return response; } catch (error) { @@ -844,17 +863,22 @@ export function createMeetingToolHandlers( const permCheck = await checkSchedulePermission(); if (permCheck) return permCheck; - const meetingId = input.meeting_id as string; + const inputId = input.meeting_id as string; const email = input.email as string; const name = input.name as string | undefined; - const meeting = await meetingsDb.getMeetingById(meetingId); + // Try to find by our UUID first, then by Zoom meeting ID + let meeting = await meetingsDb.getMeetingById(inputId); + if (!meeting) { + meeting = await meetingsDb.getMeetingByZoomId(inputId); + } if (!meeting) { - return `❌ Meeting not found: "${meetingId}"`; + return `❌ Meeting not found: "${inputId}". Use list_upcoming_meetings to find the correct meeting ID.`; } try { - const result = await meetingService.addAttendeesToMeeting(meetingId, [ + // Use meeting.id (our UUID) not the inputId (might be Zoom ID) + const result = await meetingService.addAttendeesToMeeting(meeting.id, [ { email, name }, ]); diff --git a/server/src/services/meeting-service.ts b/server/src/services/meeting-service.ts index a6a26c906f..05b2faf068 100644 --- a/server/src/services/meeting-service.ts +++ b/server/src/services/meeting-service.ts @@ -33,17 +33,17 @@ const ZOOM_HOST_EMAIL = process.env.ZOOM_HOST_EMAIL || 'addie@agenticadvertising /** * Format a Date as ISO string WITHOUT the Z suffix. - * This is needed for Zoom API which interprets times with Z as UTC, - * but times without Z in the specified timezone parameter. + * This is needed for both Zoom and Google Calendar APIs which interpret + * times with Z as UTC, but times without Z in the specified timezone parameter. * - * When Claude sends "2026-01-15T11:00:00" for 11 AM ET, JavaScript parses it - * as 11:00 UTC (server local time). By stripping the Z suffix, we tell Zoom - * to interpret "2026-01-15T11:00:00" in the timezone we specify (America/New_York), - * resulting in the correct 11:00 AM ET meeting time. + * When Claude sends "2026-01-15T13:00:00" for 1 PM, JavaScript parses it + * as 13:00 server local time (UTC on Fly.io). By stripping the Z suffix, + * we tell the APIs to interpret "2026-01-15T13:00:00" in the timezone we + * specify (America/New_York), resulting in the correct 1 PM ET meeting time. */ -function formatDateForZoom(date: Date): string { - // toISOString() returns "2026-01-15T11:00:00.000Z" - // We strip the milliseconds and Z to get "2026-01-15T11:00:00" +function formatDateWithoutZ(date: Date): string { + // toISOString() returns "2026-01-15T13:00:00.000Z" + // We strip the milliseconds and Z to get "2026-01-15T13:00:00" return date.toISOString().replace(/\.\d{3}Z$/, ''); } @@ -103,7 +103,7 @@ export async function scheduleMeeting(options: ScheduleMeetingOptions): Promise< zoomMeeting = await zoom.createMeeting(ZOOM_HOST_EMAIL, { topic: `${workingGroup.name}: ${options.title}`, // Format without Z suffix so Zoom interprets in the specified timezone - start_time: formatDateForZoom(options.startTime), + start_time: formatDateWithoutZ(options.startTime), duration: durationMinutes, timezone, agenda: options.agenda || options.description, @@ -172,15 +172,16 @@ export async function scheduleMeeting(options: ScheduleMeetingOptions): Promise< })); // Build calendar event + // Use formatDateWithoutZ so Google interprets in the specified timezone const eventInput: calendar.CreateCalendarEventInput = { summary: `${workingGroup.name}: ${options.title}`, description: buildCalendarDescription(options.description, options.agenda, zoomMeeting), start: { - dateTime: options.startTime.toISOString(), + dateTime: formatDateWithoutZ(options.startTime), timeZone: timezone, }, end: { - dateTime: endTime.toISOString(), + dateTime: formatDateWithoutZ(endTime), timeZone: timezone, }, attendees: attendeeEmails, From 216ff03de3c0024a12f35ca3d92d1aa4091e8c1b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 12:53:32 -0500 Subject: [PATCH 21/58] feat: Rename Research section to Perspectives and add homepage preview (#781) - Rename "Research & Ideas" to "Perspectives" across the site - Fix perspectives article detail page routing (was redirecting to section) - Add "The Latest" section to homepage showing recent perspectives - Add 301 redirect from /latest/research to /latest/perspectives - Update Addie MCP tools with new section URLs Co-authored-by: Claude Opus 4.5 --- .changeset/twelve-hounds-stand.md | 2 + server/public/index.html | 149 ++++++++++++++++++ server/public/nav.js | 4 +- server/src/addie/mcp/member-tools.ts | 10 +- .../172_rename_research_to_perspectives.sql | 8 + server/src/http.ts | 16 +- server/src/routes/latest.ts | 15 +- 7 files changed, 186 insertions(+), 18 deletions(-) create mode 100644 .changeset/twelve-hounds-stand.md create mode 100644 server/src/db/migrations/172_rename_research_to_perspectives.sql diff --git a/.changeset/twelve-hounds-stand.md b/.changeset/twelve-hounds-stand.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/twelve-hounds-stand.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/index.html b/server/public/index.html index 187c3a1a78..972063797a 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -409,6 +409,92 @@ gap: 1.5rem; } } + + /* Latest Posts Section */ + .latestPostsSection_AdCP { + padding: 4rem 0; + background: var(--ifm-color-emphasis-50, #f9fafb); + } + .latestPostsHeader_AdCP { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + } + .latestPostsHeader_AdCP .sectionTitle_Ut5p { + margin-bottom: 0; + } + .viewAllLink_AdCP { + font-size: 0.95rem; + font-weight: 500; + color: var(--color-brand, #1a36b4); + text-decoration: none; + } + .viewAllLink_AdCP:hover { + text-decoration: underline; + } + .latestPostsGrid_AdCP { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + } + .latestPostCard_AdCP { + background: white; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.06); + transition: all 0.2s ease; + border: 1px solid transparent; + display: flex; + flex-direction: column; + } + .latestPostCard_AdCP:hover { + border-color: var(--color-brand, #1a36b4); + box-shadow: 0 4px 16px rgba(26, 54, 180, 0.1); + transform: translateY(-2px); + } + .latestPostMeta_AdCP { + font-size: 0.8rem; + color: var(--ifm-color-emphasis-600); + margin-bottom: 0.75rem; + } + .latestPostTitle_AdCP { + font-size: 1.1rem; + font-weight: 600; + line-height: 1.4; + margin-bottom: 0.75rem; + color: var(--ifm-heading-color); + } + .latestPostTitle_AdCP a { + color: inherit; + text-decoration: none; + } + .latestPostTitle_AdCP a:hover { + color: var(--color-brand, #1a36b4); + } + .latestPostExcerpt_AdCP { + font-size: 0.9rem; + color: var(--ifm-color-emphasis-700); + line-height: 1.6; + flex-grow: 1; + } + .latestPostsLoading_AdCP, + .latestPostsEmpty_AdCP { + grid-column: 1 / -1; + text-align: center; + padding: 2rem; + color: var(--ifm-color-emphasis-600); + } + @media (max-width: 900px) { + .latestPostsGrid_AdCP { + grid-template-columns: 1fr; + } + .latestPostsHeader_AdCP { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + } @@ -696,6 +782,23 @@

Join the community

+ +
+
+
+
+
+

The Latest

+ View all perspectives +
+
+
Loading...
+
+
+
+
+
+
@@ -717,5 +820,51 @@

Built in the open

+ + \ No newline at end of file diff --git a/server/public/nav.js b/server/public/nav.js index 82f4a9107d..24531f17c4 100644 --- a/server/public/nav.js +++ b/server/public/nav.js @@ -204,7 +204,7 @@ @@ -633,56 +634,112 @@

${escapeHtml(s.title)}

document.getElementById('meetingModal').style.display = 'flex'; } + // Edit existing meeting + function editMeeting() { + if (!viewingMeeting) return; + + const m = viewingMeeting; + const date = new Date(m.start_time); + const tz = m.timezone || 'America/New_York'; + + // Format date/time in the meeting's timezone for editing + const dateFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }); + const timeFormatter = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false }); + const formattedDate = dateFormatter.format(date); // YYYY-MM-DD format + const formattedTime = timeFormatter.format(date).replace(/^24:/, '00:'); // HH:MM format + + document.getElementById('modalTitle').textContent = 'Edit Meeting'; + document.getElementById('meetingId').value = m.id; + document.getElementById('workingGroupId').value = m.working_group_id; + document.getElementById('title').value = m.title; + document.getElementById('meetingDate').value = formattedDate; + document.getElementById('meetingTime').value = formattedTime; + document.getElementById('timezone').value = tz; + document.getElementById('description').value = m.description || m.agenda || ''; + + // Calculate duration from start_time and end_time + if (m.end_time) { + const endDate = new Date(m.end_time); + const durationMs = endDate.getTime() - date.getTime(); + document.getElementById('duration').value = Math.round(durationMs / 60000); + } else { + document.getElementById('duration').value = 60; + } + + // Hide invite mode for edits (can't change invites after creation) + document.getElementById('inviteMode').parentElement.style.display = 'none'; + + document.getElementById('saveBtn').textContent = 'Save Changes'; + closeDetailsModal(); + document.getElementById('meetingModal').style.display = 'flex'; + } + function closeModal() { document.getElementById('meetingModal').style.display = 'none'; + // Reset invite mode visibility + document.getElementById('inviteMode').parentElement.style.display = 'block'; + document.getElementById('saveBtn').textContent = 'Schedule'; } - // Save meeting + // Save meeting (create or update) async function saveMeeting(event) { event.preventDefault(); + const meetingId = document.getElementById('meetingId').value; + const isUpdate = !!meetingId; + const date = document.getElementById('meetingDate').value; const time = document.getElementById('meetingTime').value; const timezone = document.getElementById('timezone').value; + const durationMinutes = parseInt(document.getElementById('duration').value) || 60; // Construct ISO datetime const startTime = new Date(`${date}T${time}`); + const endTime = new Date(startTime.getTime() + durationMinutes * 60000); const data = { - working_group_id: document.getElementById('workingGroupId').value, title: document.getElementById('title').value.trim(), description: document.getElementById('description').value.trim() || null, start_time: startTime.toISOString(), - duration_minutes: parseInt(document.getElementById('duration').value) || 60, + end_time: endTime.toISOString(), timezone: timezone, - invite_mode: document.getElementById('inviteMode').value, }; + // Only include these fields for new meetings + if (!isUpdate) { + data.working_group_id = document.getElementById('workingGroupId').value; + data.duration_minutes = durationMinutes; + data.invite_mode = document.getElementById('inviteMode').value; + } + const saveBtn = document.getElementById('saveBtn'); saveBtn.disabled = true; - saveBtn.textContent = 'Scheduling...'; + saveBtn.textContent = isUpdate ? 'Saving...' : 'Scheduling...'; try { - const response = await fetch('/api/admin/meetings', { - method: 'POST', + const url = isUpdate ? `/api/admin/meetings/${meetingId}` : '/api/admin/meetings'; + const method = isUpdate ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) { const error = await response.json(); - throw new Error(error.message || 'Failed to schedule meeting'); + throw new Error(error.message || `Failed to ${isUpdate ? 'update' : 'schedule'} meeting`); } closeModal(); loadMeetings(); } catch (error) { - console.error('Error scheduling meeting:', error); - alert('Failed to schedule meeting: ' + error.message); + console.error(`Error ${isUpdate ? 'updating' : 'scheduling'} meeting:`, error); + alert(`Failed to ${isUpdate ? 'update' : 'schedule'} meeting: ` + error.message); } saveBtn.disabled = false; - saveBtn.textContent = 'Schedule'; + saveBtn.textContent = isUpdate ? 'Save Changes' : 'Schedule'; } // View meeting details @@ -731,7 +788,10 @@

${escapeHtml(s.title)}

`; - document.getElementById('cancelMeetingBtn').style.display = m.status === 'scheduled' ? 'block' : 'none'; + // Only show Edit and Cancel buttons for scheduled meetings + const isScheduled = m.status === 'scheduled'; + document.getElementById('editMeetingBtn').style.display = isScheduled ? 'inline-block' : 'none'; + document.getElementById('cancelMeetingBtn').style.display = isScheduled ? 'inline-block' : 'none'; document.getElementById('detailsModal').style.display = 'flex'; } catch (error) { console.error('Error loading meeting:', error); diff --git a/server/public/working-groups/detail.html b/server/public/working-groups/detail.html index 2bc76e2327..a263930244 100644 --- a/server/public/working-groups/detail.html +++ b/server/public/working-groups/detail.html @@ -1674,10 +1674,10 @@

Recent Events

const response = await fetch(`/api/meetings?working_group_id=${currentGroup.id}`); if (!response.ok) return; - const meetings = await response.json(); - // Filter to only upcoming meetings + const data = await response.json(); + // API returns { meetings: [...] } - filter to scheduled upcoming meetings const now = new Date(); - const upcoming = meetings.filter(m => new Date(m.start_time) > now && m.status === 'scheduled'); + const upcoming = (data.meetings || []).filter(m => new Date(m.start_time) > now && m.status === 'scheduled'); const meetingsSection = document.getElementById('meetingsSection'); const meetingsList = document.getElementById('meetingsList'); diff --git a/server/src/addie/mcp/meeting-tools.ts b/server/src/addie/mcp/meeting-tools.ts index c4046fbc72..ae3915d1db 100644 --- a/server/src/addie/mcp/meeting-tools.ts +++ b/server/src/addie/mcp/meeting-tools.ts @@ -384,6 +384,48 @@ Example prompts this handles: required: ['meeting_id'], }, }, + { + name: 'update_meeting', + description: `Update an existing meeting's details. Use this when someone wants to change the time, title, description, or agenda of a scheduled meeting. + +This will update the meeting in the database, Zoom (if configured), and Google Calendar. + +IMPORTANT: For start_time, provide the time in the user's timezone WITHOUT a Z suffix (same as schedule_meeting).`, + input_schema: { + type: 'object' as const, + properties: { + meeting_id: { + type: 'string', + description: 'Meeting ID (UUID or Zoom meeting ID)', + }, + title: { + type: 'string', + description: 'New meeting title', + }, + description: { + type: 'string', + description: 'New meeting description', + }, + agenda: { + type: 'string', + description: 'New meeting agenda (markdown supported)', + }, + start_time: { + type: 'string', + description: 'New start time in ISO 8601 format WITHOUT timezone suffix (e.g., "2026-01-15T14:00:00" for 2pm). Use timezone parameter to specify the timezone.', + }, + duration_minutes: { + type: 'number', + description: 'New duration in minutes', + }, + timezone: { + type: 'string', + description: 'Timezone for start_time (default: America/New_York)', + }, + }, + required: ['meeting_id'], + }, + }, { name: 'add_meeting_attendee', description: `Add someone to a meeting. Use this when someone asks to add a specific person to a scheduled meeting.`, @@ -929,6 +971,165 @@ export function createMeetingToolHandlers( } }); + // Update meeting + handlers.set('update_meeting', async (input) => { + const permCheck = await checkSchedulePermission(); + if (permCheck) return permCheck; + + const meetingId = input.meeting_id as string; + + // Try to find by our UUID first, then by Zoom meeting ID + let meeting = await meetingsDb.getMeetingById(meetingId); + if (!meeting) { + meeting = await meetingsDb.getMeetingByZoomId(meetingId); + } + if (!meeting) { + return `❌ Meeting not found: "${meetingId}". Use list_upcoming_meetings to find the correct meeting ID.`; + } + + if (meeting.status === 'cancelled') { + return `❌ Cannot update a cancelled meeting.`; + } + + // Build updates object + const updates: Record = {}; + const changes: string[] = []; + + if (input.title) { + updates.title = input.title as string; + changes.push(`Title → "${input.title}"`); + } + + if (input.description !== undefined) { + updates.description = input.description as string; + changes.push('Description updated'); + } + + if (input.agenda !== undefined) { + updates.agenda = input.agenda as string; + changes.push('Agenda updated'); + } + + // Handle time updates + const startTimeStr = input.start_time as string | undefined; + const durationMinutes = input.duration_minutes as number | undefined; + const timezone = (input.timezone as string) || meeting.timezone || 'America/New_York'; + + // Calculate current duration from start_time and end_time + const currentDuration = meeting.start_time && meeting.end_time + ? Math.round((meeting.end_time.getTime() - meeting.start_time.getTime()) / 60000) + : 60; + + if (startTimeStr) { + const startTime = parseDateInTimezone(startTimeStr, timezone); + if (isNaN(startTime.getTime())) { + return `❌ Invalid start time format. Please use ISO 8601 format (e.g., "2026-01-15T14:00:00").`; + } + updates.start_time = startTime; + updates.timezone = timezone; + + const duration = durationMinutes || currentDuration; + updates.end_time = new Date(startTime.getTime() + duration * 60 * 1000); + + changes.push(`Time → ${formatDate(startTime)} at ${formatTime(startTime, timezone)}`); + } else if (durationMinutes && meeting.start_time) { + // Just updating duration, keep existing start time + updates.end_time = new Date(meeting.start_time.getTime() + durationMinutes * 60 * 1000); + changes.push(`Duration → ${durationMinutes} minutes`); + } + + if (changes.length === 0) { + return `No changes specified. You can update: title, description, agenda, start_time, duration_minutes, timezone.`; + } + + try { + // Update in database + const updatedMeeting = await meetingsDb.updateMeeting(meeting.id, updates); + if (!updatedMeeting) { + return `❌ Failed to update meeting in database.`; + } + + const errors: string[] = []; + + // Update Zoom meeting if time changed and Zoom meeting exists + if (updates.start_time && meeting.zoom_meeting_id && zoom.isZoomConfigured()) { + try { + const startTime = updates.start_time as Date; + await zoom.updateMeeting(meeting.zoom_meeting_id, { + start_time: startTime.toISOString(), + duration: durationMinutes || currentDuration, + timezone, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + errors.push(`Zoom update failed: ${msg}`); + logger.error({ err: error, meetingId: meeting.id }, 'Failed to update Zoom meeting'); + } + } + + // Update Google Calendar event if it exists + if (meeting.google_calendar_event_id && calendar.isGoogleCalendarConfigured()) { + try { + const startTime = (updates.start_time as Date) || meeting.start_time; + const title = (updates.title as string) || meeting.title; + + // Calculate end time - use updated value, or compute from start + duration + let endTime = updates.end_time as Date | undefined; + if (!endTime) { + if (meeting.end_time) { + endTime = meeting.end_time; + } else { + // Fallback: compute from start time + current duration + endTime = new Date(startTime.getTime() + currentDuration * 60 * 1000); + } + } + + // Get working group for calendar event summary + const workingGroup = await workingGroupDb.getWorkingGroupById(meeting.working_group_id); + const summary = workingGroup ? `${workingGroup.name}: ${title}` : title; + + await calendar.updateEvent(meeting.google_calendar_event_id, { + summary, + description: (updates.description as string) || meeting.description || undefined, + start: { + dateTime: startTime.toISOString(), + timeZone: timezone, + }, + end: { + dateTime: endTime.toISOString(), + timeZone: timezone, + }, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + errors.push(`Calendar update failed: ${msg}`); + logger.error({ err: error, meetingId: meeting.id }, 'Failed to update calendar event'); + } + } + + let response = `✅ Updated: **${updatedMeeting.title}**\n\n`; + response += `**Changes:**\n${changes.map(c => `• ${c}`).join('\n')}\n`; + + if (errors.length > 0) { + response += `\n⚠️ Some integrations had issues:\n${errors.map(e => `• ${e}`).join('\n')}`; + } else if (meeting.google_calendar_event_id) { + response += `\n📧 Calendar invites have been updated.`; + } + + logger.info({ + meetingId: meeting.id, + changes, + updatedBy: getUserId(), + }, 'Meeting updated via Addie'); + + return response; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ err: error, meetingId: meeting.id }, 'Failed to update meeting via Addie'); + return `❌ Failed to update meeting: ${msg}`; + } + }); + // Add attendee handlers.set('add_meeting_attendee', async (input) => { const permCheck = await checkSchedulePermission(); From 3b5f48d0f8497f78806ab6cb197f6443c142c226 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 19:44:05 -0500 Subject: [PATCH 27/58] fix: Add access control for private Slack channel search (#786) - Fix channel activity query to use slack_ts instead of created_at - Remove aao-admin from exclusion list (was blocking indexing) - Add access control for private channels based on working group membership - Filter search results to only show channels user has access to - Only index private channels that have linked working groups - Add getAccessiblePrivateChannelIds for efficient membership lookup - Add getWorkingGroupIdsByUser for local membership queries This ensures users can only see search results from: 1. Public channels (accessible to all workspace members) 2. Private channels where they are a working group member Co-authored-by: Claude Opus 4.5 --- .changeset/yellow-deer-brush.md | 4 + server/src/addie/bolt-app.ts | 13 ++ server/src/addie/handler.ts | 13 ++ .../src/addie/jobs/slack-history-backfill.ts | 33 +++- server/src/addie/mcp/knowledge-search.ts | 42 +++++- server/src/db/addie-db.ts | 53 ++++++- server/src/db/working-group-db.ts | 13 ++ server/src/slack/client.ts | 141 ++++++++++++++++++ server/src/slack/events.ts | 22 ++- 9 files changed, 315 insertions(+), 19 deletions(-) create mode 100644 .changeset/yellow-deer-brush.md diff --git a/.changeset/yellow-deer-brush.md b/.changeset/yellow-deer-brush.md new file mode 100644 index 0000000000..19e5573f43 --- /dev/null +++ b/.changeset/yellow-deer-brush.md @@ -0,0 +1,4 @@ +--- +--- + +Internal changes only - no protocol impact diff --git a/server/src/addie/bolt-app.ts b/server/src/addie/bolt-app.ts index 8f63a7a28f..5cec58c153 100644 --- a/server/src/addie/bolt-app.ts +++ b/server/src/addie/bolt-app.ts @@ -709,6 +709,19 @@ async function createUserScopedTools( allHandlers.set('bookmark_resource', createUserScopedBookmarkHandler(slackUserId)); } + // Override Slack search handlers with user-scoped versions (for private channel access control) + if (slackUserId) { + const userScopedKnowledgeHandlers = createKnowledgeToolHandlers(slackUserId); + const searchSlackHandler = userScopedKnowledgeHandlers.get('search_slack'); + const getChannelActivityHandler = userScopedKnowledgeHandlers.get('get_channel_activity'); + if (searchSlackHandler) { + allHandlers.set('search_slack', searchSlackHandler); + } + if (getChannelActivityHandler) { + allHandlers.set('get_channel_activity', getChannelActivityHandler); + } + } + return { tools: { tools: allTools, diff --git a/server/src/addie/handler.ts b/server/src/addie/handler.ts index 5990b644ed..955bf63590 100644 --- a/server/src/addie/handler.ts +++ b/server/src/addie/handler.ts @@ -300,6 +300,19 @@ async function createUserScopedTools( allHandlers.set('bookmark_resource', createUserScopedBookmarkHandler(slackUserId)); } + // Override Slack search handlers with user-scoped versions (for private channel access control) + if (slackUserId) { + const userScopedKnowledgeHandlers = createKnowledgeToolHandlers(slackUserId); + const searchSlackHandler = userScopedKnowledgeHandlers.get('search_slack'); + const getChannelActivityHandler = userScopedKnowledgeHandlers.get('get_channel_activity'); + if (searchSlackHandler) { + allHandlers.set('search_slack', searchSlackHandler); + } + if (getChannelActivityHandler) { + allHandlers.set('get_channel_activity', getChannelActivityHandler); + } + } + return { tools: { tools: allTools, diff --git a/server/src/addie/jobs/slack-history-backfill.ts b/server/src/addie/jobs/slack-history-backfill.ts index 15e9429548..76a1fba496 100644 --- a/server/src/addie/jobs/slack-history-backfill.ts +++ b/server/src/addie/jobs/slack-history-backfill.ts @@ -14,6 +14,7 @@ import { logger } from '../../logger.js'; import { AddieDatabase } from '../../db/addie-db.js'; +import { WorkingGroupDatabase } from '../../db/working-group-db.js'; import { getSlackChannels, getFullChannelHistory, @@ -24,13 +25,13 @@ import { } from '../../slack/client.js'; const addieDb = new AddieDatabase(); +const workingGroupDb = new WorkingGroupDatabase(); /** * Default channels to exclude from indexing (sensitive/admin channels) * These contain billing info, admin discussions, etc. that shouldn't be searchable */ const DEFAULT_EXCLUDED_CHANNELS = [ - 'aao-admin', 'aao-billing', 'admin', 'billing', @@ -65,7 +66,7 @@ export interface BackfillOptions { maxMessagesPerChannel?: number; /** Specific channel IDs to backfill (default: all accessible channels) */ channelIds?: string[]; - /** Include private channels (default: false) */ + /** Include private channels (default: true, access controlled at query time) */ includePrivateChannels?: boolean; /** Channel names to exclude (default: admin/billing channels) */ excludeChannelNames?: string[]; @@ -94,7 +95,7 @@ export async function runSlackHistoryBackfill(options: BackfillOptions = {}): Pr daysBack = 90, maxMessagesPerChannel = 1000, channelIds, - includePrivateChannels = false, + includePrivateChannels = true, // Access controlled at query time via membership checks excludeChannelNames = DEFAULT_EXCLUDED_CHANNELS, minMessageLength = 20, includeThreadReplies = true, @@ -172,11 +173,33 @@ export async function runSlackHistoryBackfill(options: BackfillOptions = {}): Pr })); } + // Filter private channels to only include those with working groups + // (enables fast local access checks without Slack API calls) + const privateChannelsBefore = channels.filter(c => c.isPrivate).length; + const filteredChannels: typeof channels = []; + for (const channel of channels) { + if (!channel.isPrivate) { + // Public channels - always include + filteredChannels.push(channel); + } else { + // Private channels - only include if they have a working group + const workingGroup = await workingGroupDb.getWorkingGroupBySlackChannelId(channel.id); + if (workingGroup) { + filteredChannels.push(channel); + } else { + logger.debug({ channelId: channel.id, channelName: channel.name }, 'Skipping private channel without working group'); + } + } + } + channels = filteredChannels; + const privateChannelsAfter = channels.filter(c => c.isPrivate).length; + logger.info({ channelCount: channels.length, publicCount: channels.filter(c => !c.isPrivate).length, - privateCount: channels.filter(c => c.isPrivate).length, - }, 'Backfilling channels'); + privateCount: privateChannelsAfter, + privateChannelsSkipped: privateChannelsBefore - privateChannelsAfter, + }, 'Backfilling channels (private channels without working groups skipped)'); // User cache to avoid repeated lookups const userCache = new Map(); diff --git a/server/src/addie/mcp/knowledge-search.ts b/server/src/addie/mcp/knowledge-search.ts index d0477c97f3..594b918066 100644 --- a/server/src/addie/mcp/knowledge-search.ts +++ b/server/src/addie/mcp/knowledge-search.ts @@ -38,6 +38,7 @@ import { // which is populated by channel history indexing. import { AddieDatabase } from '../../db/addie-db.js'; import { queueWebSearchResult } from '../services/content-curator.js'; +import { findChannelWithAccess, getAccessiblePrivateChannelIds } from '../../slack/client.js'; const addieDb = new AddieDatabase(); @@ -473,8 +474,9 @@ function extractSmartExcerpt(content: string, query: string, maxLength: number = /** * Tool handlers + * @param slackUserId - Optional Slack user ID for access control on private channels */ -export function createKnowledgeToolHandlers(): Map< +export function createKnowledgeToolHandlers(slackUserId?: string): Map< string, (input: Record) => Promise > { @@ -643,8 +645,27 @@ ${excerpt}`; const limit = Math.min((input.limit as number) || 10, 25); try { - // Search local database with optional channel filter - const localResults = await addieDb.searchSlackMessages(searchQuery, { limit, channel }); + // Check access if searching a specific channel and we have user context + if (channel && slackUserId) { + const accessResult = await findChannelWithAccess(channel, slackUserId); + if (accessResult && !accessResult.hasAccess) { + return `Cannot search #${accessResult.channel.name}: ${accessResult.reason || 'Access denied'}.\n\nPrivate channel messages are only accessible to channel members.`; + } + } + + // Get accessible private channel IDs for access filtering + // This ensures search results don't leak private channel content + let accessiblePrivateChannelIds: string[] | undefined; + if (slackUserId) { + accessiblePrivateChannelIds = await getAccessiblePrivateChannelIds(slackUserId); + } + + // Search local database with optional channel filter and access control + const localResults = await addieDb.searchSlackMessages(searchQuery, { + limit, + channel, + accessiblePrivateChannelIds, + }); if (localResults.length > 0) { const formatted = localResults @@ -682,6 +703,21 @@ ${excerpt}`; const limit = input.limit as number | undefined; try { + // Check access if we have user context + if (slackUserId) { + const accessResult = await findChannelWithAccess(channel, slackUserId); + if (accessResult && !accessResult.hasAccess) { + return `Cannot access #${accessResult.channel.name}: ${accessResult.reason || 'Access denied'}.\n\nPrivate channel activity is only accessible to channel members.`; + } + // If it's a private channel and not indexed, inform the user + if (accessResult && accessResult.channel.is_private) { + const messages = await addieDb.getChannelActivity(channel, { days, limit }); + if (messages.length === 0) { + return `No indexed activity found for private channel #${accessResult.channel.name}.\n\nPrivate channels are indexed but may not have historical data. Recent messages should appear after they are sent.`; + } + } + } + const messages = await addieDb.getChannelActivity(channel, { days, limit }); if (messages.length === 0) { diff --git a/server/src/db/addie-db.ts b/server/src/db/addie-db.ts index 699bdcb642..84961cfed6 100644 --- a/server/src/db/addie-db.ts +++ b/server/src/db/addie-db.ts @@ -717,21 +717,55 @@ export class AddieDatabase { /** * Search Slack messages stored locally using PostgreSQL full-text search + * + * @param accessiblePrivateChannelIds - List of private channel IDs the user has access to. + * If provided, results will only include public channels OR private channels in this list. + * If not provided (undefined), no access filtering is applied (use for internal/admin queries). */ async searchSlackMessages(searchQuery: string, options: { limit?: number; channel?: string; + accessiblePrivateChannelIds?: string[]; } = {}): Promise { const limit = options.limit ?? 10; const channel = options.channel; + const accessiblePrivateChannelIds = options.accessiblePrivateChannelIds; + + // Build dynamic query with optional filters + const params: (string | number | string[])[] = [searchQuery, limit]; + let paramIndex = 3; - // Build query with optional channel filter - const channelFilter = channel - ? `AND LOWER(slack_channel_name) LIKE LOWER($3)` - : ''; - const params: (string | number)[] = [searchQuery, limit]; + // Channel name filter + let channelFilter = ''; if (channel) { + channelFilter = `AND LOWER(slack_channel_name) LIKE LOWER($${paramIndex})`; params.push(`%${channel}%`); + paramIndex++; + } + + // Access control filter for private channels + // Only include messages from: + // 1. Public channels (those without a working group - tracked via slack_channel_id) + // 2. Private channels the user has access to (in accessiblePrivateChannelIds) + let accessFilter = ''; + if (accessiblePrivateChannelIds !== undefined) { + if (accessiblePrivateChannelIds.length > 0) { + // Include public channels (not in any working group) OR accessible private channels + accessFilter = `AND ( + NOT EXISTS ( + SELECT 1 FROM working_groups wg + WHERE wg.slack_channel_id = addie_knowledge.slack_channel_id + ) + OR slack_channel_id = ANY($${paramIndex}::text[]) + )`; + params.push(accessiblePrivateChannelIds); + } else { + // User has no private channel access - only show public channels + accessFilter = `AND NOT EXISTS ( + SELECT 1 FROM working_groups wg + WHERE wg.slack_channel_id = addie_knowledge.slack_channel_id + )`; + } } const result = await query( @@ -749,6 +783,7 @@ export class AddieDatabase { AND source_type = 'slack' AND search_vector @@ websearch_to_tsquery('english', $1) ${channelFilter} + ${accessFilter} ORDER BY rank DESC LIMIT $2`, params @@ -768,6 +803,7 @@ export class AddieDatabase { /** * Get recent messages from a channel (no keyword search, just by recency) + * Uses the actual Slack message timestamp (slack_ts) for filtering, not the DB record creation time */ async getChannelActivity(channel: string, options: { days?: number; @@ -794,13 +830,14 @@ export class AddieDatabase { slack_channel_name as channel_name, slack_username as username, slack_permalink as permalink, - created_at + TO_TIMESTAMP(slack_ts::numeric) as created_at FROM addie_knowledge WHERE is_active = TRUE AND source_type = 'slack' AND LOWER(slack_channel_name) LIKE LOWER($1) - AND created_at >= NOW() - INTERVAL '1 day' * $2 - ORDER BY created_at DESC + AND slack_ts IS NOT NULL + AND TO_TIMESTAMP(slack_ts::numeric) >= NOW() - INTERVAL '1 day' * $2 + ORDER BY slack_ts::numeric DESC LIMIT $3`, [`%${channel}%`, days, limit] ); diff --git a/server/src/db/working-group-db.ts b/server/src/db/working-group-db.ts index 86ebd6a910..ff86c68652 100644 --- a/server/src/db/working-group-db.ts +++ b/server/src/db/working-group-db.ts @@ -510,6 +510,19 @@ export class WorkingGroupDatabase { return result.rows.length > 0; } + /** + * Get all working group IDs a user is a member of + */ + async getWorkingGroupIdsByUser(userId: string): Promise { + const canonicalUserId = await this.resolveToCanonicalUserId(userId); + const result = await query<{ working_group_id: string }>( + `SELECT working_group_id FROM working_group_memberships + WHERE workos_user_id = $1 AND status = 'active'`, + [canonicalUserId] + ); + return result.rows.map(r => r.working_group_id); + } + /** * Get all memberships for a working group */ diff --git a/server/src/slack/client.ts b/server/src/slack/client.ts index 8494c497dd..29d048d598 100644 --- a/server/src/slack/client.ts +++ b/server/src/slack/client.ts @@ -7,6 +7,7 @@ import { logger } from '../logger.js'; import { SlackDatabase } from '../db/slack-db.js'; +import { WorkingGroupDatabase } from '../db/working-group-db.js'; import type { SlackUser, SlackChannel, @@ -23,6 +24,15 @@ function getSlackDb(): SlackDatabase { return slackDb; } +// Lazy-initialized working group database for access checks +let workingGroupDb: WorkingGroupDatabase | null = null; +function getWorkingGroupDb(): WorkingGroupDatabase { + if (!workingGroupDb) { + workingGroupDb = new WorkingGroupDatabase(); + } + return workingGroupDb; +} + // Use ADDIE_BOT_TOKEN as the primary token (fall back to SLACK_BOT_TOKEN for migration) const SLACK_BOT_TOKEN = process.env.ADDIE_BOT_TOKEN || process.env.SLACK_BOT_TOKEN; const SLACK_API_BASE = 'https://slack.com/api'; @@ -492,6 +502,137 @@ export async function getChannelMembers(channelId: string): Promise { return members; } +/** + * Check if a user has access to a channel + * Returns true for public channels, checks membership for private channels + * + * Private channels are only indexed if they have a linked working group, + * so we use local working group membership for access control (fast, no API calls). + */ +export async function checkChannelAccess( + channelId: string, + slackUserId: string +): Promise<{ hasAccess: boolean; isPrivate: boolean; reason?: string }> { + try { + const channelInfo = await getChannelInfo(channelId); + if (!channelInfo) { + return { hasAccess: false, isPrivate: false, reason: 'Channel not found' }; + } + + // Public channels are accessible to all workspace members + if (!channelInfo.is_private) { + return { hasAccess: true, isPrivate: false }; + } + + // Private channel - check local working group membership + const wgDb = getWorkingGroupDb(); + const workingGroup = await wgDb.getWorkingGroupBySlackChannelId(channelId); + + if (!workingGroup) { + // Private channel without a working group is not indexed + return { + hasAccess: false, + isPrivate: true, + reason: 'This private channel is not indexed (no linked working group)', + }; + } + + // Check local membership + const slackDb = getSlackDb(); + const mapping = await slackDb.getBySlackUserId(slackUserId); + + if (mapping?.workos_user_id) { + const isMember = await wgDb.isMember(workingGroup.id, mapping.workos_user_id); + if (isMember) { + return { hasAccess: true, isPrivate: true }; + } + } + + return { + hasAccess: false, + isPrivate: true, + reason: 'You are not a member of this private channel', + }; + } catch (error) { + logger.warn({ error, channelId, slackUserId }, 'Failed to check channel access'); + // Fail closed - deny access on error + return { hasAccess: false, isPrivate: false, reason: 'Failed to verify access' }; + } +} + +/** + * Find a channel by name (partial match) and check user access + * Returns channel info if found and accessible + */ +export async function findChannelWithAccess( + channelName: string, + slackUserId: string +): Promise<{ channel: SlackChannel; hasAccess: boolean; reason?: string } | null> { + try { + // Get all channels the bot can see + const allChannels = await getSlackChannels({ + types: 'public_channel,private_channel', + exclude_archived: true, + }); + + // Find channel by name (case-insensitive partial match) + const normalizedName = channelName.toLowerCase(); + const matchedChannel = allChannels.find( + (c) => c.name.toLowerCase().includes(normalizedName) + ); + + if (!matchedChannel) { + return null; + } + + // Check access + const access = await checkChannelAccess(matchedChannel.id, slackUserId); + + return { + channel: matchedChannel, + hasAccess: access.hasAccess, + reason: access.reason, + }; + } catch (error) { + logger.warn({ error, channelName, slackUserId }, 'Failed to find channel with access check'); + return null; + } +} + +/** + * Get the list of private channel IDs the user has access to + * Used to filter search results - only returns channels with working groups + */ +export async function getAccessiblePrivateChannelIds(slackUserId: string): Promise { + try { + const slackDb = getSlackDb(); + const wgDb = getWorkingGroupDb(); + + // Get user's WorkOS ID + const mapping = await slackDb.getBySlackUserId(slackUserId); + if (!mapping?.workos_user_id) { + return []; + } + + // Get all working groups the user is a member of + const workingGroupIds = await wgDb.getWorkingGroupIdsByUser(mapping.workos_user_id); + + // Get the channel IDs for these working groups + const channelIds: string[] = []; + for (const wgId of workingGroupIds) { + const workingGroup = await wgDb.getWorkingGroupById(wgId); + if (workingGroup?.slack_channel_id) { + channelIds.push(workingGroup.slack_channel_id); + } + } + + return channelIds; + } catch (error) { + logger.warn({ error, slackUserId }, 'Failed to get accessible private channel IDs'); + return []; + } +} + /** * Search message result */ diff --git a/server/src/slack/events.ts b/server/src/slack/events.ts index f4d97d3bf4..bb7374ec7c 100644 --- a/server/src/slack/events.ts +++ b/server/src/slack/events.ts @@ -407,9 +407,25 @@ export async function handleMessage(event: SlackMessageEvent): Promise { }, }); - // Index public channel messages for Addie's local search - // Skip DMs (im) and private channels - only index public messages - if (event.channel_type === 'channel' && event.text && event.text.length > 20) { + // Index channel messages for Addie's local search + // Public channels: always index + // Private channels (group): only index if linked to a working group (for fast local access checks) + const isPublicChannel = event.channel_type === 'channel'; + const isPrivateChannel = event.channel_type === 'group'; + + let shouldIndex = false; + if (isPublicChannel) { + shouldIndex = true; + } else if (isPrivateChannel) { + // Only index private channels that have a working group (enables fast local access checks) + const workingGroup = await workingGroupDb.getWorkingGroupBySlackChannelId(event.channel); + shouldIndex = !!workingGroup; + if (!shouldIndex) { + logger.debug({ channelId: event.channel }, 'Skipping private channel without working group'); + } + } + + if (shouldIndex && event.text && event.text.length > 20) { await indexMessageForSearch(event); // Queue for passive note extraction (async, rate-limited) From 2e343ffaaf99d65c9633411638fa0d60a53b2162 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 19:51:10 -0500 Subject: [PATCH 28/58] chore: Move scheduled job logs to debug level (#788) Reduce log noise from scheduled jobs by changing routine success logs from info to debug level while keeping error logs at info level. Affected jobs: - Industry monitor: RSS feed fetching - Content curator: RSS perspectives, community articles, pending resources - Outreach scheduler: Proactive outreach Errors are still logged at info level with a modified message suffix "with errors" to maintain visibility into failures. Co-authored-by: Claude Opus 4.5 --- .changeset/shiny-dots-act.md | 4 ++++ server/src/addie/services/feed-fetcher.ts | 7 ++++++- .../src/addie/services/proactive-outreach.ts | 6 ++++-- server/src/http.ts | 20 +++++++++---------- 4 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 .changeset/shiny-dots-act.md diff --git a/.changeset/shiny-dots-act.md b/.changeset/shiny-dots-act.md new file mode 100644 index 0000000000..3aacdbb4cb --- /dev/null +++ b/.changeset/shiny-dots-act.md @@ -0,0 +1,4 @@ +--- +--- + +Reduce log noise from scheduled jobs by moving routine success logs to debug level while keeping error logs at info level. diff --git a/server/src/addie/services/feed-fetcher.ts b/server/src/addie/services/feed-fetcher.ts index f0a1811dc4..fe21bb37d8 100644 --- a/server/src/addie/services/feed-fetcher.ts +++ b/server/src/addie/services/feed-fetcher.ts @@ -228,8 +228,13 @@ export async function processFeedsToFetch(): Promise<{ } // Only log when there's something notable - if (totalNewPerspectives > 0 || errorCount > 0) { + if (errorCount > 0) { logger.info( + { feedsProcessed: feeds.length, newPerspectives: totalNewPerspectives, errors: errorCount }, + 'RSS feed processing complete with errors' + ); + } else if (totalNewPerspectives > 0) { + logger.debug( { feedsProcessed: feeds.length, newPerspectives: totalNewPerspectives, errors: errorCount }, 'RSS feed processing complete' ); diff --git a/server/src/addie/services/proactive-outreach.ts b/server/src/addie/services/proactive-outreach.ts index ff05335402..43964312cc 100644 --- a/server/src/addie/services/proactive-outreach.ts +++ b/server/src/addie/services/proactive-outreach.ts @@ -634,8 +634,10 @@ export async function runOutreachScheduler(options: { } } - if (sent > 0 || errors > 0) { - logger.info({ sent, skipped, errors }, 'Outreach scheduler completed'); + if (errors > 0) { + logger.info({ sent, skipped, errors }, 'Outreach scheduler completed with errors'); + } else if (sent > 0) { + logger.debug({ sent, skipped, errors }, 'Outreach scheduler completed'); } return { processed: candidates.length, sent, skipped, errors }; } diff --git a/server/src/http.ts b/server/src/http.ts index ec7085fecb..636898f22b 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -6953,17 +6953,17 @@ Disallow: /api/admin/ // Process manually queued resources const result = await processPendingResources({ limit: 5 }); if (result.processed > 0) { - logger.info(result, 'Content curator: processed pending resources'); + logger.debug(result, 'Content curator: processed pending resources'); } // Process RSS perspectives const rssResult = await processRssPerspectives({ limit: 5 }); if (rssResult.processed > 0) { - logger.info(rssResult, 'Content curator: processed RSS perspectives'); + logger.debug(rssResult, 'Content curator: processed RSS perspectives'); } // Process community articles const communityResult = await processCommunityArticles({ limit: 5 }); if (communityResult.processed > 0) { - logger.info(communityResult, 'Content curator: processed community articles'); + logger.debug(communityResult, 'Content curator: processed community articles'); } // Send replies to processed community articles const replyResult = await sendCommunityReplies(async (channelId, threadTs, text) => { @@ -6971,7 +6971,7 @@ Disallow: /api/admin/ return result.ok; }); if (replyResult.sent > 0) { - logger.info(replyResult, 'Content curator: sent community article replies'); + logger.debug(replyResult, 'Content curator: sent community article replies'); } } catch (err) { logger.error({ err }, 'Content curator: initial processing failed'); @@ -6984,17 +6984,17 @@ Disallow: /api/admin/ // Process manually queued resources const result = await processPendingResources({ limit: 5 }); if (result.processed > 0) { - logger.info(result, 'Content curator: processed pending resources'); + logger.debug(result, 'Content curator: processed pending resources'); } // Process RSS perspectives const rssResult = await processRssPerspectives({ limit: 5 }); if (rssResult.processed > 0) { - logger.info(rssResult, 'Content curator: processed RSS perspectives'); + logger.debug(rssResult, 'Content curator: processed RSS perspectives'); } // Process community articles const communityResult = await processCommunityArticles({ limit: 5 }); if (communityResult.processed > 0) { - logger.info(communityResult, 'Content curator: processed community articles'); + logger.debug(communityResult, 'Content curator: processed community articles'); } // Send replies to processed community articles const replyResult = await sendCommunityReplies(async (channelId, threadTs, text) => { @@ -7002,7 +7002,7 @@ Disallow: /api/admin/ return result.ok; }); if (replyResult.sent > 0) { - logger.info(replyResult, 'Content curator: sent community article replies'); + logger.debug(replyResult, 'Content curator: sent community article replies'); } } catch (err) { logger.error({ err }, 'Content curator: periodic processing failed'); @@ -7028,7 +7028,7 @@ Disallow: /api/admin/ try { const result = await processFeedsToFetch(); if (result.feedsProcessed > 0) { - logger.info(result, 'Industry monitor: fetched RSS feeds'); + logger.debug(result, 'Industry monitor: fetched RSS feeds'); } } catch (err) { logger.error({ err }, 'Industry monitor: initial feed fetch failed'); @@ -7039,7 +7039,7 @@ Disallow: /api/admin/ try { const result = await processFeedsToFetch(); if (result.feedsProcessed > 0) { - logger.info(result, 'Industry monitor: fetched RSS feeds'); + logger.debug(result, 'Industry monitor: fetched RSS feeds'); } } catch (err) { logger.error({ err }, 'Industry monitor: periodic feed fetch failed'); From 292a3555fc1103861773ef5f6e9cc7f1e53ebd75 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 16 Jan 2026 07:12:01 -0500 Subject: [PATCH 29/58] fix: Simplify members section to show only organizations (#791) - Remove "Founding Members" vs "Members" distinction - Show single "Members" section with organization logos only - Filter out individual members (non-organizations) from display - Remove unused CSS classes (.launch-members, .launch-member, .member-name-link) - Update dynamic loading to only show members with logos Co-authored-by: Claude Opus 4.5 --- .changeset/honest-stamps-switch.md | 2 + server/public/org-index.html | 82 ++++-------------------------- 2 files changed, 13 insertions(+), 71 deletions(-) create mode 100644 .changeset/honest-stamps-switch.md diff --git a/.changeset/honest-stamps-switch.md b/.changeset/honest-stamps-switch.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/honest-stamps-switch.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/org-index.html b/server/public/org-index.html index 24185f5c0f..bd411b3206 100644 --- a/server/public/org-index.html +++ b/server/public/org-index.html @@ -235,17 +235,6 @@ .member-logo:hover { opacity: 1; } - .launch-members { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 1rem 2rem; - margin-top: 1rem; - } - .launch-member { - font-size: 0.95rem; - color: var(--color-text-muted); - } .all-members-link { text-align: center; margin-top: 2.5rem; @@ -273,15 +262,6 @@ .member-logo-link:hover .member-logo { opacity: 1; } - .member-name-link { - color: var(--color-text-muted); - text-decoration: none; - transition: color 0.2s; - } - .member-name-link:hover { - color: var(--color-brand); - } - /* Go Agentic Section */ .go-agentic { padding: 2.5rem 2rem; @@ -518,11 +498,11 @@

Working Groups

Our Members

Industry leaders building the future of advertising together

- -
-
Founding Members
-
- + +
+
Members
+
+ @@ -533,34 +513,6 @@

Our Members

- - - @@ -608,30 +560,18 @@

Shape the Future of Advertising

const data = await response.json(); const members = data.members || []; - // Separate founding members (with logos) from others - const foundingMembers = members.filter(m => m.is_founding_member && m.show_in_carousel && m.logo_url); - const otherMembers = members.filter(m => !m.is_founding_member || !m.show_in_carousel || !m.logo_url); + // Only show members with logos (organizations, not individuals) + const membersWithLogos = members.filter(m => m.show_in_carousel && m.logo_url); - // Render founding members with logos - const foundingLogosEl = document.getElementById('founding-member-logos'); - if (foundingMembers.length > 0) { - foundingLogosEl.innerHTML = foundingMembers.map(member => ` + // Render members with logos + const memberLogosEl = document.getElementById('member-logos'); + if (membersWithLogos.length > 0) { + memberLogosEl.innerHTML = membersWithLogos.map(member => ` `).join(''); } - - // Render other members as text links - const otherNamesEl = document.getElementById('other-member-names'); - const otherMembersTier = document.getElementById('other-members-tier'); - if (otherMembers.length > 0) { - // Update tier label to "Members" (not "Launch Members") - otherMembersTier.querySelector('.tier-label').textContent = 'Members'; - otherNamesEl.innerHTML = otherMembers.map(member => ` - ${escapeHtml(member.display_name)} - `).join(''); - } } catch (error) { console.debug('Failed to load dynamic members, keeping static content:', error); // Keep static content on error From e7f7e63dab22808599445abb658c1ee2984469d1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 16 Jan 2026 07:32:59 -0500 Subject: [PATCH 30/58] fix: Parse API response correctly in admin meetings page (#790) * fix: Parse API response correctly in admin meetings page The admin meetings page was treating API responses as raw arrays instead of extracting data from the response object structure. The APIs return { meetings: [...] }, { series: [...] }, and { meeting, attendees } but the frontend was assigning the entire response object directly. - Fix loadMeetings() to extract data.meetings from response - Fix loadSeries() to extract data.series and handle missing group filter - Fix viewMeeting() to properly merge meeting and attendees from response Co-Authored-By: Claude Opus 4.5 * feat: Add Schedule button to admin working groups page Allows admins to schedule a meeting directly from the committee list without having to navigate to the meetings page first. - Added Schedule button on each committee row - Auto-opens scheduling modal via URL parameter (?action=schedule) - Working group is pre-selected in the modal Co-Authored-By: Claude Opus 4.5 * chore: Clear action param from URL after opening schedule modal Prevents the modal from re-opening if the user refreshes the page or bookmarks it while the modal is open. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/shy-kings-crash.md | 4 ++++ .changeset/silly-paws-lick.md | 4 ++++ server/public/admin-meetings.html | 31 ++++++++++++++++++++----- server/public/admin-working-groups.html | 1 + 4 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 .changeset/shy-kings-crash.md create mode 100644 .changeset/silly-paws-lick.md diff --git a/.changeset/shy-kings-crash.md b/.changeset/shy-kings-crash.md new file mode 100644 index 0000000000..d21d7fc8c3 --- /dev/null +++ b/.changeset/shy-kings-crash.md @@ -0,0 +1,4 @@ +--- +--- + +Add Schedule button to admin working groups page diff --git a/.changeset/silly-paws-lick.md b/.changeset/silly-paws-lick.md new file mode 100644 index 0000000000..7572fa4a1a --- /dev/null +++ b/.changeset/silly-paws-lick.md @@ -0,0 +1,4 @@ +--- +--- + +Fix admin meetings page crash when loading meetings and series diff --git a/server/public/admin-meetings.html b/server/public/admin-meetings.html index 88c10ad2f3..3460bbd0f7 100644 --- a/server/public/admin-meetings.html +++ b/server/public/admin-meetings.html @@ -463,7 +463,8 @@

Meeting Details

const response = await fetch(url); if (!response.ok) throw new Error('Failed to load meetings'); - meetings = await response.json(); + const data = await response.json(); + meetings = data.meetings || []; renderMeetings(); document.getElementById('loading').style.display = 'none'; @@ -479,15 +480,20 @@

Meeting Details

async function loadSeries() { try { const groupId = document.getElementById('groupFilter').value; - let url = '/api/admin/meetings/series'; - if (groupId) { - url += `?working_group_id=${groupId}`; + + // Series endpoint requires a working group filter + if (!groupId) { + series = []; + renderSeries(); + return; } + const url = `/api/admin/meetings/series?working_group_id=${groupId}`; const response = await fetch(url); if (!response.ok) throw new Error('Failed to load series'); - series = await response.json(); + const data = await response.json(); + series = data.series || []; renderSeries(); } catch (error) { console.error('Error loading series:', error); @@ -748,7 +754,8 @@

${escapeHtml(s.title)}

const response = await fetch(`/api/admin/meetings/${id}`); if (!response.ok) throw new Error('Failed to load meeting'); - viewingMeeting = await response.json(); + const data = await response.json(); + viewingMeeting = { ...data.meeting, attendees: data.attendees }; const m = viewingMeeting; const group = workingGroups.find(g => g.id === m.working_group_id); const date = new Date(m.start_time); @@ -845,6 +852,18 @@

${escapeHtml(s.title)}

async function init() { await loadWorkingGroups(); await loadMeetings(); + + // Auto-open schedule modal if requested via URL + const params = new URLSearchParams(window.location.search); + if (params.get('action') === 'schedule') { + showCreateModal(); + // Clear the action param to prevent re-triggering on refresh + params.delete('action'); + const newUrl = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.replaceState({}, '', newUrl); + } } init(); diff --git a/server/public/admin-working-groups.html b/server/public/admin-working-groups.html index 31e81ad277..cbc53404da 100644 --- a/server/public/admin-working-groups.html +++ b/server/public/admin-working-groups.html @@ -788,6 +788,7 @@

${escapeHtml(group.name)}

+
From 97257938f7c37662922491f6d8f93da9309b8ca2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 16 Jan 2026 07:56:07 -0500 Subject: [PATCH 31/58] feat: Add member setup nudges for incomplete profiles (#792) Add proactive reminders for members about incomplete setup tasks: - Dashboard alerts for missing logo, tagline, and pending join requests - Addie scheduled job to DM members about missing setup items - Addie home alerts for same items via Slack home tab Only targets paying members with active subscriptions. Rate limited to once per 7 days per nudge type per user. Co-authored-by: Claude Opus 4.5 --- .changeset/icy-cases-dig.md | 2 + server/public/dashboard.html | 178 +++++++++ server/src/addie/home/builders/alerts.ts | 29 +- server/src/addie/jobs/scheduler.ts | 51 +++ server/src/addie/jobs/setup-nudges.ts | 345 ++++++++++++++++++ server/src/addie/member-context.ts | 32 ++ .../src/db/migrations/173_setup_nudge_log.sql | 14 + server/src/http.ts | 3 + 8 files changed, 652 insertions(+), 2 deletions(-) create mode 100644 .changeset/icy-cases-dig.md create mode 100644 server/src/addie/jobs/setup-nudges.ts create mode 100644 server/src/db/migrations/173_setup_nudge_log.sql diff --git a/.changeset/icy-cases-dig.md b/.changeset/icy-cases-dig.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/icy-cases-dig.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/dashboard.html b/server/public/dashboard.html index 26fbb00a38..93e1f81d5a 100644 --- a/server/public/dashboard.html +++ b/server/public/dashboard.html @@ -634,8 +634,64 @@

Tell Us About Your Organization

background: var(--color-gray-100); } + /* Setup alerts banner styles */ + .setup-alert { + background: var(--color-warning-50); + border: 1px solid var(--color-warning-200); + border-left: 4px solid var(--color-warning-500); + border-radius: var(--radius-md); + padding: var(--space-4); + margin-bottom: var(--space-4); + display: flex; + align-items: flex-start; + gap: var(--space-3); + } + .setup-alert-icon { + font-size: 18px; + line-height: 1; + flex-shrink: 0; + } + .setup-alert-content { + flex: 1; + min-width: 0; + } + .setup-alert-title { + font-weight: 600; + color: var(--color-warning-800); + margin: 0 0 2px 0; + font-size: 14px; + } + .setup-alert-message { + color: var(--color-warning-700); + margin: 0; + font-size: 13px; + } + .setup-alert-action { + flex-shrink: 0; + } + .setup-alert-action .btn { + font-size: 12px; + padding: 6px 12px; + } + .setup-alert-dismiss { + background: none; + border: none; + color: var(--color-warning-500); + cursor: pointer; + padding: 4px; + font-size: 16px; + line-height: 1; + opacity: 0.7; + } + .setup-alert-dismiss:hover { + opacity: 1; + } + + + + @@ -1289,6 +1345,9 @@

Unable to Load // Show member promo banner (e.g., for upcoming events) renderMemberPromoBanner(org); + + // Show setup alerts for incomplete profile items + renderSetupAlerts(org); } else { // Hide the small membership card and show CTA section membershipCard.style.display = 'none'; @@ -1578,6 +1637,125 @@

Join the Agentic Advertising Organization

// promoBanner.style.display = 'block'; } + // Render setup alerts for incomplete profile items (members only) + async function renderSetupAlerts(org) { + const setupAlerts = document.getElementById('setupAlerts'); + if (!setupAlerts) return; + + // Only show for active members + if (org.billing?.status !== 'active') { + setupAlerts.style.display = 'none'; + return; + } + + // Get dismissed alerts from session storage + const dismissedAlerts = JSON.parse(sessionStorage.getItem('dismissedSetupAlerts') || '[]'); + + // Fetch member profile to check for missing items + try { + const response = await fetch(`/api/member-profiles/org/${org.id}`); + if (!response.ok) { + setupAlerts.style.display = 'none'; + return; + } + + const profile = await response.json(); + const alerts = []; + + // Check for missing logo + if (!profile.logo_url && !dismissedAlerts.includes('missing-logo')) { + alerts.push({ + id: 'missing-logo', + icon: '🖼️', + title: 'Add Your Company Logo', + message: 'Upload a logo so your company appears in the member directory and on the homepage.', + actionLabel: 'Add Logo', + actionUrl: '/dashboard-settings#logo' + }); + } + + // Check for missing tagline + if (!profile.tagline && !dismissedAlerts.includes('missing-tagline')) { + alerts.push({ + id: 'missing-tagline', + icon: '✏️', + title: 'Add a Company Description', + message: 'Help others learn about your organization by adding a tagline.', + actionLabel: 'Add Description', + actionUrl: '/dashboard-settings#profile' + }); + } + + // Check for pending join requests (admins only) + try { + const pendingResponse = await fetch(`/api/organizations/${org.id}/join-requests`); + if (pendingResponse.ok) { + const pendingData = await pendingResponse.json(); + const pendingCount = pendingData.requests?.length || 0; + if (pendingCount > 0 && !dismissedAlerts.includes('pending-join-requests')) { + alerts.push({ + id: 'pending-join-requests', + icon: '👋', + title: `${pendingCount} Pending Join Request${pendingCount > 1 ? 's' : ''}`, + message: `${pendingCount} ${pendingCount > 1 ? 'people are' : 'person is'} waiting to join your team.`, + actionLabel: 'Review Requests', + actionUrl: `/team?org=${org.id}` + }); + } + } + } catch (e) { + // Ignore errors - user may not be admin + } + + // If no alerts, hide the container + if (alerts.length === 0) { + setupAlerts.style.display = 'none'; + return; + } + + // Render alerts + setupAlerts.innerHTML = alerts.map(alert => ` +
+
${alert.icon}
+
+

${escapeHtml(alert.title)}

+

${escapeHtml(alert.message)}

+
+ + +
+ `).join(''); + + setupAlerts.style.display = 'block'; + } catch (error) { + console.error('Error fetching member profile for setup alerts:', error); + setupAlerts.style.display = 'none'; + } + } + + // Dismiss a setup alert for the current session + function dismissSetupAlert(alertId) { + const dismissedAlerts = JSON.parse(sessionStorage.getItem('dismissedSetupAlerts') || '[]'); + if (!dismissedAlerts.includes(alertId)) { + dismissedAlerts.push(alertId); + sessionStorage.setItem('dismissedSetupAlerts', JSON.stringify(dismissedAlerts)); + } + + // Remove the alert from the DOM + const alertEl = document.querySelector(`.setup-alert[data-alert-id="${alertId}"]`); + if (alertEl) { + alertEl.remove(); + } + + // Hide container if no more alerts + const setupAlerts = document.getElementById('setupAlerts'); + if (setupAlerts && setupAlerts.children.length === 0) { + setupAlerts.style.display = 'none'; + } + } + // Load team count for status card async function loadTeamCount(orgId) { try { diff --git a/server/src/addie/home/builders/alerts.ts b/server/src/addie/home/builders/alerts.ts index ab96330c04..deec0e4658 100644 --- a/server/src/addie/home/builders/alerts.ts +++ b/server/src/addie/home/builders/alerts.ts @@ -52,7 +52,7 @@ export async function buildAlerts(memberContext: MemberContext): Promise 0) { + const count = memberContext.pending_join_requests_count; + alerts.push({ + id: 'pending-join-requests', + severity: 'warning', + title: `${count} Pending Join Request${count > 1 ? 's' : ''}`, + message: `${count} ${count > 1 ? 'people are' : 'person is'} waiting to join your team`, + actionLabel: 'Review Requests', + actionUrl: 'https://agenticadvertising.org/dashboard#team', + }); + } + + // 6. Upcoming renewal (within 30 days) if (memberContext.subscription?.current_period_end) { const daysUntilRenewal = Math.floor( (memberContext.subscription.current_period_end.getTime() - Date.now()) / (1000 * 60 * 60 * 24) diff --git a/server/src/addie/jobs/scheduler.ts b/server/src/addie/jobs/scheduler.ts index 8a50326a18..d813aa54f0 100644 --- a/server/src/addie/jobs/scheduler.ts +++ b/server/src/addie/jobs/scheduler.ts @@ -10,6 +10,7 @@ import { runDocumentIndexerJob } from './committee-document-indexer.js'; import { runSummaryGeneratorJob } from './committee-summary-generator.js'; import { runOutreachScheduler } from '../services/proactive-outreach.js'; import { enrichMissingOrganizations } from '../../services/enrichment.js'; +import { runSetupNudgesJob } from './setup-nudges.js'; const logger = baseLogger.child({ module: 'job-scheduler' }); @@ -198,6 +199,49 @@ class JobScheduler { logger.debug({ intervalHours: INTERVAL_HOURS }, 'Account enrichment job started'); } + /** + * Start the setup nudges job + * Sends DMs to members about incomplete setup tasks (missing logo, tagline, pending join requests) + */ + startSetupNudges(): void { + const JOB_NAME = 'setup-nudges'; + const INTERVAL_HOURS = 6; // Check every 6 hours + const INITIAL_DELAY_MS = 240000; // 4 minute delay on startup + + const job: ScheduledJob = { + name: JOB_NAME, + intervalId: null, + initialTimeoutId: null, + }; + + // Run after a delay on startup + job.initialTimeoutId = setTimeout(async () => { + try { + const result = await runSetupNudgesJob({ limit: 10 }); + if (result.nudgesSent > 0) { + logger.info(result, 'Setup nudges: initial run completed'); + } + } catch (err) { + logger.error({ err }, 'Setup nudges: initial run failed'); + } + }, INITIAL_DELAY_MS); + + // Then run periodically + job.intervalId = setInterval(async () => { + try { + const result = await runSetupNudgesJob({ limit: 10 }); + if (result.nudgesSent > 0) { + logger.info(result, 'Setup nudges: job completed'); + } + } catch (err) { + logger.error({ err }, 'Setup nudges: job failed'); + } + }, INTERVAL_HOURS * 60 * 60 * 1000); + + this.jobs.set(JOB_NAME, job); + logger.debug({ intervalHours: INTERVAL_HOURS }, 'Setup nudges job started'); + } + /** * Stop the document indexer job */ @@ -226,6 +270,13 @@ class JobScheduler { this.stopJob('account-enrichment'); } + /** + * Stop the setup nudges job + */ + stopSetupNudges(): void { + this.stopJob('setup-nudges'); + } + /** * Stop a specific job by name */ diff --git a/server/src/addie/jobs/setup-nudges.ts b/server/src/addie/jobs/setup-nudges.ts new file mode 100644 index 0000000000..e289bb9772 --- /dev/null +++ b/server/src/addie/jobs/setup-nudges.ts @@ -0,0 +1,345 @@ +/** + * Setup Nudges Job + * + * Sends proactive DMs to members about incomplete setup items: + * - Missing company logo (members only) + * - Missing company tagline (members only) + * - Pending join requests (org admins only) + * + * Runs periodically to remind users about setup tasks. + * Uses rate limiting to avoid spam (once per 7 days per user per nudge type). + */ + +import { logger } from '../../logger.js'; +import { query } from '../../db/client.js'; + +/** Don't re-nudge same user for same issue within this interval */ +const NUDGE_INTERVAL = '7 days'; + +/** Delay between Slack messages to avoid rate limits */ +const MESSAGE_DELAY_MS = 2000; + +type NudgeType = 'missing_logo' | 'missing_tagline' | 'pending_join_requests'; + +interface SetupNudge { + slack_user_id: string; + workos_user_id: string; + user_name: string | null; + org_id: string; + org_name: string; + nudge_type: NudgeType; + nudge_detail?: string; // e.g., count of pending requests +} + +interface NudgeResult { + nudgesChecked: number; + nudgesSent: number; + skipped: number; + errors: number; +} + +/** + * Get members missing logos who haven't been nudged recently + */ +async function getMembersWithMissingLogos(): Promise { + const result = await query( + `SELECT + sm.slack_user_id, + sm.workos_user_id, + COALESCE(sm.slack_display_name, sm.slack_real_name) as user_name, + o.workos_organization_id as org_id, + o.name as org_name, + 'missing_logo'::text as nudge_type + FROM member_profiles mp + JOIN organizations o ON o.workos_organization_id = mp.workos_organization_id + JOIN organization_memberships om ON om.workos_organization_id = o.workos_organization_id + JOIN slack_user_mappings sm ON sm.workos_user_id = om.workos_user_id + WHERE o.subscription_status = 'active' + AND (mp.logo_url IS NULL OR mp.logo_url = '') + AND sm.slack_user_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM setup_nudge_log snl + WHERE snl.slack_user_id = sm.slack_user_id + AND snl.nudge_type = 'missing_logo' + AND snl.sent_at > NOW() - INTERVAL '7 days' + ) + ORDER BY o.created_at ASC + LIMIT 20` + ); + return result.rows; +} + +/** + * Get members missing taglines who haven't been nudged recently + */ +async function getMembersWithMissingTaglines(): Promise { + const result = await query( + `SELECT + sm.slack_user_id, + sm.workos_user_id, + COALESCE(sm.slack_display_name, sm.slack_real_name) as user_name, + o.workos_organization_id as org_id, + o.name as org_name, + 'missing_tagline'::text as nudge_type + FROM member_profiles mp + JOIN organizations o ON o.workos_organization_id = mp.workos_organization_id + JOIN organization_memberships om ON om.workos_organization_id = o.workos_organization_id + JOIN slack_user_mappings sm ON sm.workos_user_id = om.workos_user_id + WHERE o.subscription_status = 'active' + AND (mp.tagline IS NULL OR mp.tagline = '') + AND sm.slack_user_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM setup_nudge_log snl + WHERE snl.slack_user_id = sm.slack_user_id + AND snl.nudge_type = 'missing_tagline' + AND snl.sent_at > NOW() - INTERVAL '7 days' + ) + ORDER BY o.created_at ASC + LIMIT 20` + ); + return result.rows; +} + +/** + * Get org admins with pending join requests who haven't been nudged recently + */ +async function getAdminsWithPendingRequests(): Promise { + const result = await query( + `SELECT + sm.slack_user_id, + sm.workos_user_id, + COALESCE(sm.slack_display_name, sm.slack_real_name) as user_name, + o.workos_organization_id as org_id, + o.name as org_name, + 'pending_join_requests'::text as nudge_type, + COUNT(jr.id)::text as pending_count + FROM organization_join_requests jr + JOIN organizations o ON o.workos_organization_id = jr.workos_organization_id + JOIN organization_memberships om ON om.workos_organization_id = o.workos_organization_id + JOIN slack_user_mappings sm ON sm.workos_user_id = om.workos_user_id + WHERE jr.status = 'pending' + AND om.role IN ('admin', 'owner') + AND sm.slack_user_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM setup_nudge_log snl + WHERE snl.slack_user_id = sm.slack_user_id + AND snl.nudge_type = 'pending_join_requests' + AND snl.sent_at > NOW() - INTERVAL '7 days' + ) + GROUP BY sm.slack_user_id, sm.workos_user_id, sm.slack_display_name, sm.slack_real_name, + o.workos_organization_id, o.name + HAVING COUNT(jr.id) > 0 + ORDER BY COUNT(jr.id) DESC + LIMIT 20` + ); + + return result.rows.map(r => ({ + ...r, + nudge_detail: r.pending_count, + })); +} + +/** + * Build a nudge message based on type + */ +function buildNudgeMessage(nudge: SetupNudge): string { + const name = nudge.user_name || 'there'; + + switch (nudge.nudge_type) { + case 'missing_logo': + return [ + `Hi ${name}! Just a friendly reminder that your organization doesn't have a logo set up yet.`, + '', + `Adding a logo helps *${nudge.org_name}* stand out in the member directory and on our homepage.`, + '', + `You can add one at: https://agenticadvertising.org/dashboard-settings`, + '', + `_Let me know if you need help uploading your logo!_`, + ].join('\n'); + + case 'missing_tagline': + return [ + `Hi ${name}! I noticed *${nudge.org_name}* doesn't have a company description yet.`, + '', + `Adding a tagline helps other members learn what your company does and makes it easier to find collaboration opportunities.`, + '', + `You can add one at: https://agenticadvertising.org/dashboard-settings`, + '', + `_Feel free to ask if you need help crafting your description!_`, + ].join('\n'); + + case 'pending_join_requests': { + const count = parseInt(nudge.nudge_detail || '1', 10); + const people = count === 1 ? 'person is' : 'people are'; + return [ + `Hi ${name}! ${count} ${people} waiting to join *${nudge.org_name}*.`, + '', + `As an admin, you can review and approve their requests at: https://agenticadvertising.org/dashboard#team`, + '', + `_Let me know if you have any questions about managing your team!_`, + ].join('\n'); + } + + default: + return ''; + } +} + +/** + * Send a nudge DM via Slack + */ +async function sendNudgeDm(slackUserId: string, message: string): Promise { + const token = process.env.ADDIE_BOT_TOKEN; + if (!token) { + logger.warn('ADDIE_BOT_TOKEN not configured - cannot send setup nudges'); + return false; + } + + try { + // Open DM channel + const openResponse = await fetch('https://slack.com/api/conversations.open', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ users: slackUserId }), + }); + + const openData = (await openResponse.json()) as { ok: boolean; channel?: { id: string }; error?: string }; + if (!openData.ok || !openData.channel?.id) { + logger.warn({ error: openData.error, slackUserId }, 'Failed to open DM channel for nudge'); + return false; + } + + // Send message + const sendResponse = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channel: openData.channel.id, + text: message, + mrkdwn: true, + }), + }); + + const sendData = (await sendResponse.json()) as { ok: boolean; error?: string }; + if (!sendData.ok) { + logger.warn({ error: sendData.error, slackUserId }, 'Failed to send nudge message'); + return false; + } + + return true; + } catch (error) { + logger.error({ error, slackUserId }, 'Error sending nudge DM'); + return false; + } +} + +/** + * Log that a nudge was sent + */ +async function logNudgeSent(slackUserId: string, nudgeType: NudgeType): Promise { + await query( + `INSERT INTO setup_nudge_log (slack_user_id, nudge_type, sent_at) + VALUES ($1, $2, NOW())`, + [slackUserId, nudgeType] + ); +} + +/** + * Run the setup nudges job + */ +export async function runSetupNudgesJob(options: { + dryRun?: boolean; + limit?: number; +} = {}): Promise { + const { dryRun = false, limit = 10 } = options; + + logger.debug({ dryRun, limit }, 'Running setup nudges job'); + + // Gather all nudges + const [missingLogos, missingTaglines, pendingRequests] = await Promise.all([ + getMembersWithMissingLogos(), + getMembersWithMissingTaglines(), + getAdminsWithPendingRequests(), + ]); + + // Combine and limit - prioritize pending requests, then logos, then taglines + const allNudges = [ + ...pendingRequests, + ...missingLogos, + ...missingTaglines, + ].slice(0, limit); + + let nudgesSent = 0; + let skipped = 0; + let errors = 0; + + for (const nudge of allNudges) { + const message = buildNudgeMessage(nudge); + if (!message) { + skipped++; + continue; + } + + if (dryRun) { + logger.info({ + slackUserId: nudge.slack_user_id, + nudgeType: nudge.nudge_type, + orgName: nudge.org_name, + message: message.substring(0, 100) + '...', + }, 'DRY RUN: Would send setup nudge'); + nudgesSent++; + continue; + } + + const success = await sendNudgeDm(nudge.slack_user_id, message); + if (success) { + await logNudgeSent(nudge.slack_user_id, nudge.nudge_type); + logger.debug({ + slackUserId: nudge.slack_user_id, + nudgeType: nudge.nudge_type, + orgName: nudge.org_name, + }, 'Sent setup nudge'); + nudgesSent++; + } else { + errors++; + } + + // Rate limit delay between Slack messages + await new Promise(resolve => setTimeout(resolve, MESSAGE_DELAY_MS)); + } + + if (nudgesSent > 0 || errors > 0) { + logger.info({ + nudgesChecked: allNudges.length, + nudgesSent, + skipped, + errors, + }, 'Setup nudges job completed'); + } + + return { + nudgesChecked: allNudges.length, + nudgesSent, + skipped, + errors, + }; +} + +/** + * Preview what nudges would be sent (dry run) + */ +export async function previewSetupNudges(): Promise { + const [missingLogos, missingTaglines, pendingRequests] = await Promise.all([ + getMembersWithMissingLogos(), + getMembersWithMissingTaglines(), + getAdminsWithPendingRequests(), + ]); + + return [...pendingRequests, ...missingLogos, ...missingTaglines]; +} diff --git a/server/src/addie/member-context.ts b/server/src/addie/member-context.ts index cb22cc6182..7c85972eb3 100644 --- a/server/src/addie/member-context.ts +++ b/server/src/addie/member-context.ts @@ -11,6 +11,7 @@ import { OrganizationDatabase } from '../db/organization-db.js'; import { WorkingGroupDatabase } from '../db/working-group-db.js'; import { EmailPreferencesDatabase } from '../db/email-preferences-db.js'; import { AddieDatabase } from '../db/addie-db.js'; +import { JoinRequestDatabase } from '../db/join-request-db.js'; import { getThreadService } from './thread-service.js'; import { workos } from '../auth/workos-client.js'; import { logger } from '../logger.js'; @@ -23,6 +24,7 @@ const orgDb = new OrganizationDatabase(); const workingGroupDb = new WorkingGroupDatabase(); const emailPrefsDb = new EmailPreferencesDatabase(); const addieDb = new AddieDatabase(); +const joinRequestDb = new JoinRequestDatabase(); /** * Get pending content count for a user @@ -157,6 +159,7 @@ export interface MemberContext { member_profile?: { display_name: string; tagline?: string; + logo_url?: string; offerings: string[]; headquarters?: string; }; @@ -229,6 +232,9 @@ export interface MemberContext { total: number; by_committee: Record; }; + + /** Pending join requests for the organization (admins only) */ + pending_join_requests_count?: number; } /** @@ -404,6 +410,7 @@ export async function getMemberContext(slackUserId: string): Promise 0) { + context.pending_join_requests_count = pendingJoinRequestsCount; + } + } catch (error) { + logger.warn({ error, organizationId }, 'Addie: Failed to get pending join requests count'); + } + } + logger.debug( { slackUserId, @@ -627,6 +646,7 @@ export async function getWebMemberContext(workosUserId: string): Promise 0) { + context.pending_join_requests_count = pendingJoinRequestsCount; + } + } catch (error) { + logger.warn({ error, organizationId }, 'Addie Web: Failed to get pending join requests count'); + } + } + logger.debug( { workosUserId, diff --git a/server/src/db/migrations/173_setup_nudge_log.sql b/server/src/db/migrations/173_setup_nudge_log.sql new file mode 100644 index 0000000000..00c6cdf724 --- /dev/null +++ b/server/src/db/migrations/173_setup_nudge_log.sql @@ -0,0 +1,14 @@ +-- Setup Nudge Log +-- Tracks when setup nudges are sent to prevent spam +-- Used by the Addie setup-nudges job + +CREATE TABLE IF NOT EXISTS setup_nudge_log ( + id SERIAL PRIMARY KEY, + slack_user_id VARCHAR(255) NOT NULL, + nudge_type VARCHAR(50) NOT NULL, -- missing_logo, missing_tagline, pending_join_requests + sent_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Index for efficient lookup of recent nudges per user/type +CREATE INDEX IF NOT EXISTS idx_setup_nudge_log_lookup + ON setup_nudge_log (slack_user_id, nudge_type, sent_at DESC); diff --git a/server/src/http.ts b/server/src/http.ts index 636898f22b..600993232f 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -6928,6 +6928,9 @@ Disallow: /api/admin/ // Start account enrichment job jobScheduler.startEnrichment(); + // Start setup nudges job (reminds members about missing logos, taglines, pending requests) + jobScheduler.startSetupNudges(); + this.server = this.app.listen(port, () => { logger.info({ port, From 7375df546af59cb366ce20541a92a5feae67f3d7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 16 Jan 2026 12:48:55 -0500 Subject: [PATCH 32/58] docs: Add ARTF and Prebid.org to protocol landscape (#797) Updates the Agentic Protocol Landscape perspectives content to include: - ARTF (Agentic RTB Framework) in IAB Tech Lab standards - Prebid.org section with key projects - Comparison table showing how standards relate at different layers - Links to ARTF spec and Prebid.org in Get Involved section Co-authored-by: Claude Opus 4.5 --- .changeset/cool-maps-sniff.md | 4 + .../db/migrations/019_seed_perspectives.sql | 31 ++++- .../174_update_protocol_landscape_artf.sql | 126 ++++++++++++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 .changeset/cool-maps-sniff.md create mode 100644 server/src/db/migrations/174_update_protocol_landscape_artf.sql diff --git a/.changeset/cool-maps-sniff.md b/.changeset/cool-maps-sniff.md new file mode 100644 index 0000000000..37f1132cc2 --- /dev/null +++ b/.changeset/cool-maps-sniff.md @@ -0,0 +1,4 @@ +--- +--- + +Add ARTF and Prebid.org to Agentic Protocol Landscape perspectives content. diff --git a/server/src/db/migrations/019_seed_perspectives.sql b/server/src/db/migrations/019_seed_perspectives.sql index c81d5b33e1..b8cae01894 100644 --- a/server/src/db/migrations/019_seed_perspectives.sql +++ b/server/src/db/migrations/019_seed_perspectives.sql @@ -130,8 +130,33 @@ The Interactive Advertising Bureau (IAB) Tech Lab develops and maintains technic - **VAST** - Video ad serving template for video advertising - **Open Measurement** - Viewability and verification measurement - **ads.txt / sellers.json** - Supply chain transparency +- **ARTF (Agentic RTB Framework)** - Enables custom algorithms to run as containerized services within DSPs and SSPs -When an AI agent executes a media buy through AdCP, the underlying transaction often flows through OpenRTB-compatible systems. Agentic protocols provide the AI-native interface; traditional protocols handle the execution. +When an AI agent executes a media buy through AdCP, the underlying transaction often flows through OpenRTB-compatible systems or header bidding wrappers. Agentic protocols provide the AI-native interface; traditional protocols handle the execution. + +### Prebid.org + +Prebid.org is an independent organization maintaining open-source header bidding solutions. While OpenRTB defines how programmatic systems communicate, many publishers use Prebid''s header bidding wrappers to orchestrate unified auctions. + +**Key projects:** + +- **Prebid.js** - Client-side header bidding wrapper for web +- **Prebid Server** - Server-side header bidding solution +- **Prebid Mobile** - Header bidding for mobile apps + +### How These Standards Relate + +Understanding where each standard operates helps clarify their complementary roles: + +| Layer | Standard | What It Does | +|-------|----------|--------------| +| **Agent Communication** | AAIF (MCP, A2A) | How agents discover each other and exchange messages | +| **Transaction Protocol** | AdCP | How buyer and seller agents negotiate and execute media buys | +| **Platform Infrastructure** | ARTF | How custom algorithms (identity, fraud, segmentation) run within ad platforms | +| **Bidding Protocol** | OpenRTB | How bid requests and responses flow between programmatic systems | +| **Header Bidding** | Prebid | How publishers run unified auctions across demand sources | + +ARTF and AdCP address different problems: ARTF enables service providers to deploy platform-independent algorithms *within* DSPs and SSPs for real-time bidstream processing. AdCP enables agents to communicate *between* platforms to discover inventory and execute transactions. A media buy negotiated via AdCP might ultimately flow through platforms running ARTF-containerized services for identity resolution or fraud detection. ## Get Involved @@ -142,11 +167,13 @@ The agentic protocol landscape is being built in the open. Whether you''re inter - **[Agentic Commerce Protocol](https://www.agenticcommerce.dev/)** - Open standard for AI-powered commerce transactions - **[Model Context Protocol](https://modelcontextprotocol.io/)** - Protocol for connecting AI assistants to external tools and data sources - **[IAB Tech Lab](https://iabtechlab.com/)** - Develops technical standards for digital advertising +- **[ARTF Specification](https://iabtechlab.com/standards/artf/)** - Agentic RTB Framework for containerized platform services +- **[Prebid.org](https://prebid.org/)** - Open-source header bidding solutions - **[AdCP Slack Community](https://join.slack.com/t/agenticads/shared_invite/zt-3h15gj6c0-FRTrD_y4HqmeXDKBl2TDEA)** - Join the conversation with developers building the agentic advertising ecosystem', 'published', '2025-12-01 00:00:00+00', 1, - ARRAY['ecosystem', 'protocols', 'standards', 'AAIF', 'MCP', 'A2A'] + ARRAY['ecosystem', 'protocols', 'standards', 'AAIF', 'MCP', 'A2A', 'ARTF', 'IAB', 'Prebid'] ) ON CONFLICT (slug) DO NOTHING; -- 2. AgenticAdvertising.org Launches (Press Release) diff --git a/server/src/db/migrations/174_update_protocol_landscape_artf.sql b/server/src/db/migrations/174_update_protocol_landscape_artf.sql new file mode 100644 index 0000000000..c8fe777958 --- /dev/null +++ b/server/src/db/migrations/174_update_protocol_landscape_artf.sql @@ -0,0 +1,126 @@ +-- Migration: 174_update_protocol_landscape_artf.sql +-- Add ARTF and Prebid.org to the agentic protocol landscape perspective + +UPDATE perspectives +SET content = '## Overview + +As AI agents become more capable, they need standardized ways to communicate with each other and with business systems. The emerging **agentic protocol landscape** addresses this through two complementary approaches: horizontal middleware that provides foundational agent-to-agent communication, and vertical protocols that enable specific business transactions. + +### Why Standards Matter + +Without open standards, the AI agent ecosystem risks fragmenting into proprietary silos where agents from different vendors cannot interoperate. Open standards enable: + +- **Interoperability** - Agents from different vendors can work together +- **Innovation** - Developers can build on shared infrastructure rather than reinventing basics +- **Trust** - Open governance ensures standards evolve for the community''s benefit +- **Choice** - Businesses aren''t locked into single vendor ecosystems + +### The Horizontal/Vertical Framework + +The agentic protocol landscape can be understood through two layers: + +**Horizontal Middleware** provides the foundational layer for agent-to-agent communication. It defines how agents discover each other, exchange messages, share context, and coordinate actions - regardless of what specific business domain they operate in. + +**Vertical Transaction Protocols** define how agents conduct specific types of business transactions. These protocols build on the foundational layer to enable domain-specific workflows. + +## Horizontal Middleware + +### Agentic AI Foundation (AAIF) + +The Agentic AI Foundation is a vendor-neutral home for open source agentic AI projects hosted by the Linux Foundation. AAIF provides open governance, funding for community programs, and coordination to ensure standards evolve for the community''s benefit rather than any single company''s interests. + +**Founding projects contributed to AAIF:** + +- **Model Context Protocol (MCP)** - Anthropic''s protocol for connecting AI assistants to external tools and data sources +- **Agent2Agent (A2A)** - Google''s protocol for communication between AI agents +- **Agents.md** - OpenAI''s specification for agent capability documentation, adopted by 60,000+ projects +- **Goose** - Block''s open source, local-first AI agent framework + +**Members include:** Amazon Web Services, Anthropic, Block, Bloomberg, Cloudflare, Google, Microsoft, OpenAI, Shopify, Snowflake, and many others. + +AAIF aims to be "what the W3C is for the Web" - a set of standards and protocols that guarantee interoperability, open access, and freedom of choice. + +## Vertical Transaction Protocols + +### Ad Context Protocol (AdCP) + +AdCP is an open standard for AI-powered advertising workflows. It enables agents to express advertising intent, discover inventory, exchange signals, and execute media buys across publishers and platforms through a unified interface. + +**Key capabilities:** + +- Natural language inventory discovery and brief-based targeting +- Signal activation for audiences, contexts, and locations +- Media buy creation and optimization +- Creative format discovery and asset management + +AdCP uses a task-first architecture where core advertising tasks can be accessed through multiple underlying protocols (MCP, A2A, or future protocols from AAIF). + +### Agentic Commerce Protocol (ACP) + +ACP is an open standard for AI-powered commerce. It enables buyer agents, merchant systems, and payment providers to coordinate checkout flows, share credentials, and complete purchases in a secure, auditable way. + +**Key capabilities:** + +- Product discovery and catalog navigation +- Cart management and checkout coordination +- Payment credential handling +- Order fulfillment and tracking + +ACP is open source under Apache 2.0 license, allowing any business to implement the specification to transact with any AI agent or payment processor. + +## Traditional Standards + +Agentic protocols don''t replace existing industry infrastructure - they build on top of it. Traditional standards continue to provide essential capabilities that the agentic layer leverages. + +### IAB Tech Lab + +The Interactive Advertising Bureau (IAB) Tech Lab develops and maintains technical standards for the digital advertising industry. These standards form the underlying infrastructure that agentic advertising systems interact with. + +**Key standards:** + +- **OpenRTB** - Real-time bidding protocol for programmatic advertising +- **VAST** - Video ad serving template for video advertising +- **Open Measurement** - Viewability and verification measurement +- **ads.txt / sellers.json** - Supply chain transparency +- **ARTF (Agentic RTB Framework)** - Enables custom algorithms to run as containerized services within DSPs and SSPs + +When an AI agent executes a media buy through AdCP, the underlying transaction often flows through OpenRTB-compatible systems or header bidding wrappers. Agentic protocols provide the AI-native interface; traditional protocols handle the execution. + +### Prebid.org + +Prebid.org is an independent organization maintaining open-source header bidding solutions. While OpenRTB defines how programmatic systems communicate, many publishers use Prebid''s header bidding wrappers to orchestrate unified auctions. + +**Key projects:** + +- **Prebid.js** - Client-side header bidding wrapper for web +- **Prebid Server** - Server-side header bidding solution +- **Prebid Mobile** - Header bidding for mobile apps + +### How These Standards Relate + +Understanding where each standard operates helps clarify their complementary roles: + +| Layer | Standard | What It Does | +|-------|----------|--------------| +| **Agent Communication** | AAIF (MCP, A2A) | How agents discover each other and exchange messages | +| **Transaction Protocol** | AdCP | How buyer and seller agents negotiate and execute media buys | +| **Platform Infrastructure** | ARTF | How custom algorithms (identity, fraud, segmentation) run within ad platforms | +| **Bidding Protocol** | OpenRTB | How bid requests and responses flow between programmatic systems | +| **Header Bidding** | Prebid | How publishers run unified auctions across demand sources | + +ARTF and AdCP address different problems: ARTF enables service providers to deploy platform-independent algorithms *within* DSPs and SSPs for real-time bidstream processing. AdCP enables agents to communicate *between* platforms to discover inventory and execute transactions. A media buy negotiated via AdCP might ultimately flow through platforms running ARTF-containerized services for identity resolution or fraud detection. + +## Get Involved + +The agentic protocol landscape is being built in the open. Whether you''re interested in horizontal middleware, vertical protocols for advertising or commerce, or traditional ad tech standards, there are opportunities to participate and shape the future. + +- **[AgenticAdvertising.org](https://agenticadvertising.org/about)** - The independent industry organization advancing open standards for AI-powered advertising +- **[Agentic AI Foundation](https://aaif.io/)** - The Linux Foundation project providing vendor-neutral governance for agentic AI standards +- **[Agentic Commerce Protocol](https://www.agenticcommerce.dev/)** - Open standard for AI-powered commerce transactions +- **[Model Context Protocol](https://modelcontextprotocol.io/)** - Protocol for connecting AI assistants to external tools and data sources +- **[IAB Tech Lab](https://iabtechlab.com/)** - Develops technical standards for digital advertising +- **[ARTF Specification](https://iabtechlab.com/standards/artf/)** - Agentic RTB Framework for containerized platform services +- **[Prebid.org](https://prebid.org/)** - Open-source header bidding solutions +- **[AdCP Slack Community](https://join.slack.com/t/agenticads/shared_invite/zt-3h15gj6c0-FRTrD_y4HqmeXDKBl2TDEA)** - Join the conversation with developers building the agentic advertising ecosystem', + tags = ARRAY['ecosystem', 'protocols', 'standards', 'AAIF', 'MCP', 'A2A', 'ARTF', 'IAB', 'Prebid'] +WHERE slug = 'agentic-protocol-landscape'; From 12eca30212b256cb1674135d6aa3feae33776c88 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 16 Jan 2026 15:31:44 -0500 Subject: [PATCH 33/58] feat: Add topic Slack channels for working groups (#795) * feat: Add topic Slack channels for working groups - Working group topics can now have an optional Slack channel ID - New invite mode 'slack_channel' allows scheduling meetings for Slack channel members - Admin UI for managing topics with Slack channel linking - Addie can manage topics via manage_committee_topics tool - Added validation for slack_channel invite mode - Added error handling for channel member lookups Co-Authored-By: Claude Opus 4.5 * fix: Rename migration to avoid version conflict Migration 173 already exists on main (173_setup_nudge_log.sql). Rename to 175 to resolve conflict. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/few-rivers-travel.md | 9 + server/public/admin-meetings.html | 107 ++++++++- server/public/admin-working-groups.html | 205 ++++++++++++++++++ server/src/addie/mcp/meeting-tools.ts | 169 ++++++++++++++- server/src/db/meetings-db.ts | 68 +++++- .../migrations/175_topic_slack_channels.sql | 24 ++ server/src/db/working-group-db.ts | 13 +- server/src/routes/meetings.ts | 89 +++++++- server/src/services/meeting-service.ts | 21 +- server/src/types.ts | 8 +- 10 files changed, 698 insertions(+), 15 deletions(-) create mode 100644 .changeset/few-rivers-travel.md create mode 100644 server/src/db/migrations/175_topic_slack_channels.sql diff --git a/.changeset/few-rivers-travel.md b/.changeset/few-rivers-travel.md new file mode 100644 index 0000000000..abd698c584 --- /dev/null +++ b/.changeset/few-rivers-travel.md @@ -0,0 +1,9 @@ +--- +--- + +Add topic Slack channels for working groups + +- Working group topics can now have an optional Slack channel ID +- New invite mode 'slack_channel' allows scheduling meetings for Slack channel members +- Admin UI for managing topics with Slack channel linking +- Addie can manage topics via manage_committee_topics tool diff --git a/server/public/admin-meetings.html b/server/public/admin-meetings.html index 3460bbd0f7..00e93c303b 100644 --- a/server/public/admin-meetings.html +++ b/server/public/admin-meetings.html @@ -317,7 +317,7 @@

Schedule Meeting

-
@@ -365,9 +365,10 @@

Schedule Meeting

- + Members will receive a calendar invite via email @@ -381,6 +382,16 @@

Schedule Meeting

+ + + +
+

Topics

+

+ Topics help organize meetings and filter invitations. Each topic can have its own Slack channel for subgroup discussions. +

+
+ + + + +
+

Leadership

@@ -809,6 +849,149 @@

${escapeHtml(group.name)}

// Current leaders in the form let currentLeaders = []; + // Current topics in the form + let currentTopics = []; + + // Topic management functions + function showAddTopicForm() { + document.getElementById('editingTopicIndex').value = '-1'; + document.getElementById('topicName').value = ''; + document.getElementById('topicSlug').value = ''; + document.getElementById('topicDescription').value = ''; + document.getElementById('topicSlackUrl').value = ''; + validateTopicSlackUrl(); + document.getElementById('topicForm').style.display = 'block'; + } + + function editTopic(index) { + const topic = currentTopics[index]; + if (!topic) return; + + document.getElementById('editingTopicIndex').value = index; + document.getElementById('topicName').value = topic.name || ''; + document.getElementById('topicSlug').value = topic.slug || ''; + document.getElementById('topicDescription').value = topic.description || ''; + // Convert channel ID back to URL format for editing + document.getElementById('topicSlackUrl').value = topic.slack_channel_id + ? `https://agenticads.slack.com/archives/${topic.slack_channel_id}` + : ''; + validateTopicSlackUrl(); + document.getElementById('topicForm').style.display = 'block'; + } + + function cancelTopicEdit() { + document.getElementById('topicForm').style.display = 'none'; + } + + function autoGenerateTopicSlug() { + const name = document.getElementById('topicName').value; + const slugInput = document.getElementById('topicSlug'); + // Only auto-generate if slug is empty or matches previous auto-generation + if (!slugInput.value || slugInput.dataset.autoGenerated === 'true') { + const slug = name.toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + slugInput.value = slug; + slugInput.dataset.autoGenerated = 'true'; + } + } + + function validateTopicSlackUrl() { + const url = document.getElementById('topicSlackUrl').value.trim(); + const statusEl = document.getElementById('topicSlackUrlStatus'); + + if (!url) { + statusEl.innerHTML = 'Optional - link a separate channel for this topic\'s discussions'; + return; + } + + const channelId = extractSlackChannelId(url); + if (channelId) { + statusEl.innerHTML = `✓ Channel ID: ${escapeHtml(channelId)}`; + } else { + statusEl.innerHTML = '⚠ Could not detect channel ID from URL'; + } + } + + function saveTopic() { + const name = document.getElementById('topicName').value.trim(); + const slug = document.getElementById('topicSlug').value.trim(); + const description = document.getElementById('topicDescription').value.trim(); + const slackUrl = document.getElementById('topicSlackUrl').value.trim(); + const editingIndex = parseInt(document.getElementById('editingTopicIndex').value); + + if (!name || !slug) { + alert('Topic name and slug are required'); + return; + } + + // Validate slug format + if (!/^[a-z0-9-]+$/.test(slug)) { + alert('Slug must contain only lowercase letters, numbers, and hyphens'); + return; + } + + // Check for duplicate slugs (excluding current topic if editing) + const duplicateIndex = currentTopics.findIndex((t, i) => t.slug === slug && i !== editingIndex); + if (duplicateIndex !== -1) { + alert('A topic with this slug already exists'); + return; + } + + const topic = { + name, + slug, + description: description || undefined, + slack_channel_id: slackUrl ? extractSlackChannelId(slackUrl) : undefined, + }; + + if (editingIndex >= 0) { + currentTopics[editingIndex] = topic; + } else { + currentTopics.push(topic); + } + + renderTopicsList(); + cancelTopicEdit(); + } + + function removeTopic(index) { + if (!confirm('Remove this topic?')) return; + currentTopics.splice(index, 1); + renderTopicsList(); + } + + function renderTopicsList() { + const listDiv = document.getElementById('topicsList'); + + if (currentTopics.length === 0) { + listDiv.innerHTML = '
No topics configured
'; + return; + } + + listDiv.innerHTML = currentTopics.map((t, i) => ` +
+
+ ${escapeHtml(t.name)} + (${escapeHtml(t.slug)}) + ${t.slack_channel_id ? `📢 ${escapeHtml(t.slack_channel_id)}` : ''} + ${t.description ? `
${escapeHtml(t.description)}
` : ''} +
+
+ + +
+
+ `).join(''); + } + + function clearTopics() { + currentTopics = []; + renderTopicsList(); + } + // Search users async function searchUsers(type) { const inputId = type === 'leader' ? 'leaderSearch' : 'memberSearch'; @@ -1184,6 +1367,8 @@

${escapeHtml(group.name)}

document.getElementById('gatheringWebsite').value = ''; document.getElementById('gatheringLogo').value = ''; clearLeaders(); + clearTopics(); + cancelTopicEdit(); // Hide topic form if open validateSlackUrl(); // Reset status message updateRegionVisibility(); // Hide region for non-chapters, hide gathering fields for non-gatherings updateMembersSectionVisibility(); // Hide members section for new groups @@ -1227,6 +1412,16 @@

${escapeHtml(group.name)}

})); renderLeadersList(); + // Topics + currentTopics = (editingGroup.topics || []).map(t => ({ + name: t.name, + slug: t.slug, + description: t.description, + slack_channel_id: t.slack_channel_id + })); + renderTopicsList(); + cancelTopicEdit(); + // Show members section for existing groups updateMembersSectionVisibility(); await loadMembers(id); @@ -1258,6 +1453,10 @@

${escapeHtml(group.name)}

document.getElementById('interestSection').style.display = 'none'; document.getElementById('interestList').innerHTML = ''; + // Reset topics section + clearTopics(); + cancelTopicEdit(); + // Reset committee type fields document.getElementById('committeeType').value = 'working_group'; document.getElementById('region').value = ''; @@ -1282,6 +1481,12 @@

${escapeHtml(group.name)}

is_private: document.getElementById('isPrivate').checked, display_order: parseInt(document.getElementById('displayOrder').value) || 0, leader_user_ids: currentLeaders.map(l => l.user_id), + topics: currentTopics.map(t => ({ + name: t.name, + slug: t.slug, + description: t.description || undefined, + slack_channel_id: t.slack_channel_id || undefined + })), // Industry gathering-specific fields event_start_date: isGathering ? (document.getElementById('gatheringStartDate').value || null) : null, event_end_date: isGathering ? (document.getElementById('gatheringEndDate').value || null) : null, diff --git a/server/src/addie/mcp/meeting-tools.ts b/server/src/addie/mcp/meeting-tools.ts index ae3915d1db..6c3be4ab62 100644 --- a/server/src/addie/mcp/meeting-tools.ts +++ b/server/src/addie/mcp/meeting-tools.ts @@ -263,8 +263,12 @@ Example prompts this handles: }, invite_mode: { type: 'string', - enum: ['all_members', 'topic_subscribers', 'none'], - description: 'Who to invite: all_members (default - invite everyone in the working group), topic_subscribers (only those subscribed to the topics), or none (opt-in - create meeting but let people join themselves)', + enum: ['all_members', 'topic_subscribers', 'slack_channel', 'none'], + description: 'Who to invite: all_members (default - invite everyone in the working group), topic_subscribers (only those subscribed to the topics), slack_channel (invite members of a specific Slack channel - requires invite_slack_channel_id), or none (opt-in - create meeting but let people join themselves)', + }, + invite_slack_channel_id: { + type: 'string', + description: 'Slack channel ID to invite members from (required when invite_mode is slack_channel). Can be found in the channel URL or settings.', }, recurrence: { type: 'object', @@ -467,6 +471,42 @@ IMPORTANT: For start_time, provide the time in the user's timezone WITHOUT a Z s required: ['working_group_slug', 'topic_slugs'], }, }, + { + name: 'manage_committee_topics', + description: `Manage topics for a working group/committee. Topics help organize meetings and filter invitations. Each topic can optionally have its own Slack channel for subgroup discussions. Use action='list' to see current topics, action='add' to create a new topic, action='update' to modify an existing topic, or action='remove' to delete a topic.`, + usage_hints: 'use when someone wants to add a topic to a working group, update topic channels, or see what topics exist', + input_schema: { + type: 'object' as const, + properties: { + working_group_slug: { + type: 'string', + description: 'Working group slug (e.g., "technical", "governance")', + }, + action: { + type: 'string', + enum: ['list', 'add', 'update', 'remove'], + description: 'Action to perform: list (show topics), add (create new), update (modify), remove (delete)', + }, + topic_slug: { + type: 'string', + description: 'Topic slug for add/update/remove actions', + }, + topic_name: { + type: 'string', + description: 'Display name for the topic (required for add, optional for update)', + }, + topic_description: { + type: 'string', + description: 'Optional description of what this topic covers', + }, + slack_channel_id: { + type: 'string', + description: 'Optional Slack channel ID for this topic (e.g., C09HEERCY8P). Members of this channel can be invited to topic meetings.', + }, + }, + required: ['working_group_slug', 'action'], + }, + }, ]; /** @@ -660,7 +700,13 @@ export function createMeetingToolHandlers( } // One-time meeting - const inviteMode = input.invite_mode as 'all_members' | 'topic_subscribers' | 'none' | undefined; + const inviteMode = input.invite_mode as 'all_members' | 'topic_subscribers' | 'slack_channel' | 'none' | undefined; + const inviteSlackChannelId = input.invite_slack_channel_id as string | undefined; + + // Validate slack_channel mode has a channel ID + if (inviteMode === 'slack_channel' && !inviteSlackChannelId) { + return `❌ When using invite_mode='slack_channel', you must also provide invite_slack_channel_id.`; + } try { const result = await meetingService.scheduleMeeting({ @@ -674,6 +720,7 @@ export function createMeetingToolHandlers( timezone, createdByUserId: getUserId(), inviteMode, + inviteSlackChannelId, }); let response = `✅ Scheduled: **${title}**\n\n`; @@ -690,6 +737,8 @@ export function createMeetingToolHandlers( response += result.errors.map(e => `• ${e}`).join('\n'); } else if (inviteMode === 'none') { response += `\n📋 Meeting created as **opt-in** - no invites sent. Members can join using the Zoom link.`; + } else if (inviteMode === 'slack_channel') { + response += `\n📧 Calendar invites sent to Slack channel members.`; } else if (inviteMode === 'topic_subscribers') { response += `\n📧 Calendar invites sent to topic subscribers.`; } else { @@ -1211,5 +1260,119 @@ export function createMeetingToolHandlers( return `✅ Updated topic subscriptions for ${workingGroup.name}:\n${validTopics.map(t => `• ${t}`).join('\n')}\n\nYou'll receive meeting invites for these topics.`; }); + // Manage committee topics (add, update, remove, list) + handlers.set('manage_committee_topics', async (input) => { + const permCheck = await checkSchedulePermission(); + if (permCheck) return permCheck; + + const workingGroupSlug = input.working_group_slug as string; + const action = input.action as 'list' | 'add' | 'update' | 'remove'; + + const workingGroup = await workingGroupDb.getWorkingGroupBySlug(workingGroupSlug); + if (!workingGroup) { + return `❌ Working group not found: "${workingGroupSlug}"`; + } + + const currentTopics = workingGroup.topics || []; + + // List topics + if (action === 'list') { + if (currentTopics.length === 0) { + return `📋 **${workingGroup.name}** has no topics configured yet.\n\nUse action='add' to create a topic for organizing meetings and invitations.`; + } + + let response = `📋 **Topics for ${workingGroup.name}:**\n\n`; + for (const topic of currentTopics) { + response += `**${topic.name}** (\`${topic.slug}\`)\n`; + if (topic.description) { + response += ` ${topic.description}\n`; + } + if (topic.slack_channel_id) { + response += ` 📢 Slack channel: ${topic.slack_channel_id}\n`; + } + response += '\n'; + } + return response.trim(); + } + + // All other actions require topic_slug + const topicSlug = input.topic_slug as string | undefined; + if (!topicSlug) { + return `❌ topic_slug is required for action='${action}'`; + } + + // Validate topic slug format (lowercase letters, numbers, hyphens only) + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(topicSlug)) { + return `❌ Invalid topic slug "${topicSlug}". Slugs must be lowercase letters, numbers, and hyphens only (e.g., "my-topic-1").`; + } + + // Add topic + if (action === 'add') { + const topicName = input.topic_name as string | undefined; + if (!topicName) { + return `❌ topic_name is required when adding a new topic`; + } + + // Check for duplicate slug + if (currentTopics.some(t => t.slug === topicSlug)) { + return `❌ A topic with slug "${topicSlug}" already exists in ${workingGroup.name}`; + } + + const newTopic = { + slug: topicSlug, + name: topicName, + description: input.topic_description as string | undefined, + slack_channel_id: input.slack_channel_id as string | undefined, + }; + + const updatedTopics = [...currentTopics, newTopic]; + await workingGroupDb.updateWorkingGroup(workingGroup.id, { topics: updatedTopics }); + + let response = `✅ Added topic **${topicName}** (\`${topicSlug}\`) to ${workingGroup.name}`; + if (newTopic.slack_channel_id) { + response += `\n📢 Linked Slack channel: ${newTopic.slack_channel_id}`; + } + return response; + } + + // Find existing topic for update/remove + const existingIndex = currentTopics.findIndex(t => t.slug === topicSlug); + if (existingIndex === -1) { + return `❌ Topic "${topicSlug}" not found in ${workingGroup.name}`; + } + + // Update topic + if (action === 'update') { + const existing = currentTopics[existingIndex]; + const updatedTopic = { + ...existing, + name: (input.topic_name as string | undefined) || existing.name, + description: input.topic_description !== undefined ? (input.topic_description as string | undefined) : existing.description, + slack_channel_id: input.slack_channel_id !== undefined ? (input.slack_channel_id as string | undefined) : existing.slack_channel_id, + }; + + const updatedTopics = [...currentTopics]; + updatedTopics[existingIndex] = updatedTopic; + await workingGroupDb.updateWorkingGroup(workingGroup.id, { topics: updatedTopics }); + + let response = `✅ Updated topic **${updatedTopic.name}** (\`${topicSlug}\`) in ${workingGroup.name}`; + if (updatedTopic.slack_channel_id) { + response += `\n📢 Linked Slack channel: ${updatedTopic.slack_channel_id}`; + } + return response; + } + + // Remove topic + if (action === 'remove') { + const removedTopic = currentTopics[existingIndex]; + const updatedTopics = currentTopics.filter((_, i) => i !== existingIndex); + await workingGroupDb.updateWorkingGroup(workingGroup.id, { topics: updatedTopics }); + + return `✅ Removed topic **${removedTopic.name}** (\`${topicSlug}\`) from ${workingGroup.name}`; + } + + return `❌ Unknown action: ${action}`; + }); + return handlers; } diff --git a/server/src/db/meetings-db.ts b/server/src/db/meetings-db.ts index 3429826397..581b6bbe08 100644 --- a/server/src/db/meetings-db.ts +++ b/server/src/db/meetings-db.ts @@ -32,8 +32,8 @@ export class MeetingsDatabase { `INSERT INTO meeting_series ( working_group_id, title, description, topic_slugs, recurrence_rule, default_start_time, duration_minutes, - timezone, invite_mode, created_by_user_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + timezone, invite_mode, invite_slack_channel_id, created_by_user_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [ input.working_group_id, @@ -45,6 +45,7 @@ export class MeetingsDatabase { input.duration_minutes ?? 60, input.timezone ?? 'America/New_York', input.invite_mode ?? 'topic_subscribers', + input.invite_slack_channel_id || null, input.created_by_user_id || null, ] ); @@ -81,6 +82,7 @@ export class MeetingsDatabase { google_calendar_id: 'google_calendar_id', google_event_series_id: 'google_event_series_id', invite_mode: 'invite_mode', + invite_slack_channel_id: 'invite_slack_channel_id', status: 'status', }; @@ -742,4 +744,66 @@ export class MeetingsDatabase { [workingGroupId, topicSlug] ); } + + /** + * Add attendees from a Slack channel to a meeting. + * Looks up channel members in slack_user_mappings and adds those with WorkOS mappings. + * @param meetingId The meeting to add attendees to + * @param slackChannelMembers Array of Slack user IDs from the channel + * @returns Number of attendees added + */ + async addAttendeesFromSlackChannel( + meetingId: string, + slackChannelMembers: string[] + ): Promise { + if (slackChannelMembers.length === 0) { + return 0; + } + + // Look up Slack users who have WorkOS mappings and get their user info + const result = await query<{ + workos_user_id: string; + email: string; + name: string; + }>( + `SELECT + m.workos_user_id, + COALESCE(u.email, m.slack_email) as email, + COALESCE(u.first_name || ' ' || u.last_name, m.slack_real_name, m.slack_display_name) as name + FROM slack_user_mappings m + LEFT JOIN users u ON u.workos_user_id = m.workos_user_id + WHERE m.slack_user_id = ANY($1) + AND m.workos_user_id IS NOT NULL + AND m.slack_is_bot = false + AND m.slack_is_deleted = false`, + [slackChannelMembers] + ); + + if (result.rows.length === 0) { + return 0; + } + + // Bulk insert attendees + const values = result.rows.map((_, i) => { + const base = i * 4; + return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, 'pending', 'auto')`; + }).join(', '); + + const insertParams = result.rows.flatMap(m => [ + meetingId, + m.workos_user_id, + m.email, + m.name, + ]); + + const insertResult = await query( + `INSERT INTO meeting_attendees (meeting_id, workos_user_id, email, name, rsvp_status, invite_source) + VALUES ${values} + ON CONFLICT (meeting_id, workos_user_id) DO NOTHING`, + insertParams + ); + + // Return actual number of rows inserted (accounts for conflicts) + return insertResult.rowCount || 0; + } } diff --git a/server/src/db/migrations/175_topic_slack_channels.sql b/server/src/db/migrations/175_topic_slack_channels.sql new file mode 100644 index 0000000000..409f9a52ee --- /dev/null +++ b/server/src/db/migrations/175_topic_slack_channels.sql @@ -0,0 +1,24 @@ +-- Migration: 173_topic_slack_channels.sql +-- Allow topics to have their own Slack channels for targeted meeting invitations + +-- ===================================================== +-- MEETING SERIES: Add slack_channel invite mode +-- ===================================================== + +-- Drop and recreate the invite_mode constraint to include 'slack_channel' +ALTER TABLE meeting_series +DROP CONSTRAINT IF EXISTS meeting_series_invite_mode_check; + +ALTER TABLE meeting_series +ADD CONSTRAINT meeting_series_invite_mode_check +CHECK (invite_mode IN ('all_members', 'topic_subscribers', 'slack_channel', 'manual')); + +-- Add column for explicit Slack channel to invite from +ALTER TABLE meeting_series +ADD COLUMN IF NOT EXISTS invite_slack_channel_id VARCHAR(50); + +COMMENT ON COLUMN meeting_series.invite_slack_channel_id IS 'Slack channel ID to pull invitees from when invite_mode is slack_channel'; + +-- Note: working_groups.topics is JSONB, so adding slack_channel_id to topic objects +-- doesn't require a schema change - it's just an optional field in the JSON structure. +-- Topic structure: {slug, name, description?, slack_channel_id?} diff --git a/server/src/db/working-group-db.ts b/server/src/db/working-group-db.ts index ff86c68652..bc25a4b051 100644 --- a/server/src/db/working-group-db.ts +++ b/server/src/db/working-group-db.ts @@ -97,8 +97,8 @@ export class WorkingGroupDatabase { name, slug, description, slack_channel_url, slack_channel_id, is_private, status, display_order, committee_type, region, linked_event_id, event_start_date, event_end_date, event_location, auto_archive_after_event, - logo_url, website_url - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + logo_url, website_url, topics + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) RETURNING *`, [ input.name, @@ -118,6 +118,7 @@ export class WorkingGroupDatabase { input.auto_archive_after_event ?? true, input.logo_url || null, input.website_url || null, + input.topics ? JSON.stringify(input.topics) : '[]', ] ); @@ -201,6 +202,8 @@ export class WorkingGroupDatabase { for (const [key, value] of Object.entries(updates)) { // Handle leaders separately if (key === 'leader_user_ids') continue; + // Handle topics separately (JSONB) + if (key === 'topics') continue; const columnName = COLUMN_MAP[key]; if (!columnName) { @@ -210,6 +213,12 @@ export class WorkingGroupDatabase { params.push(value); } + // Handle topics (JSONB column) + if (updates.topics !== undefined) { + setClauses.push(`topics = $${paramIndex++}`); + params.push(JSON.stringify(updates.topics)); + } + // Update working group fields if any if (setClauses.length > 0) { params.push(id); diff --git a/server/src/routes/meetings.ts b/server/src/routes/meetings.ts index 970fc02a61..426d250aae 100644 --- a/server/src/routes/meetings.ts +++ b/server/src/routes/meetings.ts @@ -11,6 +11,7 @@ import { createLogger } from "../logger.js"; import { requireAuth, requireAdmin, optionalAuth } from "../middleware/auth.js"; import { MeetingsDatabase } from "../db/meetings-db.js"; import { WorkingGroupDatabase } from "../db/working-group-db.js"; +import { getChannelMembers } from "../slack/client.js"; import type { WorkingGroupTopic, MeetingStatus } from "../types.js"; // UUID validation helper @@ -121,6 +122,7 @@ export function createMeetingRouters(): { duration_minutes, timezone, invite_mode, + invite_slack_channel_id, } = req.body; if (!working_group_id || !title) { @@ -156,6 +158,7 @@ export function createMeetingRouters(): { duration_minutes, timezone, invite_mode, + invite_slack_channel_id, created_by_user_id: user.id, }); @@ -279,6 +282,8 @@ export function createMeetingRouters(): { end_time, timezone, status, + invite_mode, + invite_slack_channel_id, } = req.body; if (!working_group_id || !title || !start_time) { @@ -311,6 +316,14 @@ export function createMeetingRouters(): { }); } + // Validate slack_channel mode requires channel ID + if (invite_mode === 'slack_channel' && !invite_slack_channel_id) { + return res.status(400).json({ + error: 'Missing Slack channel ID', + message: 'invite_slack_channel_id is required when invite_mode is "slack_channel"', + }); + } + const meeting = await meetingsDb.createMeeting({ series_id, working_group_id, @@ -325,7 +338,21 @@ export function createMeetingRouters(): { created_by_user_id: user.id, }); - res.status(201).json(meeting); + // Handle invite mode + let invitedCount = 0; + if (invite_mode === 'slack_channel' && invite_slack_channel_id) { + const channelMembers = await getChannelMembers(invite_slack_channel_id); + invitedCount = await meetingsDb.addAttendeesFromSlackChannel(meeting.id, channelMembers); + logger.info({ meetingId: meeting.id, invitedCount, invite_mode, channelId: invite_slack_channel_id }, 'Invited Slack channel members'); + } else if (invite_mode === 'all_members') { + invitedCount = await meetingsDb.addAttendeesFromGroup(meeting.id, working_group_id); + logger.info({ meetingId: meeting.id, invitedCount, invite_mode }, 'Invited all group members'); + } else if (invite_mode === 'topic_subscribers' && topic_slugs?.length > 0) { + invitedCount = await meetingsDb.addAttendeesFromGroup(meeting.id, working_group_id, topic_slugs); + logger.info({ meetingId: meeting.id, invitedCount, invite_mode }, 'Invited topic subscribers'); + } + + res.status(201).json({ ...meeting, invited_count: invitedCount }); } catch (error) { logger.error({ err: error }, 'Create meeting error'); res.status(500).json({ @@ -477,6 +504,66 @@ export function createMeetingRouters(): { } }); + // POST /api/admin/meetings/:id/invite-channel - Invite Slack channel members + adminApiRouter.post('/:id/invite-channel', requireAuth, requireAdmin, async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { slack_channel_id } = req.body; + + if (!isValidUuid(id)) { + return res.status(400).json({ + error: 'Invalid meeting ID', + message: 'Meeting ID must be a valid UUID', + }); + } + + if (!slack_channel_id) { + return res.status(400).json({ + error: 'Missing required field', + message: 'slack_channel_id is required', + }); + } + + const meeting = await meetingsDb.getMeetingById(id); + if (!meeting) { + return res.status(404).json({ + error: 'Meeting not found', + message: 'No meeting found with the specified ID', + }); + } + + // Validate that the Slack channel is associated with the working group + const workingGroup = await workingGroupDb.getWorkingGroupById(meeting.working_group_id); + if (workingGroup) { + const validChannels = [ + workingGroup.slack_channel_id, + ...(workingGroup.topics?.map(t => t.slack_channel_id) || []), + ].filter(Boolean); + + if (!validChannels.includes(slack_channel_id)) { + return res.status(400).json({ + error: 'Invalid Slack channel', + message: 'The specified Slack channel is not associated with this working group', + }); + } + } + + // Get channel members from Slack + const channelMembers = await getChannelMembers(slack_channel_id); + + // Add attendees from the Slack channel + const count = await meetingsDb.addAttendeesFromSlackChannel(id, channelMembers); + + res.json({ success: true, invited_count: count, channel_member_count: channelMembers.length }); + } catch (error) { + logger.error({ err: error }, 'Invite Slack channel to meeting error'); + res.status(500).json({ + error: 'Failed to invite channel members', + message: 'An internal error occurred', + }); + } + }); + // POST /api/admin/meetings/:id/attendees - Add attendee manually adminApiRouter.post('/:id/attendees', requireAuth, requireAdmin, async (req: Request, res: Response) => { try { diff --git a/server/src/services/meeting-service.ts b/server/src/services/meeting-service.ts index 71cd02af07..63e50cb58c 100644 --- a/server/src/services/meeting-service.ts +++ b/server/src/services/meeting-service.ts @@ -17,6 +17,7 @@ import { notifyMeetingStarted, notifyMeetingEnded, } from '../notifications/slack.js'; +import { getChannelMembers } from '../slack/client.js'; import type { CreateMeetingInput, UpdateMeetingInput, @@ -49,8 +50,10 @@ export interface ScheduleMeetingOptions { createZoomMeeting?: boolean; sendCalendarInvites?: boolean; announceInSlack?: boolean; - // Control who gets invited: 'all_members', 'topic_subscribers', or 'none' (opt-in) - inviteMode?: 'all_members' | 'topic_subscribers' | 'none'; + // Control who gets invited: 'all_members', 'topic_subscribers', 'slack_channel', or 'none' (opt-in) + inviteMode?: 'all_members' | 'topic_subscribers' | 'slack_channel' | 'none'; + // Slack channel ID for invite_mode='slack_channel' + inviteSlackChannelId?: string; } export interface ScheduleMeetingResult { @@ -137,11 +140,21 @@ export async function scheduleMeeting(options: ScheduleMeetingOptions): Promise< meeting.zoom_passcode = zoomMeeting.password; } - // Invite working group members to the meeting based on inviteMode + // Invite members to the meeting based on inviteMode let invitedCount = 0; const inviteMode = options.inviteMode || 'all_members'; - if (inviteMode !== 'none') { + if (inviteMode === 'slack_channel' && options.inviteSlackChannelId) { + // Invite members from a Slack channel + try { + const channelMembers = await getChannelMembers(options.inviteSlackChannelId); + invitedCount = await meetingsDb.addAttendeesFromSlackChannel(meeting.id, channelMembers); + logger.info({ meetingId: meeting.id, invitedCount, inviteMode, channelId: options.inviteSlackChannelId }, 'Invited Slack channel members to meeting'); + } catch (error) { + logger.error({ error, channelId: options.inviteSlackChannelId, meetingId: meeting.id }, 'Failed to fetch Slack channel members'); + errors.push(`Failed to invite Slack channel members: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } else if (inviteMode !== 'none') { // 'all_members' passes undefined topicSlugs, 'topic_subscribers' passes the actual topics const topicFilter = inviteMode === 'topic_subscribers' ? options.topicSlugs : undefined; invitedCount = await meetingsDb.addAttendeesFromGroup( diff --git a/server/src/types.ts b/server/src/types.ts index 54a53ea43f..9df7856b23 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -372,6 +372,7 @@ export interface WorkingGroupTopic { slug: string; name: string; description?: string; + slack_channel_id?: string; } export interface WorkingGroup { @@ -433,6 +434,7 @@ export interface CreateWorkingGroupInput { display_order?: number; committee_type?: CommitteeType; region?: string; + topics?: WorkingGroupTopic[]; // Industry gathering fields linked_event_id?: string; event_start_date?: Date; @@ -454,6 +456,7 @@ export interface UpdateWorkingGroupInput { display_order?: number; committee_type?: CommitteeType; region?: string; + topics?: WorkingGroupTopic[]; // Industry gathering fields linked_event_id?: string; event_start_date?: Date; @@ -857,7 +860,7 @@ export interface EventSponsorDisplay { export type MeetingStatus = 'draft' | 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; export type MeetingSeriesStatus = 'active' | 'paused' | 'archived'; -export type MeetingInviteMode = 'all_members' | 'topic_subscribers' | 'manual'; +export type MeetingInviteMode = 'all_members' | 'topic_subscribers' | 'slack_channel' | 'manual'; export type RsvpStatus = 'pending' | 'accepted' | 'declined' | 'tentative'; export type MeetingInviteSource = 'auto' | 'manual' | 'request'; @@ -891,6 +894,7 @@ export interface MeetingSeries { google_calendar_id?: string; google_event_series_id?: string; invite_mode: MeetingInviteMode; + invite_slack_channel_id?: string; status: MeetingSeriesStatus; created_by_user_id?: string; created_at: Date; @@ -907,6 +911,7 @@ export interface CreateMeetingSeriesInput { duration_minutes?: number; timezone?: string; invite_mode?: MeetingInviteMode; + invite_slack_channel_id?: string; created_by_user_id?: string; } @@ -924,6 +929,7 @@ export interface UpdateMeetingSeriesInput { google_calendar_id?: string; google_event_series_id?: string; invite_mode?: MeetingInviteMode; + invite_slack_channel_id?: string; status?: MeetingSeriesStatus; } From 6716a07e3ca823da1f8249de6189ee8a7115c294 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 17 Jan 2026 07:37:58 -0500 Subject: [PATCH 34/58] fix: Simplify propose_content tool and add allocation perspective (#799) * feat: Add 'Agentic Advertising is for Allocation' perspective Adds Brian O'Kelley's article as a link-type perspective with the quote "OpenRTB is a protocol for day trading; AdCP is a protocol for investing" from Benjamin Masse. Co-Authored-By: Claude Opus 4.5 * fix: Handle URL-encoded interactive payloads in Addie /events endpoint Slack sends interactive component payloads (button clicks, modal submissions) as URL-encoded form data (payload={json}), not raw JSON. The /events endpoint was only parsing JSON, causing "Unexpected token 'p'" errors when interactive payloads arrived. Now checks if body starts with "payload=" and decodes it appropriately. Co-Authored-By: Claude Opus 4.5 * feat: Add logging for Addie tool errors Previously, when tools returned error strings (like "Failed to create content: ...") or threw exceptions, these weren't logged. Now we log: - ERROR level: when tools throw exceptions - WARN level: when tools return error-like results This will help debug issues like the content submission failure. Co-Authored-By: Claude Opus 4.5 * fix: Simplify propose_content tool schema The tool schema had a confusing nested 'collection' object with type: 'personal' | 'committee'. This was misleading because: 1. There's no "personal" collection - migration 153 moved all personal content to the 'editorial' working group 2. All content goes to a working group - the only question is which one 3. The 'personal' option was just syntactic sugar for 'editorial' Changes: - Replace nested collection object with simple committee_slug parameter - Default to 'editorial' (site-wide Perspectives section) if not specified - Maintain backward compatibility with legacy collection format - Add validation for legacy format to prevent silent wrong-collection errors - Update description to accurately describe the content system The content system: - 'editorial' committee = site-wide Perspectives section (accepts public submissions) - Other committees = working groups, councils, chapters (may require membership) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/smart-pants-admire.md | 2 + server/src/addie/claude-client.ts | 11 ++++- server/src/addie/mcp/member-tools.ts | 46 +++++++++---------- .../176_add_allocation_perspective.sql | 38 +++++++++++++++ server/src/routes/slack.ts | 20 +++++++- 5 files changed, 92 insertions(+), 25 deletions(-) create mode 100644 .changeset/smart-pants-admire.md create mode 100644 server/src/db/migrations/176_add_allocation_perspective.sql diff --git a/.changeset/smart-pants-admire.md b/.changeset/smart-pants-admire.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/smart-pants-admire.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/addie/claude-client.ts b/server/src/addie/claude-client.ts index 9abe3ea7f8..e3350f76d2 100644 --- a/server/src/addie/claude-client.ts +++ b/server/src/addie/claude-client.ts @@ -694,13 +694,21 @@ export class AddieClaudeClient { } } else { // Regular text result + // Log if the result indicates an error (tool returned error string rather than throwing) + const looksLikeError = result.startsWith('Error:') || + result.startsWith('Failed to') || + result.includes('not found') || + result.includes('need to be logged in'); + if (looksLikeError) { + logger.warn({ toolName, toolInput, result: result.substring(0, 500), durationMs }, 'Addie: Tool returned error result'); + } toolResults.push({ tool_use_id: toolUseId, content: result }); toolExecutions.push({ tool_name: toolName, parameters: toolInput, result, result_summary: this.summarizeToolResult(toolName, result), - is_error: false, + is_error: looksLikeError, duration_ms: durationMs, sequence: executionSequence, }); @@ -708,6 +716,7 @@ export class AddieClaudeClient { } catch (error) { const durationMs = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ toolName, toolInput, error: errorMessage, durationMs }, 'Addie: Tool threw exception'); toolResults.push({ tool_use_id: toolUseId, content: `Error: ${errorMessage}`, diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index c3113feabf..e357506d37 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -342,7 +342,7 @@ export const MEMBER_TOOLS: AddieTool[] = [ { name: 'propose_content', description: - 'Create content for the website (perspectives, committee posts). Content can be for personal perspectives or committee collections. Committee leads and admins can publish directly; others submit for review. Supports co-authors.', + 'Create content for the website. Content is published to a committee (working group, council, or chapter). Default is "editorial" which is the site-wide Perspectives section. Committee leads and admins can publish directly; others submit for review.', usage_hints: 'use for "write a perspective", "post to the sustainability group", "create an article", "share my thoughts on X"', input_schema: { type: 'object', @@ -352,15 +352,11 @@ export const MEMBER_TOOLS: AddieTool[] = [ content_type: { type: 'string', enum: ['article', 'link'], description: 'Type (default: article)' }, external_url: { type: 'string', description: 'URL for link type' }, excerpt: { type: 'string', description: 'Short excerpt/summary' }, - category: { type: 'string', description: 'Category' }, - collection: { - type: 'object', description: 'Where to publish', - properties: { type: { type: 'string', enum: ['personal', 'committee'], description: 'Collection type' }, committee_slug: { type: 'string', description: 'Committee slug' } }, - required: ['type'], - }, + category: { type: 'string', description: 'Category (e.g., Op-Ed, Interview, Ecosystem)' }, + committee_slug: { type: 'string', description: 'Target committee slug (default: editorial for Perspectives). Use list_working_groups to see options.' }, co_author_emails: { type: 'array', items: { type: 'string' }, description: 'Co-author emails' }, }, - required: ['title', 'collection'], + required: ['title'], }, }, { @@ -1304,13 +1300,20 @@ export function createMemberToolHandlers( const externalUrl = input.external_url as string | undefined; const excerpt = input.excerpt as string | undefined; const category = input.category as string | undefined; - const inputCollection = input.collection as { type: string; committee_slug?: string } | undefined; const coAuthorEmails = input.co_author_emails as string[] | undefined; - // Default to editorial collection for personal/perspectives content - const collection = inputCollection?.type === 'personal' || !inputCollection - ? { type: 'committee', committee_slug: 'editorial' } - : inputCollection; + // Support both new format (committee_slug) and legacy format (collection.committee_slug) + const legacyCollection = input.collection as { type?: string; committee_slug?: string } | undefined; + + // Validate legacy format: if type='committee', require committee_slug + if (legacyCollection?.type === 'committee' && !legacyCollection.committee_slug) { + return 'committee_slug is required when using collection.type="committee". Specify the committee or omit collection to default to editorial (Perspectives).'; + } + + const committeeSlug = (input.committee_slug as string) || + legacyCollection?.committee_slug || + (legacyCollection?.type === 'personal' ? 'editorial' : null) || + 'editorial'; // Validate requirements if (contentType === 'article' && !contentBody) { @@ -1319,9 +1322,6 @@ export function createMemberToolHandlers( if (contentType === 'link' && !externalUrl) { return 'A URL is required for link type content. Please provide the external_url.'; } - if (collection.type === 'committee' && !collection.committee_slug) { - return 'committee_slug is required when targeting a committee collection.'; - } // Call the content service directly (bypasses HTTP auth) // Dynamic import to avoid pulling in auth.ts at module load time @@ -1338,13 +1338,13 @@ export function createMemberToolHandlers( external_url: externalUrl, excerpt, category, - collection: { committee_slug: collection.committee_slug }, + collection: { committee_slug: committeeSlug }, } ); if (!result.success) { if (result.error?.includes('No collection found')) { - return `Committee "${collection.committee_slug}" not found. Use list_working_groups to see available committees.`; + return `Committee "${committeeSlug}" not found. Use list_working_groups to see available committees.`; } return `Failed to create content: ${result.error}`; } @@ -1353,21 +1353,21 @@ export function createMemberToolHandlers( response += `**Title:** ${title}\n`; response += `**Status:** ${result.status === 'published' ? '✅ Published' : '⏳ Pending Review'}\n`; - if (collection.committee_slug === 'editorial') { + if (committeeSlug === 'editorial') { response += `**Collection:** Perspectives\n`; } else { - response += `**Collection:** ${collection.committee_slug}\n`; + response += `**Collection:** ${committeeSlug}\n`; } if (result.status === 'published') { - if (collection.committee_slug === 'editorial') { + if (committeeSlug === 'editorial') { response += `\n**View:** https://agenticadvertising.org/latest/perspectives\n`; response += `_Your perspective is now live in The Latest > Perspectives section._\n`; } else { - response += `\n**View:** https://agenticadvertising.org/committees/${collection.committee_slug}\n`; + response += `\n**View:** https://agenticadvertising.org/committees/${committeeSlug}\n`; } } else { - if (collection.committee_slug === 'editorial') { + if (committeeSlug === 'editorial') { response += `\n_Your perspective has been submitted for review. Once approved, it will appear in The Latest > Perspectives section._\n`; } else { response += `\n_Your content has been submitted for review. A committee lead will review it and you'll be notified when it's approved._\n`; diff --git a/server/src/db/migrations/176_add_allocation_perspective.sql b/server/src/db/migrations/176_add_allocation_perspective.sql new file mode 100644 index 0000000000..9f1631ad21 --- /dev/null +++ b/server/src/db/migrations/176_add_allocation_perspective.sql @@ -0,0 +1,38 @@ +-- Migration: 176_add_allocation_perspective.sql +-- Adds "Agentic Advertising is for Allocation" perspective by Brian O'Kelley + +INSERT INTO perspectives ( + slug, + content_type, + title, + subtitle, + category, + excerpt, + external_url, + external_site_name, + author_name, + status, + published_at, + display_order, + tags +) VALUES ( + 'agentic-advertising-is-for-allocation', + 'link', + 'Agentic Advertising is for Allocation', + '"OpenRTB is a protocol for day trading; AdCP is a protocol for investing" - Benjamin Masse', + 'Op-Ed', + 'Programmatic advertising emerged to solve a specific challenge: determining which ad network would pay the most for a given impression. Yet premium publishers and brand advertisers face fundamentally different questions—ones more aligned with capacity-constrained markets like hotels or airlines. How should limited inventory or budget be allocated to maximize returns?', + 'https://bokonads.com/p/agentic-advertising-is-for-allocation', + 'BOK on Ads', + 'Brian O''Kelley', + 'published', + '2026-01-11 00:00:00+00', + 0, + ARRAY['op-ed', 'allocation', 'AdCP', 'OpenRTB', 'thought-leadership'] +) ON CONFLICT (slug) DO NOTHING; + +-- Update display_order for existing perspectives to shift them down +UPDATE perspectives +SET display_order = display_order + 1 +WHERE slug != 'agentic-advertising-is-for-allocation' + AND display_order >= 0; diff --git a/server/src/routes/slack.ts b/server/src/routes/slack.ts index e789b69479..0c17a21464 100644 --- a/server/src/routes/slack.ts +++ b/server/src/routes/slack.ts @@ -122,13 +122,31 @@ export function createSlackRouter(addieBoltRouter?: Router | null): { aaobotRout // Handle URL verification separately (before Bolt) // URL verification requests don't have signatures, so we need to handle them first // We use a custom parser that peeks at the body without consuming it for non-verification requests + // Also handles interactive payloads which come as URL-encoded form data addieRouter.post('/events', (req, res, next) => { let body = ''; req.setEncoding('utf8'); req.on('data', (chunk: string) => { body += chunk; }); req.on('end', () => { try { - const parsed = JSON.parse(body); + let parsed: Record; + + // Check if this is URL-encoded form data (interactive payloads) + // Slack sends interactive component payloads as: payload={json} + if (body.startsWith('payload=')) { + const params = new URLSearchParams(body); + const payloadStr = params.get('payload'); + if (!payloadStr) { + logger.warn('Addie: Empty payload in form data'); + return res.status(400).json({ error: 'Empty payload' }); + } + parsed = JSON.parse(payloadStr); + logger.debug({ type: (parsed as Record).type }, 'Addie: Parsed interactive payload'); + } else { + // Regular JSON body (events, URL verification) + parsed = JSON.parse(body); + } + if (parsed.type === 'url_verification') { logger.info('Addie: Handling URL verification challenge'); return res.json({ challenge: parsed.challenge }); From 1da11c3fa76a5828369955144a8acb74af57b899 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 18 Jan 2026 11:12:39 -0500 Subject: [PATCH 35/58] fix: Add publisher to member offerings and extract profile routes (#796) (#803) - Add 'publisher' to VALID_MEMBER_OFFERINGS to match MemberOffering type - Create centralized VALID_* constants for runtime validation to prevent type drift: - VALID_MEMBER_OFFERINGS, VALID_ORGANIZATION_ROLES, VALID_ASSIGNABLE_ROLES - VALID_LEGAL_DOCUMENT_TYPES, VALID_REVENUE_TIERS - Extract member profile routes from http.ts to routes/member-profiles.ts (~530 lines) - Add 'trialing' to subscription status types for trial user support - Fix slug validation to properly check reserved keywords Closes #796 Co-authored-by: Claude Opus 4.5 --- .changeset/tender-trees-cut.md | 2 + server/src/billing/stripe-client.ts | 4 +- server/src/db/organization-db.ts | 14 +- server/src/http.ts | 668 +------------------------- server/src/routes/admin/prospects.ts | 6 +- server/src/routes/billing-public.ts | 21 +- server/src/routes/member-profiles.ts | 694 +++++++++++++++++++++++++++ server/src/routes/organizations.ts | 28 +- server/src/types.ts | 46 ++ 9 files changed, 804 insertions(+), 679 deletions(-) create mode 100644 .changeset/tender-trees-cut.md create mode 100644 server/src/routes/member-profiles.ts diff --git a/.changeset/tender-trees-cut.md b/.changeset/tender-trees-cut.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/tender-trees-cut.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/billing/stripe-client.ts b/server/src/billing/stripe-client.ts index 9271a3af1a..246b92e6f2 100644 --- a/server/src/billing/stripe-client.ts +++ b/server/src/billing/stripe-client.ts @@ -303,7 +303,7 @@ export async function getPriceByLookupKey(lookupKey: string): Promise { try { const { agreement_type, version, effective_date, text } = req.body; - const validTypes = ['terms_of_service', 'privacy_policy', 'membership', 'bylaws', 'ip_policy']; + const validTypes = VALID_LEGAL_DOCUMENT_TYPES; if (!agreement_type || !version || !effective_date || !text) { return res.status(400).json({ @@ -2817,7 +2832,7 @@ export class HTTPServer { try { const { id } = req.params; const { agreement_type, version, effective_date, text } = req.body; - const validTypes = ['terms_of_service', 'privacy_policy', 'membership', 'bylaws', 'ip_policy']; + const validTypes = VALID_LEGAL_DOCUMENT_TYPES; if (!agreement_type || !version || !effective_date || !text) { return res.status(400).json({ @@ -2871,7 +2886,7 @@ export class HTTPServer { }); } - const validTypes = ['terms_of_service', 'privacy_policy', 'membership', 'bylaws', 'ip_policy']; + const validTypes = VALID_LEGAL_DOCUMENT_TYPES; if (!validTypes.includes(agreement_type)) { return res.status(400).json({ error: 'Invalid agreement type', @@ -4610,7 +4625,7 @@ Disallow: /api/admin/ }); } - const validTypes = ['terms_of_service', 'privacy_policy', 'membership', 'bylaws', 'ip_policy']; + const validTypes = VALID_LEGAL_DOCUMENT_TYPES; if (!validTypes.includes(agreement_type)) { return res.status(400).json({ error: 'Invalid agreement type', @@ -5113,12 +5128,11 @@ Disallow: /api/admin/ this.app.get('/api/agreement/current', async (req, res) => { try { const type = (req.query.type as string) || 'membership'; - const validTypes = ['terms_of_service', 'privacy_policy', 'membership', 'bylaws', 'ip_policy']; - if (!validTypes.includes(type)) { + if (!VALID_LEGAL_DOCUMENT_TYPES.includes(type as any)) { return res.status(400).json({ error: 'Invalid agreement type', - message: 'Type must be: terms_of_service, privacy_policy, membership, bylaws, or ip_policy' + message: `Type must be one of: ${VALID_LEGAL_DOCUMENT_TYPES.join(', ')}` }); } @@ -5152,7 +5166,6 @@ Disallow: /api/admin/ const type = req.query.type as string; const version = req.query.version as string; const format = req.query.format as string; // 'json' or 'html' (default: html) - const validTypes = ['terms_of_service', 'privacy_policy', 'membership', 'bylaws', 'ip_policy']; if (!type) { return res.status(400).json({ @@ -5161,10 +5174,10 @@ Disallow: /api/admin/ }); } - if (!validTypes.includes(type)) { + if (!VALID_LEGAL_DOCUMENT_TYPES.includes(type as any)) { return res.status(400).json({ error: 'Invalid agreement type', - message: 'Type must be: terms_of_service, privacy_policy, membership, bylaws, or ip_policy' + message: `Type must be one of: ${VALID_LEGAL_DOCUMENT_TYPES.join(', ')}` }); } @@ -5841,638 +5854,9 @@ Disallow: /api/admin/ } }); - // GET /api/me/member-profile - Get current user's organization's member profile - // Supports ?org=org_id query parameter to specify which organization - this.app.get('/api/me/member-profile', requireAuth, async (req, res) => { - const startTime = Date.now(); - logger.info({ userId: req.user?.id, org: req.query.org }, 'GET /api/me/member-profile started'); - try { - const user = req.user!; - const requestedOrgId = req.query.org as string | undefined; - - // Dev mode: handle dev organizations without WorkOS - const isDevUserProfile = isDevModeEnabled() && Object.values(DEV_USERS).some(du => du.id === user.id) && requestedOrgId?.startsWith('org_dev_'); - if (isDevUserProfile) { - const localOrg = await orgDb.getOrganization(requestedOrgId!); - if (!localOrg) { - return res.status(404).json({ - error: 'Organization not found', - message: 'The requested organization does not exist', - }); - } - const profile = await memberDb.getProfileByOrgId(requestedOrgId!); - logger.info({ userId: user.id, orgId: requestedOrgId, hasProfile: !!profile, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile completed (dev mode)'); - return res.json({ - profile: profile || null, - organization_id: requestedOrgId, - organization_name: localOrg.name, - }); - } - - // Get user's organization memberships - const memberships = await workos!.userManagement.listOrganizationMemberships({ - userId: user.id, - }); - - if (memberships.data.length === 0) { - logger.info({ userId: user.id, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile: no organization'); - return res.status(404).json({ - error: 'No organization', - message: 'User is not a member of any organization', - }); - } - - // Determine which org to use - let targetOrgId: string; - if (requestedOrgId) { - // Verify user is a member of the requested org - const isMember = memberships.data.some(m => m.organizationId === requestedOrgId); - if (!isMember) { - logger.info({ userId: user.id, requestedOrgId, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile: not authorized'); - return res.status(403).json({ - error: 'Not authorized', - message: 'User is not a member of the requested organization', - }); - } - targetOrgId = requestedOrgId; - } else { - // Default to first org - targetOrgId = memberships.data[0].organizationId; - } - - const profile = await memberDb.getProfileByOrgId(targetOrgId); - - // Get org name from WorkOS - const org = await workos!.organizations.getOrganization(targetOrgId); - - logger.info({ userId: user.id, orgId: targetOrgId, hasProfile: !!profile, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile completed'); - res.json({ - profile: profile || null, - organization_id: targetOrgId, - organization_name: org.name, - }); - } catch (error) { - logger.error({ err: error, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile error'); - res.status(500).json({ - error: 'Failed to get member profile', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // POST /api/me/member-profile - Create member profile for current user's organization - // Supports ?org=org_id query parameter to specify which organization - this.app.post('/api/me/member-profile', requireAuth, async (req, res) => { - const startTime = Date.now(); - logger.info({ userId: req.user?.id, org: req.query.org }, 'POST /api/me/member-profile started'); - try { - const user = req.user!; - const requestedOrgId = req.query.org as string | undefined; - const { - display_name, - slug, - tagline, - description, - logo_url, - logo_light_url, - logo_dark_url, - brand_color, - contact_email, - contact_website, - contact_phone, - linkedin_url, - twitter_url, - offerings, - agents, - headquarters, - markets, - tags, - is_public, - show_in_carousel, - } = req.body; - - // Validate required fields - if (!display_name || !slug) { - return res.status(400).json({ - error: 'Missing required fields', - message: 'display_name and slug are required', - }); - } - - // Validate slug format and reserved words - if (!isValidSlug(slug)) { - return res.status(400).json({ - error: 'Invalid slug', - message: 'Slug must contain only lowercase letters, numbers, and hyphens, cannot start or end with a hyphen, and cannot be a reserved keyword (admin, api, auth, dashboard, members, registry, onboarding)', - }); - } - - // Dev mode: handle dev organizations without WorkOS - const isDevUserProfile = isDevModeEnabled() && Object.values(DEV_USERS).some(du => du.id === user.id) && requestedOrgId?.startsWith('org_dev_'); - - let targetOrgId: string; - - if (isDevUserProfile) { - // Dev mode: use the requested dev org directly - const localOrg = await orgDb.getOrganization(requestedOrgId!); - if (!localOrg) { - return res.status(404).json({ - error: 'Organization not found', - message: 'The requested organization does not exist', - }); - } - targetOrgId = requestedOrgId!; - logger.info({ userId: user.id, orgId: targetOrgId }, 'POST /api/me/member-profile: dev mode bypass'); - } else { - // Normal mode: check WorkOS memberships - const memberships = await workos!.userManagement.listOrganizationMemberships({ - userId: user.id, - }); - - if (memberships.data.length === 0) { - return res.status(400).json({ - error: 'No organization', - message: 'User must be a member of an organization to create a profile', - }); - } - - if (requestedOrgId) { - // Verify user is a member of the requested org - const isMember = memberships.data.some(m => m.organizationId === requestedOrgId); - if (!isMember) { - return res.status(403).json({ - error: 'Not authorized', - message: 'User is not a member of the requested organization', - }); - } - targetOrgId = requestedOrgId; - } else { - // Default to first org - targetOrgId = memberships.data[0].organizationId; - } - } - - // Ensure organization exists in local DB (on-demand sync from WorkOS, skip for dev mode) - let org = await orgDb.getOrganization(targetOrgId); - if (!org && !isDevUserProfile) { - try { - const workosOrg = await workos!.organizations.getOrganization(targetOrgId); - if (workosOrg) { - org = await orgDb.createOrganization({ - workos_organization_id: workosOrg.id, - name: workosOrg.name, - }); - logger.info({ orgId: targetOrgId, name: workosOrg.name }, 'On-demand synced organization from WorkOS for member profile'); - } - } catch (syncError) { - logger.warn({ orgId: targetOrgId, err: syncError }, 'Failed to sync organization from WorkOS'); - } - } - - if (!org) { - return res.status(404).json({ - error: 'Organization not found', - message: 'Organization does not exist. Please contact support.', - }); - } - - // Check if profile already exists for this org - const existingProfile = await memberDb.getProfileByOrgId(targetOrgId); - if (existingProfile) { - return res.status(409).json({ - error: 'Profile already exists', - message: 'Organization already has a member profile. Use PUT to update.', - }); - } - - // Check slug availability - const slugAvailable = await memberDb.isSlugAvailable(slug); - if (!slugAvailable) { - return res.status(409).json({ - error: 'Slug not available', - message: 'This slug is already taken. Please choose a different one.', - }); - } - - // Validate offerings if provided - const validOfferings = ['buyer_agent', 'sales_agent', 'creative_agent', 'signals_agent', 'consulting', 'other']; - if (offerings && Array.isArray(offerings)) { - const invalidOfferings = offerings.filter((o: string) => !validOfferings.includes(o)); - if (invalidOfferings.length > 0) { - return res.status(400).json({ - error: 'Invalid offerings', - message: `Invalid offerings: ${invalidOfferings.join(', ')}. Valid options: ${validOfferings.join(', ')}`, - }); - } - } - - const profile = await memberDb.createProfile({ - workos_organization_id: targetOrgId, - display_name, - slug, - tagline, - description, - logo_url, - logo_light_url, - logo_dark_url, - brand_color, - contact_email, - contact_website, - contact_phone, - linkedin_url, - twitter_url, - offerings: offerings || [], - agents: agents || [], - headquarters, - markets: markets || [], - tags: tags || [], - is_public: is_public ?? false, - show_in_carousel: show_in_carousel ?? false, - }); - - // Invalidate Addie's member context cache - organization profile created - invalidateMemberContextCache(); - - logger.info({ profileId: profile.id, orgId: targetOrgId, slug, durationMs: Date.now() - startTime }, 'POST /api/me/member-profile completed'); - - res.status(201).json({ profile }); - } catch (error) { - logger.error({ err: error, durationMs: Date.now() - startTime }, 'POST /api/me/member-profile error'); - res.status(500).json({ - error: 'Failed to create member profile', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // PUT /api/me/member-profile - Update current user's organization's member profile - // Supports ?org=org_id query parameter to specify which organization - this.app.put('/api/me/member-profile', requireAuth, async (req, res) => { - const startTime = Date.now(); - logger.info({ userId: req.user?.id }, 'PUT /api/me/member-profile started'); - try { - const user = req.user!; - const requestedOrgId = req.query.org as string | undefined; - const updates = req.body; - - // Dev mode: handle dev organizations without WorkOS - const isDevUserProfile = isDevModeEnabled() && Object.values(DEV_USERS).some(du => du.id === user.id) && requestedOrgId?.startsWith('org_dev_'); - - let targetOrgId: string; - - if (isDevUserProfile) { - // Dev mode: use the requested dev org directly - const localOrg = await orgDb.getOrganization(requestedOrgId!); - if (!localOrg) { - return res.status(404).json({ - error: 'Organization not found', - message: 'The requested organization does not exist', - }); - } - targetOrgId = requestedOrgId!; - logger.info({ userId: user.id, orgId: targetOrgId }, 'PUT /api/me/member-profile: dev mode bypass'); - } else { - // Normal mode: check WorkOS memberships - const memberships = await workos!.userManagement.listOrganizationMemberships({ - userId: user.id, - }); - - if (memberships.data.length === 0) { - return res.status(400).json({ - error: 'No organization', - message: 'User must be a member of an organization to update a profile', - }); - } - - if (requestedOrgId) { - const isMember = memberships.data.some(m => m.organizationId === requestedOrgId); - if (!isMember) { - return res.status(403).json({ - error: 'Not authorized', - message: 'User is not a member of the requested organization', - }); - } - targetOrgId = requestedOrgId; - } else { - targetOrgId = memberships.data[0].organizationId; - } - } - - // Check if profile exists - const existingProfile = await memberDb.getProfileByOrgId(targetOrgId); - if (!existingProfile) { - return res.status(404).json({ - error: 'Profile not found', - message: 'No member profile exists for your organization. Use POST to create one.', - }); - } - - // Validate offerings if provided - const validOfferings = ['buyer_agent', 'sales_agent', 'creative_agent', 'signals_agent', 'consulting', 'other']; - if (updates.offerings && Array.isArray(updates.offerings)) { - const invalidOfferings = updates.offerings.filter((o: string) => !validOfferings.includes(o)); - if (invalidOfferings.length > 0) { - return res.status(400).json({ - error: 'Invalid offerings', - message: `Invalid offerings: ${invalidOfferings.join(', ')}. Valid options: ${validOfferings.join(', ')}`, - }); - } - } - - // Remove fields that shouldn't be updated directly - delete updates.id; - delete updates.workos_organization_id; - delete updates.slug; // Slug changes not allowed via this endpoint - delete updates.created_at; - delete updates.updated_at; - delete updates.featured; // Only admins can set featured - - const profile = await memberDb.updateProfileByOrgId(targetOrgId, updates); - - // Invalidate Addie's member context cache - organization profile updated - invalidateMemberContextCache(); - - const duration = Date.now() - startTime; - logger.info({ profileId: profile?.id, orgId: targetOrgId, durationMs: duration }, 'Member profile updated'); - - res.json({ profile }); - } catch (error) { - const duration = Date.now() - startTime; - logger.error({ err: error, durationMs: duration }, 'Update member profile error'); - res.status(500).json({ - error: 'Failed to update member profile', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // PUT /api/me/member-profile/visibility - Update visibility only (with subscription check) - // Supports ?org=org_id query parameter to specify which organization - this.app.put('/api/me/member-profile/visibility', requireAuth, async (req, res) => { - try { - const user = req.user!; - const requestedOrgId = req.query.org as string | undefined; - const { is_public, show_in_carousel } = req.body; - - // Validate request body - if (typeof is_public !== 'boolean') { - return res.status(400).json({ - error: 'Invalid request', - message: 'is_public must be a boolean', - }); - } - - // Dev mode: handle dev organizations without WorkOS - const isDevUserProfile = isDevModeEnabled() && Object.values(DEV_USERS).some(du => du.id === user.id) && requestedOrgId?.startsWith('org_dev_'); - - let targetOrgId: string; - - if (isDevUserProfile) { - // Dev mode: use the requested dev org directly - const localOrg = await orgDb.getOrganization(requestedOrgId!); - if (!localOrg) { - return res.status(404).json({ - error: 'Organization not found', - message: 'The requested organization does not exist', - }); - } - targetOrgId = requestedOrgId!; - logger.info({ userId: user.id, orgId: targetOrgId }, 'PUT /api/me/member-profile/visibility: dev mode bypass'); - } else { - // Normal mode: check WorkOS memberships - const memberships = await workos!.userManagement.listOrganizationMemberships({ - userId: user.id, - }); - - if (memberships.data.length === 0) { - return res.status(400).json({ - error: 'No organization', - message: 'User must be a member of an organization to update visibility', - }); - } - - if (requestedOrgId) { - const isMember = memberships.data.some(m => m.organizationId === requestedOrgId); - if (!isMember) { - return res.status(403).json({ - error: 'Not authorized', - message: 'User is not a member of the requested organization', - }); - } - targetOrgId = requestedOrgId; - } else { - targetOrgId = memberships.data[0].organizationId; - } - } - - // Check if profile exists - const existingProfile = await memberDb.getProfileByOrgId(targetOrgId); - if (!existingProfile) { - return res.status(404).json({ - error: 'Profile not found', - message: 'No member profile exists for your organization.', - }); - } - - // If trying to make public, check subscription status - if (is_public) { - const subscriptionInfo = await orgDb.getSubscriptionInfo(targetOrgId); - if (!subscriptionInfo || subscriptionInfo.status !== 'active') { - return res.status(403).json({ - error: 'Subscription required', - message: 'An active membership subscription is required to make your profile public.', - }); - } - } - - // Update visibility - const profile = await memberDb.updateProfileByOrgId(targetOrgId, { - is_public, - show_in_carousel: show_in_carousel ?? is_public, // Default to match is_public - }); - - // Invalidate Addie's member context cache - organization profile visibility changed - invalidateMemberContextCache(); - - logger.info({ profileId: profile?.id, orgId: targetOrgId, is_public }, 'Member profile visibility updated'); - - res.json({ profile }); - } catch (error) { - logger.error({ err: error }, 'Update member profile visibility error'); - res.status(500).json({ - error: 'Failed to update visibility', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // DELETE /api/me/member-profile - Delete current user's organization's member profile - // Supports ?org=org_id query parameter to specify which organization - this.app.delete('/api/me/member-profile', requireAuth, async (req, res) => { - const startTime = Date.now(); - logger.info({ userId: req.user?.id, org: req.query.org }, 'DELETE /api/me/member-profile started'); - try { - const user = req.user!; - const requestedOrgId = req.query.org as string | undefined; - - // Dev mode: handle dev organizations without WorkOS - const isDevUserProfile = isDevModeEnabled() && Object.values(DEV_USERS).some(du => du.id === user.id) && requestedOrgId?.startsWith('org_dev_'); - - let targetOrgId: string; - - if (isDevUserProfile) { - // Dev mode: use the requested dev org directly - const localOrg = await orgDb.getOrganization(requestedOrgId!); - if (!localOrg) { - return res.status(404).json({ - error: 'Organization not found', - message: 'The requested organization does not exist', - }); - } - targetOrgId = requestedOrgId!; - logger.info({ userId: user.id, orgId: targetOrgId }, 'DELETE /api/me/member-profile: dev mode bypass'); - } else { - // Normal mode: check WorkOS memberships - const memberships = await workos!.userManagement.listOrganizationMemberships({ - userId: user.id, - }); - - if (memberships.data.length === 0) { - return res.status(400).json({ - error: 'No organization', - message: 'User must be a member of an organization', - }); - } - - if (requestedOrgId) { - const isMember = memberships.data.some(m => m.organizationId === requestedOrgId); - if (!isMember) { - return res.status(403).json({ - error: 'Not authorized', - message: 'User is not a member of the requested organization', - }); - } - targetOrgId = requestedOrgId; - } else { - targetOrgId = memberships.data[0].organizationId; - } - } - - // Check if profile exists - const existingProfile = await memberDb.getProfileByOrgId(targetOrgId); - if (!existingProfile) { - return res.status(404).json({ - error: 'Profile not found', - message: 'No member profile exists for your organization', - }); - } - - await memberDb.deleteProfile(existingProfile.id); - - // Invalidate Addie's member context cache - organization profile deleted - invalidateMemberContextCache(); - - logger.info({ profileId: existingProfile.id, orgId: targetOrgId, durationMs: Date.now() - startTime }, 'DELETE /api/me/member-profile completed'); - - res.json({ success: true }); - } catch (error) { - logger.error({ err: error, durationMs: Date.now() - startTime }, 'DELETE /api/me/member-profile error'); - res.status(500).json({ - error: 'Failed to delete member profile', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // Admin routes for member profiles - // GET /api/admin/member-profiles - List all member profiles (admin) - this.app.get('/api/admin/member-profiles', requireAuth, requireAdmin, async (req, res) => { - try { - const { is_public, search, limit, offset } = req.query; - - const profiles = await memberDb.listProfiles({ - is_public: is_public === 'true' ? true : is_public === 'false' ? false : undefined, - search: search as string, - limit: limit ? parseInt(limit as string, 10) : 100, - offset: offset ? parseInt(offset as string, 10) : 0, - }); - - res.json({ profiles }); - } catch (error) { - logger.error({ err: error }, 'Admin list member profiles error'); - res.status(500).json({ - error: 'Failed to list member profiles', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // PUT /api/admin/member-profiles/:id - Update any member profile (admin) - this.app.put('/api/admin/member-profiles/:id', requireAuth, requireAdmin, async (req, res) => { - try { - const { id } = req.params; - const updates = req.body; - - // Remove fields that shouldn't be updated - delete updates.id; - delete updates.workos_organization_id; - delete updates.created_at; - delete updates.updated_at; - - const profile = await memberDb.updateProfile(id, updates); - - if (!profile) { - return res.status(404).json({ - error: 'Profile not found', - message: `No member profile found with ID: ${id}`, - }); - } - - // Invalidate Addie's member context cache - organization profile updated by admin - invalidateMemberContextCache(); - - logger.info({ profileId: id, adminUpdate: true }, 'Member profile updated by admin'); - - res.json({ profile }); - } catch (error) { - logger.error({ err: error }, 'Admin update member profile error'); - res.status(500).json({ - error: 'Failed to update member profile', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // DELETE /api/admin/member-profiles/:id - Delete any member profile (admin) - this.app.delete('/api/admin/member-profiles/:id', requireAuth, requireAdmin, async (req, res) => { - try { - const { id } = req.params; - - const deleted = await memberDb.deleteProfile(id); - - if (!deleted) { - return res.status(404).json({ - error: 'Profile not found', - message: `No member profile found with ID: ${id}`, - }); - } - - // Invalidate Addie's member context cache - organization profile deleted by admin - invalidateMemberContextCache(); - - logger.info({ profileId: id, adminDelete: true }, 'Member profile deleted by admin'); - - res.json({ success: true }); - } catch (error) { - logger.error({ err: error }, 'Admin delete member profile error'); - res.status(500).json({ - error: 'Failed to delete member profile', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); + // Note: Member profile routes are in routes/member-profiles.ts (mounted in setupRoutes) - // Note: Prospect management routes are now in routes/admin.ts + // Note: Prospect management routes are in routes/admin.ts // Routes: GET/POST /api/admin/prospects, POST /api/admin/prospects/bulk, // PUT /api/admin/prospects/:orgId, GET /api/admin/prospects/stats, // GET /api/admin/organizations diff --git a/server/src/routes/admin/prospects.ts b/server/src/routes/admin/prospects.ts index 20b36460a4..1a1cc59dc9 100644 --- a/server/src/routes/admin/prospects.ts +++ b/server/src/routes/admin/prospects.ts @@ -10,6 +10,7 @@ import { createLogger } from "../../logger.js"; import { requireAuth, requireAdmin } from "../../middleware/auth.js"; import { createProspect } from "../../services/prospect.js"; import { COMPANY_TYPE_VALUES } from "../../config/company-types.js"; +import { VALID_REVENUE_TIERS } from "../../db/organization-db.js"; import { MEMBER_FILTER_ALIASED, NOT_MEMBER_ALIASED, @@ -623,11 +624,10 @@ export function setupProspectRoutes(apiRouter: Router, config: ProspectRoutesCon const pool = getPool(); // Validate revenue_tier if provided - const validRevenueTiers = ['under_1m', '1m_5m', '5m_50m', '50m_250m', '250m_1b', '1b_plus']; - if (updates.revenue_tier && !validRevenueTiers.includes(updates.revenue_tier)) { + if (updates.revenue_tier && !VALID_REVENUE_TIERS.includes(updates.revenue_tier as any)) { return res.status(400).json({ error: "Invalid revenue_tier", - message: `revenue_tier must be one of: ${validRevenueTiers.join(", ")}`, + message: `revenue_tier must be one of: ${VALID_REVENUE_TIERS.join(", ")}`, }); } diff --git a/server/src/routes/billing-public.ts b/server/src/routes/billing-public.ts index 713f7e5c4d..8e17e23921 100644 --- a/server/src/routes/billing-public.ts +++ b/server/src/routes/billing-public.ts @@ -26,6 +26,7 @@ import { type CompanyType, type RevenueTier, type Organization, + VALID_REVENUE_TIERS, } from "../db/organization-db.js"; import { mapIndustryToCompanyType, @@ -636,28 +637,18 @@ export function createPublicBillingRouter(): Router { const { orgId } = req.params; const { company_type, revenue_tier } = req.body; - // Validate inputs - use centralized company types config - const validCompanyTypes = COMPANY_TYPE_VALUES; - const validRevenueTiers = [ - "under_1m", - "1m_5m", - "5m_50m", - "50m_250m", - "250m_1b", - "1b_plus", - ]; - - if (company_type && !validCompanyTypes.includes(company_type)) { + // Validate inputs - use centralized constants + if (company_type && !COMPANY_TYPE_VALUES.includes(company_type)) { return res.status(400).json({ error: "Invalid company_type", - message: `company_type must be one of: ${validCompanyTypes.join(", ")}`, + message: `company_type must be one of: ${COMPANY_TYPE_VALUES.join(", ")}`, }); } - if (revenue_tier && !validRevenueTiers.includes(revenue_tier)) { + if (revenue_tier && !VALID_REVENUE_TIERS.includes(revenue_tier as any)) { return res.status(400).json({ error: "Invalid revenue_tier", - message: `revenue_tier must be one of: ${validRevenueTiers.join(", ")}`, + message: `revenue_tier must be one of: ${VALID_REVENUE_TIERS.join(", ")}`, }); } diff --git a/server/src/routes/member-profiles.ts b/server/src/routes/member-profiles.ts new file mode 100644 index 0000000000..79da6311e9 --- /dev/null +++ b/server/src/routes/member-profiles.ts @@ -0,0 +1,694 @@ +/** + * Member profile routes module + * + * This module contains member profile-related routes extracted from http.ts. + * Includes profile CRUD operations for both authenticated users and admins. + */ + +import { Router } from "express"; +import { WorkOS } from "@workos-inc/node"; +import { createLogger } from "../logger.js"; +import { + requireAuth, + requireAdmin, + isDevModeEnabled, + DEV_USERS, +} from "../middleware/auth.js"; +import { MemberDatabase } from "../db/member-db.js"; +import { OrganizationDatabase } from "../db/organization-db.js"; +import { getSubscriptionInfo } from "../billing/stripe-client.js"; +import { VALID_MEMBER_OFFERINGS } from "../types.js"; + +const logger = createLogger("member-profile-routes"); + +/** + * Validate slug format and check against reserved keywords + */ +function isValidSlug(slug: string): boolean { + const reserved = ['admin', 'api', 'auth', 'dashboard', 'members', 'registry', 'onboarding']; + if (reserved.includes(slug.toLowerCase())) { + return false; + } + return /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug.toLowerCase()); +} + +export interface MemberProfileRoutesConfig { + workos: WorkOS | null; + memberDb: MemberDatabase; + orgDb: OrganizationDatabase; + invalidateMemberContextCache: () => void; +} + +/** + * Create member profile routes + * Returns a router for user profile routes (/api/me/member-profile) + */ +export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Router { + const { workos, memberDb, orgDb, invalidateMemberContextCache } = config; + const router = Router(); + + // GET /api/me/member-profile - Get current user's organization's member profile + router.get('/', requireAuth, async (req, res) => { + const startTime = Date.now(); + logger.info({ userId: req.user?.id, org: req.query.org }, 'GET /api/me/member-profile started'); + try { + const user = req.user!; + const requestedOrgId = req.query.org as string | undefined; + + // Dev mode: handle dev organizations without WorkOS + const isDevUserProfile = isDevModeEnabled() && Object.values(DEV_USERS).some(du => du.id === user.id) && requestedOrgId?.startsWith('org_dev_'); + if (isDevUserProfile) { + const localOrg = await orgDb.getOrganization(requestedOrgId!); + if (!localOrg) { + return res.status(404).json({ + error: 'Organization not found', + message: 'The requested organization does not exist', + }); + } + const profile = await memberDb.getProfileByOrgId(requestedOrgId!); + logger.info({ userId: user.id, orgId: requestedOrgId, hasProfile: !!profile, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile completed (dev mode)'); + return res.json({ + profile: profile || null, + organization_id: requestedOrgId, + organization_name: localOrg.name, + }); + } + + // Get user's organization memberships + const memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + }); + + if (memberships.data.length === 0) { + logger.info({ userId: user.id, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile: no organization'); + return res.status(404).json({ + error: 'No organization', + message: 'User is not a member of any organization', + }); + } + + // Determine which org to use + let targetOrgId: string; + if (requestedOrgId) { + // Verify user is a member of the requested org + const isMember = memberships.data.some(m => m.organizationId === requestedOrgId); + if (!isMember) { + logger.info({ userId: user.id, requestedOrgId, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile: not authorized'); + return res.status(403).json({ + error: 'Not authorized', + message: 'User is not a member of the requested organization', + }); + } + targetOrgId = requestedOrgId; + } else { + // Default to first org + targetOrgId = memberships.data[0].organizationId; + } + + const profile = await memberDb.getProfileByOrgId(targetOrgId); + + // Get org name from WorkOS + const org = await workos!.organizations.getOrganization(targetOrgId); + + logger.info({ userId: user.id, orgId: targetOrgId, hasProfile: !!profile, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile completed'); + res.json({ + profile: profile || null, + organization_id: targetOrgId, + organization_name: org.name, + }); + } catch (error) { + logger.error({ err: error, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile error'); + res.status(500).json({ + error: 'Failed to get member profile', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/me/member-profile - Create member profile for current user's organization + router.post('/', requireAuth, async (req, res) => { + const startTime = Date.now(); + logger.info({ userId: req.user?.id, org: req.query.org }, 'POST /api/me/member-profile started'); + try { + const user = req.user!; + const requestedOrgId = req.query.org as string | undefined; + const { + display_name, + slug, + tagline, + description, + logo_url, + logo_light_url, + logo_dark_url, + brand_color, + contact_email, + contact_website, + contact_phone, + linkedin_url, + twitter_url, + offerings, + agents, + headquarters, + markets, + tags, + is_public, + show_in_carousel, + } = req.body; + + // Validate required fields + if (!display_name || !slug) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'display_name and slug are required', + }); + } + + // Validate slug format and reserved words + if (!isValidSlug(slug)) { + return res.status(400).json({ + error: 'Invalid slug', + message: 'Slug must contain only lowercase letters, numbers, and hyphens, cannot start or end with a hyphen, and cannot be a reserved keyword (admin, api, auth, dashboard, members, registry, onboarding)', + }); + } + + // Dev mode: handle dev organizations without WorkOS + const isDevUserProfile = isDevModeEnabled() && Object.values(DEV_USERS).some(du => du.id === user.id) && requestedOrgId?.startsWith('org_dev_'); + let targetOrgId: string; + + if (isDevUserProfile) { + const localOrg = await orgDb.getOrganization(requestedOrgId!); + if (!localOrg) { + return res.status(404).json({ + error: 'Organization not found', + message: 'The requested organization does not exist', + }); + } + targetOrgId = requestedOrgId!; + logger.info({ userId: user.id, orgId: targetOrgId }, 'POST /api/me/member-profile: dev mode bypass'); + } else { + // Get user's organization memberships + const memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + }); + + if (memberships.data.length === 0) { + return res.status(404).json({ + error: 'No organization', + message: 'User is not a member of any organization', + }); + } + + // Determine which org to use + if (requestedOrgId) { + // Verify user is admin/owner of the requested org + const membership = memberships.data.find(m => m.organizationId === requestedOrgId); + if (!membership) { + return res.status(403).json({ + error: 'Not authorized', + message: 'User is not a member of the requested organization', + }); + } + const role = membership.role?.slug || 'member'; + if (role !== 'admin' && role !== 'owner') { + return res.status(403).json({ + error: 'Not authorized', + message: 'Only admins and owners can create member profiles', + }); + } + targetOrgId = requestedOrgId; + } else { + targetOrgId = memberships.data[0].organizationId; + } + } + + // Check if profile already exists + const existingProfile = await memberDb.getProfileByOrgId(targetOrgId); + if (existingProfile) { + return res.status(409).json({ + error: 'Profile already exists', + message: 'Organization already has a member profile. Use PUT to update.', + }); + } + + // Check slug availability + const slugAvailable = await memberDb.isSlugAvailable(slug); + if (!slugAvailable) { + return res.status(409).json({ + error: 'Slug not available', + message: 'This slug is already taken. Please choose a different one.', + }); + } + + // Validate offerings if provided + if (offerings && Array.isArray(offerings)) { + const invalidOfferings = offerings.filter((o: string) => !VALID_MEMBER_OFFERINGS.includes(o as any)); + if (invalidOfferings.length > 0) { + return res.status(400).json({ + error: 'Invalid offerings', + message: `Invalid offerings: ${invalidOfferings.join(', ')}. Valid options: ${VALID_MEMBER_OFFERINGS.join(', ')}`, + }); + } + } + + const profile = await memberDb.createProfile({ + workos_organization_id: targetOrgId, + display_name, + slug, + tagline, + description, + logo_url, + logo_light_url, + logo_dark_url, + brand_color, + contact_email, + contact_website, + contact_phone, + linkedin_url, + twitter_url, + offerings: offerings || [], + agents: agents || [], + headquarters, + markets: markets || [], + tags: tags || [], + is_public: is_public ?? false, + show_in_carousel: show_in_carousel ?? false, + }); + + // Invalidate Addie's member context cache - organization profile created + invalidateMemberContextCache(); + + logger.info({ profileId: profile.id, orgId: targetOrgId, slug, durationMs: Date.now() - startTime }, 'POST /api/me/member-profile completed'); + + res.status(201).json({ profile }); + } catch (error) { + logger.error({ err: error, durationMs: Date.now() - startTime }, 'POST /api/me/member-profile error'); + res.status(500).json({ + error: 'Failed to create member profile', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // PUT /api/me/member-profile - Update current user's organization's member profile + router.put('/', requireAuth, async (req, res) => { + const startTime = Date.now(); + logger.info({ userId: req.user?.id }, 'PUT /api/me/member-profile started'); + try { + const user = req.user!; + const requestedOrgId = req.query.org as string | undefined; + const updates = req.body; + + // Dev mode: handle dev organizations without WorkOS + const isDevUserProfile = isDevModeEnabled() && Object.values(DEV_USERS).some(du => du.id === user.id) && requestedOrgId?.startsWith('org_dev_'); + let targetOrgId: string; + + if (isDevUserProfile) { + const localOrg = await orgDb.getOrganization(requestedOrgId!); + if (!localOrg) { + return res.status(404).json({ + error: 'Organization not found', + message: 'The requested organization does not exist', + }); + } + targetOrgId = requestedOrgId!; + logger.info({ userId: user.id, orgId: targetOrgId }, 'PUT /api/me/member-profile: dev mode bypass'); + } else { + // Get user's organization memberships + const memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + }); + + if (memberships.data.length === 0) { + return res.status(404).json({ + error: 'No organization', + message: 'User is not a member of any organization', + }); + } + + // Determine which org to use + if (requestedOrgId) { + // Verify user is a member of the requested org + const isMember = memberships.data.some(m => m.organizationId === requestedOrgId); + if (!isMember) { + return res.status(403).json({ + error: 'Not authorized', + message: 'User is not a member of the requested organization', + }); + } + targetOrgId = requestedOrgId; + } else { + targetOrgId = memberships.data[0].organizationId; + } + } + + // Check if profile exists + const existingProfile = await memberDb.getProfileByOrgId(targetOrgId); + if (!existingProfile) { + return res.status(404).json({ + error: 'Profile not found', + message: 'No member profile exists for your organization. Use POST to create one.', + }); + } + + // Validate offerings if provided + if (updates.offerings && Array.isArray(updates.offerings)) { + const invalidOfferings = updates.offerings.filter((o: string) => !VALID_MEMBER_OFFERINGS.includes(o as any)); + if (invalidOfferings.length > 0) { + return res.status(400).json({ + error: 'Invalid offerings', + message: `Invalid offerings: ${invalidOfferings.join(', ')}. Valid options: ${VALID_MEMBER_OFFERINGS.join(', ')}`, + }); + } + } + + // Remove fields that shouldn't be updated directly + delete updates.id; + delete updates.workos_organization_id; + delete updates.slug; // Slug changes not allowed via this endpoint + delete updates.created_at; + delete updates.updated_at; + delete updates.featured; // Only admins can set featured + + const profile = await memberDb.updateProfileByOrgId(targetOrgId, updates); + + // Invalidate Addie's member context cache - organization profile updated + invalidateMemberContextCache(); + + const duration = Date.now() - startTime; + logger.info({ profileId: profile?.id, orgId: targetOrgId, durationMs: duration }, 'Member profile updated'); + + res.json({ profile }); + } catch (error) { + const duration = Date.now() - startTime; + logger.error({ err: error, durationMs: duration }, 'Update member profile error'); + res.status(500).json({ + error: 'Failed to update member profile', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // PUT /api/me/member-profile/visibility - Update visibility only (with subscription check) + router.put('/visibility', requireAuth, async (req, res) => { + const startTime = Date.now(); + logger.info({ userId: req.user?.id }, 'PUT /api/me/member-profile/visibility started'); + try { + const user = req.user!; + const requestedOrgId = req.query.org as string | undefined; + const { is_public, show_in_carousel } = req.body; + + // Dev mode: handle dev organizations without WorkOS + const isDevUserProfile = isDevModeEnabled() && Object.values(DEV_USERS).some(du => du.id === user.id) && requestedOrgId?.startsWith('org_dev_'); + let targetOrgId: string; + + if (isDevUserProfile) { + const localOrg = await orgDb.getOrganization(requestedOrgId!); + if (!localOrg) { + return res.status(404).json({ + error: 'Organization not found', + message: 'The requested organization does not exist', + }); + } + targetOrgId = requestedOrgId!; + logger.info({ userId: user.id, orgId: targetOrgId }, 'PUT /api/me/member-profile/visibility: dev mode bypass'); + } else { + // Get user's organization memberships + const memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + }); + + if (memberships.data.length === 0) { + return res.status(404).json({ + error: 'No organization', + message: 'User is not a member of any organization', + }); + } + + // Determine which org to use + if (requestedOrgId) { + const isMember = memberships.data.some(m => m.organizationId === requestedOrgId); + if (!isMember) { + return res.status(403).json({ + error: 'Not authorized', + message: 'User is not a member of the requested organization', + }); + } + targetOrgId = requestedOrgId; + } else { + targetOrgId = memberships.data[0].organizationId; + } + } + + // Check if profile exists + const existingProfile = await memberDb.getProfileByOrgId(targetOrgId); + if (!existingProfile) { + return res.status(404).json({ + error: 'Profile not found', + message: 'No member profile exists for your organization.', + }); + } + + // Check subscription status before allowing visibility toggle + // Only allow public profiles for paying members + if (is_public === true) { + const org = await orgDb.getOrganization(targetOrgId); + if (org?.stripe_customer_id) { + const subscriptionInfo = await getSubscriptionInfo(org.stripe_customer_id); + if (subscriptionInfo?.status !== 'active' && subscriptionInfo?.status !== 'trialing') { + return res.status(402).json({ + error: 'Subscription required', + message: 'An active subscription is required to make your profile public.', + }); + } + } else { + // No Stripe customer - must be in dev mode or a legacy account + // In dev mode, allow it. In production without stripe, deny. + if (!isDevModeEnabled()) { + return res.status(402).json({ + error: 'Subscription required', + message: 'An active subscription is required to make your profile public.', + }); + } + } + } + + // Update only visibility fields + const updates: { is_public?: boolean; show_in_carousel?: boolean } = {}; + if (typeof is_public === 'boolean') updates.is_public = is_public; + if (typeof show_in_carousel === 'boolean') updates.show_in_carousel = show_in_carousel; + + const profile = await memberDb.updateProfileByOrgId(targetOrgId, updates); + + // Invalidate Addie's member context cache + invalidateMemberContextCache(); + + const duration = Date.now() - startTime; + logger.info({ profileId: profile?.id, orgId: targetOrgId, updates, durationMs: duration }, 'Member profile visibility updated'); + + res.json({ profile }); + } catch (error) { + const duration = Date.now() - startTime; + logger.error({ err: error, durationMs: duration }, 'Update member profile visibility error'); + res.status(500).json({ + error: 'Failed to update profile visibility', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // DELETE /api/me/member-profile - Delete current user's organization's member profile + router.delete('/', requireAuth, async (req, res) => { + const startTime = Date.now(); + logger.info({ userId: req.user?.id, org: req.query.org }, 'DELETE /api/me/member-profile started'); + try { + const user = req.user!; + const requestedOrgId = req.query.org as string | undefined; + + // Dev mode: handle dev organizations without WorkOS + const isDevUserProfile = isDevModeEnabled() && Object.values(DEV_USERS).some(du => du.id === user.id) && requestedOrgId?.startsWith('org_dev_'); + let targetOrgId: string; + + if (isDevUserProfile) { + const localOrg = await orgDb.getOrganization(requestedOrgId!); + if (!localOrg) { + return res.status(404).json({ + error: 'Organization not found', + message: 'The requested organization does not exist', + }); + } + targetOrgId = requestedOrgId!; + logger.info({ userId: user.id, orgId: targetOrgId }, 'DELETE /api/me/member-profile: dev mode bypass'); + } else { + // Get user's organization memberships + const memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + }); + + if (memberships.data.length === 0) { + return res.status(404).json({ + error: 'No organization', + message: 'User is not a member of any organization', + }); + } + + // Determine which org to use + if (requestedOrgId) { + // Verify user is admin/owner of the requested org + const membership = memberships.data.find(m => m.organizationId === requestedOrgId); + if (!membership) { + return res.status(403).json({ + error: 'Not authorized', + message: 'User is not a member of the requested organization', + }); + } + const role = membership.role?.slug || 'member'; + if (role !== 'admin' && role !== 'owner') { + return res.status(403).json({ + error: 'Not authorized', + message: 'Only admins and owners can delete member profiles', + }); + } + targetOrgId = requestedOrgId; + } else { + targetOrgId = memberships.data[0].organizationId; + } + } + + // Check if profile exists + const existingProfile = await memberDb.getProfileByOrgId(targetOrgId); + if (!existingProfile) { + return res.status(404).json({ + error: 'Profile not found', + message: 'No member profile exists for your organization.', + }); + } + + // Delete the profile + await memberDb.deleteProfile(existingProfile.id); + + // Invalidate Addie's member context cache - organization profile deleted + invalidateMemberContextCache(); + + logger.info({ profileId: existingProfile.id, orgId: targetOrgId, durationMs: Date.now() - startTime }, 'DELETE /api/me/member-profile completed'); + + res.json({ success: true }); + } catch (error) { + logger.error({ err: error, durationMs: Date.now() - startTime }, 'DELETE /api/me/member-profile error'); + res.status(500).json({ + error: 'Failed to delete member profile', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} + +/** + * Create admin member profile routes + * Returns a router for admin profile routes (/api/admin/member-profiles) + */ +export function createAdminMemberProfileRouter(config: MemberProfileRoutesConfig): Router { + const { memberDb, invalidateMemberContextCache } = config; + const router = Router(); + + // GET /api/admin/member-profiles - List all member profiles (admin) + router.get('/', requireAuth, requireAdmin, async (req, res) => { + try { + const { is_public, search, limit, offset } = req.query; + + const profiles = await memberDb.listProfiles({ + is_public: is_public === 'true' ? true : is_public === 'false' ? false : undefined, + search: search as string, + limit: limit ? parseInt(limit as string, 10) : 100, + offset: offset ? parseInt(offset as string, 10) : 0, + }); + + res.json({ profiles }); + } catch (error) { + logger.error({ err: error }, 'Admin list member profiles error'); + res.status(500).json({ + error: 'Failed to list member profiles', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // PUT /api/admin/member-profiles/:id - Update any member profile (admin) + router.put('/:id', requireAuth, requireAdmin, async (req, res) => { + try { + const { id } = req.params; + const updates = req.body; + + // Validate offerings if provided + if (updates.offerings && Array.isArray(updates.offerings)) { + const invalidOfferings = updates.offerings.filter((o: string) => !VALID_MEMBER_OFFERINGS.includes(o as any)); + if (invalidOfferings.length > 0) { + return res.status(400).json({ + error: 'Invalid offerings', + message: `Invalid offerings: ${invalidOfferings.join(', ')}. Valid options: ${VALID_MEMBER_OFFERINGS.join(', ')}`, + }); + } + } + + // Remove fields that shouldn't be updated + delete updates.id; + delete updates.workos_organization_id; + delete updates.created_at; + delete updates.updated_at; + + const profile = await memberDb.updateProfile(id, updates); + + if (!profile) { + return res.status(404).json({ + error: 'Profile not found', + message: `No member profile found with ID: ${id}`, + }); + } + + // Invalidate Addie's member context cache - organization profile updated by admin + invalidateMemberContextCache(); + + logger.info({ profileId: id, adminUpdate: true }, 'Member profile updated by admin'); + + res.json({ profile }); + } catch (error) { + logger.error({ err: error }, 'Admin update member profile error'); + res.status(500).json({ + error: 'Failed to update member profile', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // DELETE /api/admin/member-profiles/:id - Delete any member profile (admin) + router.delete('/:id', requireAuth, requireAdmin, async (req, res) => { + try { + const { id } = req.params; + + const deleted = await memberDb.deleteProfile(id); + + if (!deleted) { + return res.status(404).json({ + error: 'Profile not found', + message: `No member profile found with ID: ${id}`, + }); + } + + // Invalidate Addie's member context cache - organization profile deleted by admin + invalidateMemberContextCache(); + + logger.info({ profileId: id, adminDelete: true }, 'Member profile deleted by admin'); + + res.json({ success: true }); + } catch (error) { + logger.error({ err: error }, 'Admin delete member profile error'); + res.status(500).json({ + error: 'Failed to delete member profile', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} diff --git a/server/src/routes/organizations.ts b/server/src/routes/organizations.ts index 74203d26b0..e2d6b074f4 100644 --- a/server/src/routes/organizations.ts +++ b/server/src/routes/organizations.ts @@ -17,8 +17,9 @@ import { } from "../middleware/auth.js"; import { invitationRateLimiter, orgCreationRateLimiter } from "../middleware/rate-limit.js"; import { validateOrganizationName, validateEmail } from "../middleware/validation.js"; -import { OrganizationDatabase, CompanyType, RevenueTier } from "../db/organization-db.js"; +import { OrganizationDatabase, CompanyType, RevenueTier, VALID_REVENUE_TIERS } from "../db/organization-db.js"; import { COMPANY_TYPE_VALUES } from "../config/company-types.js"; +import { VALID_ORGANIZATION_ROLES, VALID_ASSIGNABLE_ROLES } from "../types.js"; import { JoinRequestDatabase } from "../db/join-request-db.js"; import { SlackDatabase } from "../db/slack-db.js"; import { getCompanyDomain } from "../utils/email-domain.js"; @@ -732,12 +733,11 @@ export function createOrganizationsRouter(): Router { } // Validate role if provided - const validRoles = ['member', 'admin']; const roleToAssign = role || 'member'; - if (!validRoles.includes(roleToAssign)) { + if (!(VALID_ASSIGNABLE_ROLES as readonly string[]).includes(roleToAssign)) { return res.status(400).json({ error: 'Invalid role', - message: `Role must be one of: ${validRoles.join(', ')}`, + message: `Role must be one of: ${VALID_ASSIGNABLE_ROLES.join(', ')}`, }); } @@ -1037,11 +1037,10 @@ export function createOrganizationsRouter(): Router { } // Validate revenue_tier if provided - const validRevenueTiers = ['under_1m', '1m_5m', '5m_50m', '50m_250m', '250m_1b', '1b_plus']; - if (revenue_tier && !validRevenueTiers.includes(revenue_tier)) { + if (revenue_tier && !(VALID_REVENUE_TIERS as readonly string[]).includes(revenue_tier)) { return res.status(400).json({ error: 'Invalid revenue tier', - message: `revenue_tier must be one of: ${validRevenueTiers.join(', ')}`, + message: `revenue_tier must be one of: ${VALID_REVENUE_TIERS.join(', ')}`, }); } @@ -1393,11 +1392,10 @@ export function createOrganizationsRouter(): Router { } // Validate revenue_tier if provided - const validRevenueTiers = ['under_1m', '1m_5m', '5m_50m', '50m_250m', '250m_1b', '1b_plus']; - if (revenue_tier !== undefined && revenue_tier !== null && !validRevenueTiers.includes(revenue_tier)) { + if (revenue_tier !== undefined && revenue_tier !== null && !VALID_REVENUE_TIERS.includes(revenue_tier as any)) { return res.status(400).json({ error: 'Invalid revenue tier', - message: `revenue_tier must be one of: ${validRevenueTiers.join(', ')}`, + message: `revenue_tier must be one of: ${VALID_REVENUE_TIERS.join(', ')}`, }); } @@ -1936,11 +1934,10 @@ export function createOrganizationsRouter(): Router { } // Validate role if provided - const validRoles = ['member', 'admin', 'owner']; - if (role && !validRoles.includes(role)) { + if (role && !VALID_ORGANIZATION_ROLES.includes(role as any)) { return res.status(400).json({ error: 'Invalid role', - message: `Role must be one of: ${validRoles.join(', ')}`, + message: `Role must be one of: ${VALID_ORGANIZATION_ROLES.join(', ')}`, }); } @@ -2187,11 +2184,10 @@ export function createOrganizationsRouter(): Router { } // Validate role - const validRoles = ['member', 'admin', 'owner']; - if (!validRoles.includes(role)) { + if (!VALID_ORGANIZATION_ROLES.includes(role as any)) { return res.status(400).json({ error: 'Invalid role', - message: `Role must be one of: ${validRoles.join(', ')}`, + message: `Role must be one of: ${VALID_ORGANIZATION_ROLES.join(', ')}`, }); } diff --git a/server/src/types.ts b/server/src/types.ts index 9df7856b23..8557275ccd 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -136,6 +136,32 @@ export type SubscriptionTier = 'basic' | 'professional' | 'enterprise'; export type CompanyUserRole = 'owner' | 'admin' | 'member'; +/** + * Valid organization roles for runtime validation + */ +export const VALID_ORGANIZATION_ROLES: readonly CompanyUserRole[] = ['owner', 'admin', 'member'] as const; + +/** + * Roles that can be assigned to new members (excludes owner) + */ +export const VALID_ASSIGNABLE_ROLES: readonly ('admin' | 'member')[] = ['admin', 'member'] as const; + +/** + * Legal document types + */ +export type LegalDocumentType = 'terms_of_service' | 'privacy_policy' | 'membership' | 'bylaws' | 'ip_policy'; + +/** + * Valid legal document types for runtime validation + */ +export const VALID_LEGAL_DOCUMENT_TYPES: readonly LegalDocumentType[] = [ + 'terms_of_service', + 'privacy_policy', + 'membership', + 'bylaws', + 'ip_policy', +] as const; + export interface Company { id: string; slug: string; @@ -200,6 +226,26 @@ export type MemberOffering = | 'consulting' | 'other'; +/** + * Valid member offering values for runtime validation + */ +export const VALID_MEMBER_OFFERINGS: readonly MemberOffering[] = [ + 'buyer_agent', + 'sales_agent', + 'creative_agent', + 'signals_agent', + 'publisher', + 'consulting', + 'other', +] as const; + +/** + * Type guard to check if a string is a valid MemberOffering + */ +export function isValidMemberOffering(value: string | undefined | null): value is MemberOffering { + return typeof value === 'string' && VALID_MEMBER_OFFERINGS.includes(value as MemberOffering); +} + /** * Agent configuration stored in member profiles * Each agent has a URL and visibility settings From 6ecac4021409911c135942a376987991ff89d96b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 18 Jan 2026 14:53:50 -0500 Subject: [PATCH 36/58] docs: Clarify signal agents and decisioning platforms (#804) * docs: Clarify signal agents and decisioning platforms terminology Standardize terminology to use 'signal agent' (MCP servers) instead of 'signal platform'. Replace ambiguous 'injective platforms' with concrete examples (SSPs, ad servers). Add glossary entries for Decisioning Platform, Signal Agent, Sales Agent, and SSP. Update related definitions to reference decisioning platform consistently. * chore: Add empty changeset for docs-only changes --- .changeset/metal-hats-agree.md | 2 ++ docs/intro.mdx | 10 +++++----- docs/reference/glossary.mdx | 16 ++++++++++++++-- docs/signals/specification.mdx | 20 ++++++++++---------- 4 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 .changeset/metal-hats-agree.md diff --git a/.changeset/metal-hats-agree.md b/.changeset/metal-hats-agree.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/metal-hats-agree.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/docs/intro.mdx b/docs/intro.mdx index 81e9e4f7a5..1f747c40f0 100644 --- a/docs/intro.mdx +++ b/docs/intro.mdx @@ -75,7 +75,7 @@ Platforms that evaluate sellers and audiences, and execute buying strategies: MCP servers that provide: - **Signal Discovery**: Finding relevant signals (audiences, contextual, geographical, temporal) using natural language - **Signal Activation**: Pushing signals to decisioning platforms -- **Integration**: Connects signal platforms to orchestration via MCP +- **Integration**: Exposes data provider capabilities via MCP #### Sales Agent (Right, Bottom) MCP servers that provide: @@ -142,8 +142,8 @@ The creative flow operates in parallel with the business and technical flows, en Each AdCP protocol operates within this ecosystem: ### 🎯 Signals Activation Protocol -- **Scope**: Works with **signal platforms** to discover and activate signals directly on **decisioning platforms** -- **Integration**: Direct integration between signal agents and decisioning platforms (DSPs, injective platforms) +- **Scope**: Works with **signal agents** to discover and activate signals directly on **decisioning platforms** +- **Integration**: Direct integration between signal agents and decisioning platforms (DSPs, SSPs, ad servers) - **Workflow**: Find signals → Direct activation on target platform → Ready for campaign use ### 📍 Curation Protocol (Coming Soon) @@ -152,7 +152,7 @@ Each AdCP protocol operates within this ecosystem: - **Workflow**: Define requirements → Find inventory → Package with signals ### 💰 Media Buy Protocol -- **Scope**: Works primarily with **decisioning platforms** (DSPs, injective platforms) +- **Scope**: Works primarily with **decisioning platforms** (DSPs, SSPs, ad servers) - **Integration**: Executes campaigns using curated inventory and activated signals - **Workflow**: Set objectives → Execute buys → Optimize performance @@ -240,7 +240,7 @@ Generate and optimize creative assets using AI-powered agents. AI is buying ads. Make sure it can buy yours. -If you operate a signal platform, DSP, or ad tech solution, AdCP lets AI agents discover and purchase your inventory. [Review the Protocol Specifications](/docs/signals/specification) to get started. +If you operate a data platform, DSP, or ad tech solution, AdCP lets AI agents discover and purchase your inventory. [Review the Protocol Specifications](/docs/signals/specification) to get started. ## For Advertisers & Agencies diff --git a/docs/reference/glossary.mdx b/docs/reference/glossary.mdx index e069d90d63..dbe1fec44a 100644 --- a/docs/reference/glossary.mdx +++ b/docs/reference/glossary.mdx @@ -67,11 +67,14 @@ Size unit representing unique device identifiers (cookies, mobile IDs) - typical **Device Type** (Media Buy Protocol) Targeting dimension for platform types: mobile, desktop, tablet, CTV, audio, DOOH. +**Decisioning Platform** +Technical infrastructure that selects which ad to serve at impression time. Receives activated signals and executes campaigns. Examples: DSPs (The Trade Desk), SSPs (Index Exchange, OpenX, PubMatic), ad servers (Google Ad Manager, Kevel). + **DOOH (Digital Out-of-Home)** Digital advertising displayed on screens in public spaces such as billboards, transit stations, airports, and retail locations. Uses CPM or flat_rate pricing with parameters for SOV, duration, and venue targeting. **DSP (Demand-Side Platform)** -Technology platform that allows advertisers to buy advertising inventory programmatically. +A type of decisioning platform that allows advertisers to buy advertising inventory programmatically. ## E @@ -163,11 +166,14 @@ Pricing model based on a percentage of media spend rather than fixed CPM. ## S +**Sales Agent** +An MCP server that exposes publisher inventory for discovery and purchase. Handles product discovery, media buy creation, and campaign management. Examples: Publisher ad servers exposing AdCP interfaces, sales house platforms. + **Screen Time** Total duration an ad was displayed across all DOOH screens, measured in seconds. Used for DOOH delivery reporting. **Seat** -A specific advertising account within a platform, typically representing a brand or campaign. +A specific advertising account within a decisioning platform, typically representing a brand or campaign. **Segment ID** The specific identifier used for signal activation, may differ from signal_id. @@ -175,9 +181,15 @@ The specific identifier used for signal activation, may differ from signal_id. **Share of Voice (SOV)** Percentage of available ad inventory allocated to a specific advertiser in DOOH loops. Expressed as 0.0-1.0 (e.g., 0.15 = 15% SOV). +**Signal Agent** +An MCP server that provides signal discovery and activation services. Enables natural language audience discovery and deploys signals to decisioning platforms. Can be private (owned by a single principal) or marketplace (licensing data to multiple principals). Examples: LiveRamp, Experian, Peer39. + **Size Unit** (Signals Protocol) The measurement type for signal size: individuals, devices, or households. +**SSP (Supply-Side Platform)** +A type of decisioning platform that helps publishers sell advertising inventory programmatically. SSPs connect to multiple demand sources and make ad selection decisions. Examples: Index Exchange, OpenX, PubMatic, Magnite. + ## T **Takeover** diff --git a/docs/signals/specification.mdx b/docs/signals/specification.mdx index 2e511a628e..7ea619d577 100644 --- a/docs/signals/specification.mdx +++ b/docs/signals/specification.mdx @@ -35,10 +35,10 @@ The Signals Activation Protocol operates within the broader [AdCP Ecosystem Laye Every signal discovery request involves two key roles: #### Orchestrator -The platform or system making the API request to the signal platform: +The platform or system making the API request to the signal agent: - **Examples**: Scope3, Claude AI assistant, trading desk platform, campaign management tool - **Responsibilities**: Makes API calls, handles authentication, manages the technical interaction -- **Account**: Has technical credentials and API access to the signal platform +- **Account**: Has technical credentials and API access to the signal agent #### Principal The entity on whose behalf the request is being made: @@ -48,7 +48,7 @@ The entity on whose behalf the request is being made: #### How This Works in Practice -1. **Request Flow**: Orchestrator → Signal Platform (on behalf of Principal) → Decisioning Platform +1. **Request Flow**: Orchestrator → Signal Agent (on behalf of Principal) → Decisioning Platform 2. **Authentication**: Orchestrator authenticates with technical credentials 3. **Authorization**: Principal's identity determines available signals and pricing 4. **Activation**: Signals are activated for Principal's account on the decisioning platform @@ -95,7 +95,7 @@ The entity on whose behalf the request is being made: #### Private Signal Agents Agents owned by the principal with exclusive access: -- **Examples**: Walmart's internal signal platform, retailer first-party data, weather APIs, location providers +- **Examples**: Walmart's internal data systems, retailer first-party data, weather APIs, location providers - **Business Model**: No signal costs (workflow orchestration only) - **Access**: Only visible and accessible to the owning principal - **Discovery**: Not discoverable by other principals @@ -146,9 +146,9 @@ The identifier assigned by the decisioning platform after activation: - **Timing**: Only available after successful activation - **Variability**: May differ by platform and account for the same signal -### Agent vs Data Provider +### Signal Agent vs Data Provider -- **Agent**: The signal platform facilitating access (e.g., LiveRamp, Experian) +- **Signal Agent**: The MCP server facilitating signal access (e.g., LiveRamp, Experian) - **Data Provider**: The original source of the signal data (e.g., Polk, Acxiom, Peer39, weather services, location providers) A signal agent may host segments from multiple data providers in their marketplace. @@ -268,14 +268,14 @@ Some operations (like signal activation) are asynchronous and include: Each MCP session involves two levels of identification: #### Orchestrator Authentication -The technical credentials used by the orchestrator to authenticate with the signal platform: +The technical credentials used by the orchestrator to authenticate with the signal agent: - **API Keys**: Technical access credentials for the orchestrator platform - **Session Scope**: Determines what operations the orchestrator can perform -- **Platform Permissions**: What signal platforms the orchestrator can access +- **Agent Permissions**: What signal agents the orchestrator can access -#### Principal Authorization +#### Principal Authorization The principal's identity determines business-level access and pricing: -- **Account Relationship**: Whether the principal has a direct relationship with the signal platform +- **Account Relationship**: Whether the principal has a direct relationship with the signal agent - **Pricing Tier**: Negotiated rates, marketplace rates, or enterprise discounts - **Signal Access**: Private signals, premium segments, or marketplace-only access - **Billing Account**: Where usage charges are applied From 1760aed5b3d1d2e3ffe61841ed3975d234435731 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 19 Jan 2026 08:45:58 -0500 Subject: [PATCH 37/58] fix: Cross-feed article deduplication and new ChatGPT news sources (#807) - Add cross-feed URL deduplication to prevent same article from multiple feeds appearing as duplicates (e.g., same Adweek article from 5 feeds) - Add new RSS feeds for ChatGPT/OpenAI coverage: The Verge, Wired, VentureBeat, Ars Technica, CNBC Tech - Reduce quality 4 quiet period from 3 hours to 1 hour - Add test case for ’ HTML entity decoding Co-authored-by: Claude Opus 4.5 --- .changeset/itchy-ravens-feel.md | 2 ++ server/src/addie/services/industry-alerts.ts | 6 ++-- server/src/db/industry-feeds-db.ts | 18 +++++++---- .../177_index_perspectives_external_url.sql | 6 ++++ .../178_add_chatgpt_news_sources.sql | 32 +++++++++++++++++++ tests/utils/html-entities.test.ts | 5 +++ 6 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 .changeset/itchy-ravens-feel.md create mode 100644 server/src/db/migrations/177_index_perspectives_external_url.sql create mode 100644 server/src/db/migrations/178_add_chatgpt_news_sources.sql diff --git a/.changeset/itchy-ravens-feel.md b/.changeset/itchy-ravens-feel.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/itchy-ravens-feel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/addie/services/industry-alerts.ts b/server/src/addie/services/industry-alerts.ts index f3478eb065..b4e99fba3d 100644 --- a/server/src/addie/services/industry-alerts.ts +++ b/server/src/addie/services/industry-alerts.ts @@ -3,7 +3,7 @@ * * Sends Slack notifications for industry articles with pacing: * - Quality 5 articles: Post immediately - * - Quality 4 articles: Post only if channel has been quiet for 3+ hours + * - Quality 4 articles: Post only if channel has been quiet for 1+ hour * - Quality < 4: Ignored (not relevant enough for our community) * * Posts one article at a time to encourage engagement. @@ -22,7 +22,7 @@ import { } from '../../db/notification-channels-db.js'; // Minimum hours of quiet before posting a quality 4 article -const QUIET_PERIOD_HOURS = 3; +const QUIET_PERIOD_HOURS = 1; interface ArticleToAlert { id: string; @@ -261,7 +261,7 @@ async function getNextArticleForChannel( * Pacing rules: * - Only posts ONE article per channel per run * - Quality 5: Post immediately - * - Quality 4: Post only if channel has been quiet for 3+ hours + * - Quality 4: Post only if channel has been quiet for 1+ hour * - Quality < 4: Ignored (not posted anywhere) */ export async function processAlerts(): Promise<{ diff --git a/server/src/db/industry-feeds-db.ts b/server/src/db/industry-feeds-db.ts index 220003f71f..426ef8a2b1 100644 --- a/server/src/db/industry-feeds-db.ts +++ b/server/src/db/industry-feeds-db.ts @@ -256,12 +256,18 @@ function generateSlug(title: string, guid: string): string { } /** - * Check if RSS article already exists as a perspective + * Check if RSS article already exists as a perspective. + * Checks both (feed_id, guid) for same-feed duplicates and + * external_url for cross-feed duplicates (same article from different feeds). */ -export async function rssArticleExists(feedId: number, guid: string): Promise { +export async function rssArticleExists(feedId: number, guid: string, externalUrl: string): Promise { const result = await query<{ exists: boolean }>( - `SELECT EXISTS(SELECT 1 FROM perspectives WHERE feed_id = $1 AND guid = $2)`, - [feedId, guid] + `SELECT EXISTS( + SELECT 1 FROM perspectives + WHERE (feed_id = $1 AND guid = $2) + OR external_url = $3 + )`, + [feedId, guid, externalUrl] ); return result.rows[0].exists; } @@ -271,8 +277,8 @@ export async function rssArticleExists(feedId: number, guid: string): Promise { - // First check if we already have this article (by guid) - const existing = await rssArticleExists(article.feed_id, article.guid); + // Check if we already have this article (by guid or URL to catch cross-feed duplicates) + const existing = await rssArticleExists(article.feed_id, article.guid, article.link); if (existing) { return null; } diff --git a/server/src/db/migrations/177_index_perspectives_external_url.sql b/server/src/db/migrations/177_index_perspectives_external_url.sql new file mode 100644 index 0000000000..0fb4e4dbbe --- /dev/null +++ b/server/src/db/migrations/177_index_perspectives_external_url.sql @@ -0,0 +1,6 @@ +-- Add index on external_url for cross-feed deduplication +-- When the same article appears in multiple RSS feeds (e.g., Adweek main + Adweek AI), +-- we need to detect duplicates by URL, not just by (feed_id, guid) +CREATE INDEX IF NOT EXISTS idx_perspectives_external_url + ON perspectives(external_url) + WHERE external_url IS NOT NULL; diff --git a/server/src/db/migrations/178_add_chatgpt_news_sources.sql b/server/src/db/migrations/178_add_chatgpt_news_sources.sql new file mode 100644 index 0000000000..b119297035 --- /dev/null +++ b/server/src/db/migrations/178_add_chatgpt_news_sources.sql @@ -0,0 +1,32 @@ +-- Migration: 178_add_chatgpt_news_sources.sql +-- Add sources covering ChatGPT/OpenAI ads news based on tipsheet.ai coverage +-- Verified RSS feeds for publications covering ChatGPT advertising launch + +-- Use DO block to handle potential duplicates gracefully since +-- industry_feeds uses a partial unique index (not a constraint) +DO $$ +BEGIN + INSERT INTO industry_feeds (name, feed_url, category) + SELECT * FROM (VALUES + ('The Verge', 'https://www.theverge.com/rss/index.xml', 'tech'), + ('Wired', 'https://www.wired.com/feed/rss', 'tech'), + ('VentureBeat', 'https://venturebeat.com/feed', 'tech'), + ('Ars Technica', 'https://feeds.arstechnica.com/arstechnica/index', 'tech'), + ('CNBC Tech', 'https://www.cnbc.com/id/19854910/device/rss/rss.html', 'business') + ) AS new_feeds(name, feed_url, category) + WHERE NOT EXISTS ( + SELECT 1 FROM industry_feeds WHERE feed_url = new_feeds.feed_url + ); +END $$; + +-- Note: The following do NOT have free/accessible RSS feeds: +-- - Financial Times (subscription only) +-- - The Information (subscription only) +-- - CNN Business (no RSS) +-- - Reuters Tech (requires authentication) +-- - Variety (paywalled/limited RSS) +-- +-- Consider adding these as email subscriptions: +-- INSERT INTO industry_feeds (name, feed_url, category, email_slug, accepts_email, is_active) VALUES +-- ('tipsheet.ai', 'email://tipsheet', 'ai', 'tipsheet', true, true) +-- ON CONFLICT (feed_url) DO NOTHING; diff --git a/tests/utils/html-entities.test.ts b/tests/utils/html-entities.test.ts index e4dba056b8..9311d0b9f3 100644 --- a/tests/utils/html-entities.test.ts +++ b/tests/utils/html-entities.test.ts @@ -12,6 +12,11 @@ describe('decodeHtmlEntities', () => { it('decodes decimal character references', () => { expect(decodeHtmlEntities("'")).toBe("'"); expect(decodeHtmlEntities(""")).toBe('"'); + // High code point: right single quote (U+2019) - common in Adweek titles + expect(decodeHtmlEntities("’")).toBe('\u2019'); + expect(decodeHtmlEntities("Read All of ADWEEK’s Year-Ahead")).toBe( + "Read All of ADWEEK\u2019s Year-Ahead" + ); }); it('decodes named entities', () => { From ccdbe189788470d82343503a575be12f460f6945 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 19 Jan 2026 11:12:46 -0500 Subject: [PATCH 38/58] fix: Dedupe alerts by URL to prevent spam from existing duplicates (#808) * fix: Dedupe alerts by URL to prevent spam from existing duplicates The previous fix (1760aed5) prevented new duplicate perspectives from being created, but existing duplicates still matched the alert query. This caused the same article to be posted multiple times. Now the alert query checks if ANY perspective with the same external_url has been alerted to a channel, not just the specific perspective_id. Co-Authored-By: Claude Opus 4.5 * fix: Reduce Addie noise with better deduplication and relevance filtering 1. Alert deduplication: Check external_url across ALL perspectives, not just the specific perspective_id. Prevents spam from cross-feed duplicates. 2. Content relevance: Tighten mentions_agentic detection to require BOTH agentic AI terms AND advertising context. Prevents general AI news (ChatGPT updates, etc.) from being flagged as relevant. 3. Performance: Add composite index on industry_alerts(channel_id, perspective_id) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/alert-dedupe-url.md | 14 +++++ server/src/addie/services/content-curator.ts | 53 +++++++++++++++++-- server/src/addie/services/industry-alerts.ts | 5 +- .../179_index_industry_alerts_channel.sql | 5 ++ 4 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 .changeset/alert-dedupe-url.md create mode 100644 server/src/db/migrations/179_index_industry_alerts_channel.sql diff --git a/.changeset/alert-dedupe-url.md b/.changeset/alert-dedupe-url.md new file mode 100644 index 0000000000..07ab477ba2 --- /dev/null +++ b/.changeset/alert-dedupe-url.md @@ -0,0 +1,14 @@ +--- +"adcontextprotocol": patch +--- + +Fix Addie alert spam and improve content relevance + +**Alert deduplication fix:** +The alert query now checks if ANY perspective with the same external_url +has been alerted to a channel, preventing spam from cross-feed duplicates. + +**Content relevance improvement:** +Tightened `mentions_agentic` detection to require BOTH agentic AI terms +AND advertising context. This prevents general AI news (e.g., ChatGPT updates) +from being flagged as relevant to our agentic advertising community. diff --git a/server/src/addie/services/content-curator.ts b/server/src/addie/services/content-curator.ts index 5d4adb2e1e..9cda3e7a23 100644 --- a/server/src/addie/services/content-curator.ts +++ b/server/src/addie/services/content-curator.ts @@ -562,18 +562,63 @@ async function createOrUpdateRssKnowledge( } /** - * Check if content mentions agentic AI concepts - * Only checks the original article content (not AI-generated summary) to avoid false positives + * Check if content mentions agentic AI in an advertising context + * Requires BOTH agentic terms AND advertising context to avoid flagging + * general AI agent news that isn't relevant to our community */ function checkMentionsAgentic(content: string, _summary: string, tags: string[]): boolean { const text = content.toLowerCase(); + + // Terms that indicate agentic AI const agenticTerms = ['agentic', 'ai agent', 'ai-agent', 'autonomous agent', 'llm agent']; - if (agenticTerms.some(term => text.includes(term))) { + const hasAgenticTerm = agenticTerms.some(term => text.includes(term)); + + // Terms that indicate advertising/marketing context + const advertisingTerms = [ + 'advertis', // matches advertising, advertiser, advertisement + 'programmatic', + 'media buy', + 'media-buy', + 'ad tech', + 'adtech', + 'ad-tech', + 'dsp', + 'ssp', + 'demand-side', + 'supply-side', + 'rtb', + 'real-time bidding', + 'campaign', + 'impression', + 'cpm', + 'cpc', + 'ctr', + 'marketing', + 'brand safety', + 'ad fraud', + 'viewability', + 'attribution', + 'retargeting', + 'audience targeting', + ]; + const hasAdvertisingContext = advertisingTerms.some(term => text.includes(term)); + + // Require BOTH agentic AND advertising context + if (hasAgenticTerm && hasAdvertisingContext) { return true; } - if (tags.some(tag => tag.includes('agent') || tag === 'a2a' || tag === 'mcp')) { + + // Also flag if explicitly tagged as agentic advertising by Claude + const agenticAdTags = ['ai-agents', 'a2a', 'mcp', 'programmatic', 'media-buying']; + const hasAgenticTag = tags.some(tag => agenticAdTags.includes(tag)); + const hasAdTag = tags.some(tag => + ['advertising', 'programmatic', 'media-buying', 'adcp'].includes(tag) + ); + + if (hasAgenticTag && hasAdTag) { return true; } + return false; } diff --git a/server/src/addie/services/industry-alerts.ts b/server/src/addie/services/industry-alerts.ts index b4e99fba3d..edde92a0ec 100644 --- a/server/src/addie/services/industry-alerts.ts +++ b/server/src/addie/services/industry-alerts.ts @@ -238,9 +238,12 @@ async function getNextArticleForChannel( OR k.notification_channel_ids IS NULL OR array_length(k.notification_channel_ids, 1) = 0 ) + -- Dedupe by URL: check if ANY perspective with the same article URL + -- has been alerted to this channel (prevents spam from cross-feed duplicates) AND NOT EXISTS ( SELECT 1 FROM industry_alerts ia - WHERE ia.perspective_id = p.id + JOIN perspectives p2 ON ia.perspective_id = p2.id + WHERE p2.external_url = p.external_url AND ia.channel_id = $2 ) ORDER BY diff --git a/server/src/db/migrations/179_index_industry_alerts_channel.sql b/server/src/db/migrations/179_index_industry_alerts_channel.sql new file mode 100644 index 0000000000..e68e0a55fe --- /dev/null +++ b/server/src/db/migrations/179_index_industry_alerts_channel.sql @@ -0,0 +1,5 @@ +-- Add composite index on industry_alerts for channel-based deduplication queries +-- This supports the NOT EXISTS subquery that checks if any perspective with +-- the same external_url has been alerted to a specific channel +CREATE INDEX IF NOT EXISTS idx_industry_alerts_channel_perspective + ON industry_alerts(channel_id, perspective_id); From 5e7a071e62cae0b83acd411ffa22f39fa4b5e8d8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 19 Jan 2026 11:46:30 -0500 Subject: [PATCH 39/58] fix: Correct AdminNav to AdminSidebar for 401 redirect handling (#810) * fix: Correct AdminNav to AdminSidebar for 401 redirect handling When users accessed admin pages while logged out, the API returned 401 but the JavaScript tried to call window.AdminNav.redirectToLogin() which doesn't exist. The correct object is window.AdminSidebar. This caused the catch block to show "Failed to load admin data" instead of properly redirecting to the login page. Fixed in 14 admin HTML files. Also removed dead code in admin-org-detail that referenced AdminNav.user which never existed. Co-Authored-By: Claude Opus 4.5 * feat: Add AdminSidebar.fetch() for shared 401 handling Added a shared fetch wrapper that automatically handles 401 redirects, so pages don't need to duplicate the 401 check. Usage: // Before (repeated in every page): const response = await fetch('/api/admin/...'); if (!response.ok) { if (response.status === 401) { window.AdminSidebar.redirectToLogin(); return; } throw new Error('...'); } // After (401 handled automatically): const response = await AdminSidebar.fetch('/api/admin/...'); if (!response.ok) { throw new Error('...'); } Updated admin.html as an example. Other pages can be migrated gradually. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/tiny-groups-share.md | 2 ++ server/public/admin-account-detail.html | 6 ++-- server/public/admin-agreements.html | 2 +- server/public/admin-domain-discovery.html | 6 ++-- server/public/admin-email.html | 2 +- server/public/admin-feeds.html | 2 +- server/public/admin-meetings.html | 2 +- server/public/admin-members.html | 10 +++--- .../public/admin-notification-channels.html | 2 +- server/public/admin-org-detail.html | 35 ++++++++----------- server/public/admin-products.html | 6 ++-- server/public/admin-prospects.html | 16 ++++----- server/public/admin-sidebar.js | 14 +++++++- server/public/admin-users.html | 2 +- server/public/admin-working-groups.html | 2 +- server/public/admin.html | 6 +--- 16 files changed, 60 insertions(+), 55 deletions(-) create mode 100644 .changeset/tiny-groups-share.md diff --git a/.changeset/tiny-groups-share.md b/.changeset/tiny-groups-share.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/tiny-groups-share.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/admin-account-detail.html b/server/public/admin-account-detail.html index d37d867e91..67611c866d 100644 --- a/server/public/admin-account-detail.html +++ b/server/public/admin-account-detail.html @@ -2338,7 +2338,7 @@

({})); @@ -2402,7 +2402,7 @@

Edit Agreement

const response = await fetch('/api/admin/agreements'); if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to load agreements'); diff --git a/server/public/admin-domain-discovery.html b/server/public/admin-domain-discovery.html index beba880490..b7d1d3f686 100644 --- a/server/public/admin-domain-discovery.html +++ b/server/public/admin-domain-discovery.html @@ -507,7 +507,7 @@

No unmapped domains found

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } const error = await response.json(); @@ -548,7 +548,7 @@

No unmapped domains found

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Bulk enrichment failed'); @@ -627,7 +627,7 @@

No unmapped domains found

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } const error = await response.json(); diff --git a/server/public/admin-email.html b/server/public/admin-email.html index 912ec9cb60..cc92292404 100644 --- a/server/public/admin-email.html +++ b/server/public/admin-email.html @@ -430,7 +430,7 @@

Recent Email Sends

const response = await fetch('/api/admin/email/stats'); if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to load stats'); diff --git a/server/public/admin-feeds.html b/server/public/admin-feeds.html index 0ccfd7e690..21cb704ecf 100644 --- a/server/public/admin-feeds.html +++ b/server/public/admin-feeds.html @@ -678,7 +678,7 @@

Delete Feed

const response = await fetch('/api/admin/feeds'); if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to load feeds'); diff --git a/server/public/admin-meetings.html b/server/public/admin-meetings.html index 00e93c303b..c61c6f57e4 100644 --- a/server/public/admin-meetings.html +++ b/server/public/admin-meetings.html @@ -426,7 +426,7 @@

Meeting Details

const response = await fetch('/api/admin/working-groups'); if (!response.ok) { if (response.status === 401) { - window.AdminNav?.redirectToLogin(); + window.AdminSidebar?.redirectToLogin(); return; } throw new Error('Failed to load working groups'); diff --git a/server/public/admin-members.html b/server/public/admin-members.html index da41e33aec..e5a3e3f7bd 100644 --- a/server/public/admin-members.html +++ b/server/public/admin-members.html @@ -462,7 +462,7 @@

This action cannot be undone

console.log('[admin-members] Response status:', response.status); if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to load members'); @@ -709,7 +709,7 @@

This action cannot be undone

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } const errorData = await response.json().catch(() => ({ message: response.statusText })); @@ -749,7 +749,7 @@

This action cannot be undone

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to sync member data'); @@ -818,7 +818,7 @@

This action cannot be undone

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to load payment history'); @@ -944,7 +944,7 @@

This action cannot be undone

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } diff --git a/server/public/admin-notification-channels.html b/server/public/admin-notification-channels.html index ad6e3d4d7f..a912d25b74 100644 --- a/server/public/admin-notification-channels.html +++ b/server/public/admin-notification-channels.html @@ -418,7 +418,7 @@

Delete Channel

const response = await fetch('/api/admin/notification-channels'); if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to load channels'); diff --git a/server/public/admin-org-detail.html b/server/public/admin-org-detail.html index 0676d408ae..f3e33ebac7 100644 --- a/server/public/admin-org-detail.html +++ b/server/public/admin-org-detail.html @@ -1489,7 +1489,7 @@

Set Discount

if (!orgResponse.ok) { if (orgResponse.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } if (orgResponse.status === 404) { @@ -1505,11 +1505,6 @@

Set Discount

domainsData = domainsResponse.ok ? await domainsResponse.json() : { domains: [], workos_domains: [] }; teamMembers = teamResponse.ok ? await teamResponse.json() : []; - // Get current user ID from AdminNav if available - if (window.AdminNav && window.AdminNav.user) { - currentUserId = window.AdminNav.user.id; - } - renderOrganization(); renderOwnerSelector(); populateOwnerDropdowns(); @@ -1799,7 +1794,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } const data = await response.json(); @@ -2231,7 +2226,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to save changes'); @@ -2301,7 +2296,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to save activity'); @@ -2355,7 +2350,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to save follow-up'); @@ -2383,7 +2378,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to complete step'); @@ -2531,7 +2526,7 @@

Set Discount

if (response && !response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to update owner'); @@ -2570,7 +2565,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to update watch status'); @@ -2730,7 +2725,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error(await getErrorMessage(response, 'Failed to add domain')); @@ -2760,7 +2755,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error(await getErrorMessage(response, 'Failed to set primary domain')); @@ -2790,7 +2785,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error(await getErrorMessage(response, 'Failed to remove domain')); @@ -2822,7 +2817,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error(await getErrorMessage(response, 'Failed to verify domain')); @@ -2954,7 +2949,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to save interest level'); @@ -3186,7 +3181,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error(await getErrorMessage(response, 'Failed to link domain')); @@ -4260,7 +4255,7 @@

Set Discount

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } if (response.status === 503) { diff --git a/server/public/admin-products.html b/server/public/admin-products.html index 71a2ce3ee5..a50a0ed04b 100644 --- a/server/public/admin-products.html +++ b/server/public/admin-products.html @@ -557,7 +557,7 @@

Archive Product

const response = await fetch('/api/admin/products'); if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to load products'); @@ -915,7 +915,7 @@

Archive Product

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } const result = await response.json(); @@ -960,7 +960,7 @@

Archive Product

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to archive product'); diff --git a/server/public/admin-prospects.html b/server/public/admin-prospects.html index 94aa32d19d..fb6762c8a1 100644 --- a/server/public/admin-prospects.html +++ b/server/public/admin-prospects.html @@ -1390,7 +1390,7 @@

Bulk Import Prospects

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to load prospects'); @@ -1772,7 +1772,7 @@

No unmapped domains found

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } const error = await response.json(); @@ -1814,7 +1814,7 @@

No unmapped domains found

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Bulk enrichment failed'); @@ -1852,7 +1852,7 @@

No unmapped domains found

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } const error = await response.json(); @@ -1944,7 +1944,7 @@

No unmapped domains found

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } const error = await response.json(); @@ -2161,7 +2161,7 @@

Search for Companies

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } const error = await response.json(); @@ -2262,7 +2262,7 @@

No Results Found

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } if (response.status === 409) { @@ -3283,7 +3283,7 @@

No accounts found

if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } const error = await response.json(); diff --git a/server/public/admin-sidebar.js b/server/public/admin-sidebar.js index 9f650fe630..8035b13066 100644 --- a/server/public/admin-sidebar.js +++ b/server/public/admin-sidebar.js @@ -386,6 +386,17 @@ window.location.href = `/auth/login?return_to=${returnUrl}`; } + // Shared fetch wrapper that handles 401 redirects automatically + async function adminFetch(url, options = {}) { + const response = await fetch(url, options); + if (response.status === 401) { + redirectToLogin(); + // Return a never-resolving promise to prevent further processing + return new Promise(() => {}); + } + return response; + } + // Auto-initialize only on admin pages function shouldInitialize() { return window.location.pathname.startsWith('/admin'); @@ -405,6 +416,7 @@ init, toggleSidebar, closeSidebar, - redirectToLogin + redirectToLogin, + fetch: adminFetch }; })(); diff --git a/server/public/admin-users.html b/server/public/admin-users.html index ba1b46f2da..1831b93363 100644 --- a/server/public/admin-users.html +++ b/server/public/admin-users.html @@ -996,7 +996,7 @@

Different Approach

if (!groupsResponse.ok) { if (groupsResponse.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to load working groups'); diff --git a/server/public/admin-working-groups.html b/server/public/admin-working-groups.html index c94490c3e3..900fd38023 100644 --- a/server/public/admin-working-groups.html +++ b/server/public/admin-working-groups.html @@ -633,7 +633,7 @@

Delete Committee

const response = await fetch('/api/admin/working-groups'); if (!response.ok) { if (response.status === 401) { - window.AdminNav.redirectToLogin(); + window.AdminSidebar.redirectToLogin(); return; } throw new Error('Failed to load working groups'); diff --git a/server/public/admin.html b/server/public/admin.html index e430365383..ed7b21e245 100644 --- a/server/public/admin.html +++ b/server/public/admin.html @@ -789,12 +789,8 @@

Dashboard

async function loadStats() { try { - const response = await fetch('/api/admin/stats'); + const response = await AdminSidebar.fetch('/api/admin/stats'); if (!response.ok) { - if (response.status === 401) { - window.AdminNav.redirectToLogin(); - return; - } throw new Error('Failed to load stats'); } From c1a6e3d554f16c29b0101fbd8a8e1554921514d9 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 19 Jan 2026 12:19:55 -0500 Subject: [PATCH 40/58] fix: Set founding member status for new profiles before cutoff date (#812) The original migration 147 only set is_founding_member=TRUE for profiles that existed at migration time. Profiles created afterward were not automatically flagged, even though they joined before the April 2026 cutoff. Changes: - Update createProfile to set is_founding_member based on cutoff date - Add migration 180 to backfill existing profiles Co-authored-by: Claude Opus 4.5 --- .changeset/itchy-gifts-go.md | 10 ++++++++++ server/src/db/member-db.ts | 4 ++-- .../db/migrations/180_fix_founding_member_status.sql | 8 ++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 .changeset/itchy-gifts-go.md create mode 100644 server/src/db/migrations/180_fix_founding_member_status.sql diff --git a/.changeset/itchy-gifts-go.md b/.changeset/itchy-gifts-go.md new file mode 100644 index 0000000000..cf7ba53df8 --- /dev/null +++ b/.changeset/itchy-gifts-go.md @@ -0,0 +1,10 @@ +--- +--- + +Fix founding member badge not displaying for members who joined after initial migration ran. + +The original migration 147 only set `is_founding_member = TRUE` for profiles that existed at migration time. Profiles created afterward were not automatically flagged, even though they joined before the April 2026 cutoff. + +Changes: +- Update `createProfile` to set founding member status based on cutoff date +- Add migration 180 to backfill existing profiles that should have the flag diff --git a/server/src/db/member-db.ts b/server/src/db/member-db.ts index 551402affb..1937c65e7c 100644 --- a/server/src/db/member-db.ts +++ b/server/src/db/member-db.ts @@ -34,8 +34,8 @@ export class MemberDatabase { contact_email, contact_website, contact_phone, linkedin_url, twitter_url, offerings, agents, publishers, headquarters, markets, metadata, tags, - is_public, show_in_carousel - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) + is_public, show_in_carousel, is_founding_member + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, NOW() < '2026-04-01'::timestamptz) RETURNING *`, [ input.workos_organization_id, diff --git a/server/src/db/migrations/180_fix_founding_member_status.sql b/server/src/db/migrations/180_fix_founding_member_status.sql new file mode 100644 index 0000000000..8e62bebf6e --- /dev/null +++ b/server/src/db/migrations/180_fix_founding_member_status.sql @@ -0,0 +1,8 @@ +-- Fix founding member status for profiles created before cutoff +-- The original migration 147 only ran once, so profiles created afterwards +-- were not automatically flagged as founding members + +UPDATE member_profiles +SET is_founding_member = TRUE +WHERE created_at < '2026-04-01'::timestamptz + AND is_founding_member = FALSE; From ab3a0d0e8f7fe61b61900235763b252e6594b162 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 03:31:50 -0500 Subject: [PATCH 41/58] feat: Add user role management to admin account detail page (#814) - Fix Users section collapsed by default - removed collapsed class - Add role column to organization_memberships table (migration 181) - Update WorkOS webhooks to cache role from membership events - Add PUT /api/admin/accounts/:orgId/members/:userId/role endpoint - Include role in account detail API response - Add role dropdown UI (Member/Admin/Owner options) - Owner role shown but disabled (cannot be assigned via dropdown) The role update endpoint: - Validates role is assignable (admin or member only) - Verifies membership belongs to org via WorkOS API - Updates both WorkOS and local cache - Logs admin email and previous role for audit trail Co-authored-by: Claude Opus 4.5 --- .changeset/shy-rivers-hug.md | 2 + server/public/admin-account-detail.html | 58 ++++++++- .../181_organization_membership_role.sql | 8 ++ server/src/routes/admin/accounts.ts | 118 +++++++++++++++++- server/src/routes/workos-webhooks.ts | 8 +- 5 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 .changeset/shy-rivers-hug.md create mode 100644 server/src/db/migrations/181_organization_membership_role.sql diff --git a/.changeset/shy-rivers-hug.md b/.changeset/shy-rivers-hug.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/shy-rivers-hug.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/admin-account-detail.html b/server/public/admin-account-detail.html index 67611c866d..1e32ddf505 100644 --- a/server/public/admin-account-detail.html +++ b/server/public/admin-account-detail.html @@ -452,6 +452,25 @@ max-height: 0 !important; } + /* Role select dropdown */ + .role-select { + font-size: var(--text-xs); + 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); + color: var(--color-text-secondary); + cursor: pointer; + margin-left: var(--space-2); + } + .role-select:hover { + border-color: var(--color-brand); + } + .role-select:disabled { + opacity: 0.6; + cursor: not-allowed; + } + /* Quick follow-up form */ .quick-followup { display: flex; @@ -811,12 +830,12 @@

Member Insights

-

Users

- + ${stripeConflictHtml} ${warningsHtml} ${changesHtml} @@ -1577,7 +1605,8 @@

Merge Preview${secondaryOrgIds.length > 1 ? ` (1 of ${secondaryOrgIds.length

@@ -1590,14 +1619,15 @@

Merge Preview${secondaryOrgIds.length > 1 ? ` (1 of ${secondaryOrgIds.length const primaryId = decodeURIComponent(this.dataset.primaryId); const secondaryId = decodeURIComponent(this.dataset.secondaryId); const remainingIds = this.dataset.remainingIds ? this.dataset.remainingIds.split(',').map(decodeURIComponent).join(',') : ''; - confirmMerge(primaryId, secondaryId, remainingIds); + const hasStripeConflict = this.dataset.hasStripeConflict === 'true'; + confirmMerge(primaryId, secondaryId, remainingIds, hasStripeConflict); }); } catch (error) { alert('Failed to load merge preview: ' + error.message); } } - async function confirmMerge(primaryOrgId, secondaryOrgId, remainingOrgIds) { + async function confirmMerge(primaryOrgId, secondaryOrgId, remainingOrgIds, hasStripeConflict = false) { // Disable all buttons immediately to prevent double-clicks document.querySelectorAll('#mergeModal button, #confirmMergeBtn, #continueMergeBtn').forEach(btn => { btn.disabled = true; @@ -1611,6 +1641,13 @@

Merge Preview${secondaryOrgIds.length > 1 ? ` (1 of ${secondaryOrgIds.length return; } + // Get Stripe customer resolution if there's a conflict + let stripeCustomerResolution = null; + if (hasStripeConflict) { + const selected = document.querySelector('input[name="stripe_resolution"]:checked'); + stripeCustomerResolution = selected ? selected.value : 'keep_primary'; + } + mergeModalElement.innerHTML = `
@@ -1621,10 +1658,14 @@

Merging organizations...

`; try { + const body = { primary_org_id: primaryOrgId, secondary_org_id: secondaryOrgId }; + if (stripeCustomerResolution) { + body.stripe_customer_resolution = stripeCustomerResolution; + } const response = await fetch('/api/admin/cleanup/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ primary_org_id: primaryOrgId, secondary_org_id: secondaryOrgId }) + body: JSON.stringify(body) }); if (!response.ok) { diff --git a/server/public/admin-prospects.html b/server/public/admin-prospects.html index fb6762c8a1..7c2039b19c 100644 --- a/server/public/admin-prospects.html +++ b/server/public/admin-prospects.html @@ -2714,6 +2714,33 @@

Loading merge preview...

` : ''; + // Stripe customer conflict handling + const stripeConflict = preview.stripe_customer_conflict || {}; + const stripeConflictHtml = stripeConflict.has_conflict ? ` +
+ 🔴 Stripe Customer Conflict +

Both organizations have Stripe customers. Choose which to keep:

+
    +
  • Primary: ${escapeHtml(stripeConflict.primary_customer_id || 'None')}
  • +
  • Secondary: ${escapeHtml(stripeConflict.secondary_customer_id || 'None')}
  • +
+
+ + + +
+
+ ` : ''; + const changesHtml = preview.estimated_changes.length > 0 ? `
Data to be moved: @@ -2766,12 +2793,13 @@

Merge Preview

+ ${stripeConflictHtml} ${warningsHtml} ${changesHtml}
-
@@ -2784,11 +2812,18 @@

Merge Preview

} // Execute merge operation - async function executeMerge(primaryOrgId, secondaryOrgId) { + async function executeMerge(primaryOrgId, secondaryOrgId, hasStripeConflict = false) { if (!confirm('Are you sure you want to merge these organizations? This action cannot be undone.')) { return; } + // Get Stripe customer resolution if there's a conflict + let stripeCustomerResolution = null; + if (hasStripeConflict) { + const selected = document.querySelector('input[name="stripe_resolution"]:checked'); + stripeCustomerResolution = selected ? selected.value : 'keep_primary'; + } + const modal = document.getElementById('aiAnalysisModal'); modal.innerHTML = `
@@ -2800,10 +2835,14 @@

Merging organizations...

`; try { + const body = { primary_org_id: primaryOrgId, secondary_org_id: secondaryOrgId }; + if (stripeCustomerResolution) { + body.stripe_customer_resolution = stripeCustomerResolution; + } const response = await fetch('/api/admin/cleanup/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ primary_org_id: primaryOrgId, secondary_org_id: secondaryOrgId }) + body: JSON.stringify(body) }); if (!response.ok) { diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index e98139d296..9e6246de66 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -65,7 +65,7 @@ import { createAndSendInvoice, type BillingProduct, } from '../../billing/stripe-client.js'; -import { mergeOrganizations, previewMerge } from '../../db/org-merge-db.js'; +import { mergeOrganizations, previewMerge, type StripeCustomerResolution } from '../../db/org-merge-db.js'; import { workos } from '../../auth/workos-client.js'; import { DomainDataState } from '@workos-inc/node'; import { processInteraction, type InteractionContext } from '../services/interaction-analyzer.js'; @@ -742,14 +742,19 @@ Returns a list of organizations with open or draft invoices.`, // ============================================ { name: 'merge_organizations', - description: 'Merge duplicate organization records. Destructive, cannot be undone. Preview first with preview=true.', - usage_hints: 'Preview first, then execute with preview=false.', + description: 'Merge duplicate organization records. Destructive, cannot be undone. Preview first with preview=true. If both orgs have Stripe customers, you must specify stripe_customer_resolution.', + usage_hints: 'Preview first, then execute with preview=false. Check preview for Stripe customer conflicts.', input_schema: { type: 'object' as const, properties: { primary_org_id: { type: 'string', description: 'Org ID to keep (data merged into)' }, secondary_org_id: { type: 'string', description: 'Org ID to remove (data moved from)' }, preview: { type: 'boolean', description: 'Show preview only (default: true)' }, + stripe_customer_resolution: { + type: 'string', + enum: ['keep_primary', 'use_secondary', 'keep_both_unlinked'], + description: 'Required if both orgs have Stripe customers. keep_primary=keep primary Stripe customer, use_secondary=replace with secondary Stripe customer, keep_both_unlinked=unlink both for manual resolution', + }, }, required: ['primary_org_id', 'secondary_org_id'], }, @@ -3665,6 +3670,7 @@ Use add_committee_leader to assign a leader.`; const primaryOrgId = input.primary_org_id as string; const secondaryOrgId = input.secondary_org_id as string; const preview = input.preview !== false; // Default to preview mode for safety + const stripeCustomerResolution = input.stripe_customer_resolution as StripeCustomerResolution | undefined; if (!primaryOrgId || !secondaryOrgId) { return '❌ Both primary_org_id and secondary_org_id are required.'; @@ -3725,6 +3731,25 @@ Use add_committee_leader to assign a leader.`; response += `- Secondary org will be deleted from WorkOS\n`; } + // Stripe customer conflict section + const stripeConflict = previewResult.stripe_customer_conflict; + if (stripeConflict.has_conflict) { + response += `\n### 🔴 Stripe Customer Conflict\n`; + response += `Both organizations have Stripe customers that need resolution:\n`; + response += `- **Primary:** ${stripeConflict.primary_customer_id}\n`; + response += `- **Secondary:** ${stripeConflict.secondary_customer_id}\n\n`; + response += `You must specify \`stripe_customer_resolution\` to proceed:\n`; + response += `- \`keep_primary\`: Keep primary's Stripe customer, orphan secondary's\n`; + response += `- \`use_secondary\`: Replace primary's customer with secondary's\n`; + response += `- \`keep_both_unlinked\`: Unlink both for manual resolution\n`; + } else if (stripeConflict.secondary_customer_id && !stripeConflict.primary_customer_id) { + response += `\n### Stripe\n`; + response += `Secondary org's Stripe customer (${stripeConflict.secondary_customer_id}) will be moved to primary org.\n`; + } else if (stripeConflict.primary_customer_id) { + response += `\n### Stripe\n`; + response += `Primary org's Stripe customer (${stripeConflict.primary_customer_id}) will be kept.\n`; + } + if (previewResult.warnings.length > 0) { response += `\n### Warnings\n`; for (const warning of previewResult.warnings) { @@ -3733,7 +3758,11 @@ Use add_committee_leader to assign a leader.`; } response += `\n---\n`; - response += `_This is a preview. To execute the merge, call merge_organizations again with preview=false._`; + if (stripeConflict.requires_resolution) { + response += `_This is a preview. To execute the merge, call merge_organizations with preview=false and stripe_customer_resolution set._`; + } else { + response += `_This is a preview. To execute the merge, call merge_organizations again with preview=false._`; + } return response; } else { @@ -3775,7 +3804,12 @@ Use add_committee_leader to assign a leader.`; // Step 2: Execute the database merge const mergedBy = memberContext?.workos_user?.workos_user_id || 'addie-admin'; - const result = await mergeOrganizations(primaryOrgId, secondaryOrgId, mergedBy); + const result = await mergeOrganizations( + primaryOrgId, + secondaryOrgId, + mergedBy, + stripeCustomerResolution ? { stripeCustomerResolution } : undefined + ); // Step 3: Add users to primary org in WorkOS let workosAdded = 0; @@ -3845,6 +3879,22 @@ Use add_committee_leader to assign a leader.`; } } + // Stripe customer action + if (result.stripe_customer_action && result.stripe_customer_action !== 'none') { + response += `\n### Stripe\n`; + switch (result.stripe_customer_action) { + case 'kept_primary': + response += `- ✅ Kept primary org's Stripe customer\n`; + break; + case 'moved_from_secondary': + response += `- 🔄 Moved Stripe customer from secondary to primary org\n`; + break; + case 'conflict_unresolved': + response += `- ⚠️ Both Stripe customers were unlinked - manual linking required\n`; + break; + } + } + if (result.prospect_notes_merged) { response += `\n📝 Prospect notes were merged.\n`; } diff --git a/server/src/db/org-merge-db.ts b/server/src/db/org-merge-db.ts index 608ebae918..20ead8c497 100644 --- a/server/src/db/org-merge-db.ts +++ b/server/src/db/org-merge-db.ts @@ -27,21 +27,28 @@ export interface MergeSummary { }[]; prospect_notes_merged: boolean; enrichment_data_preserved: boolean; + stripe_customer_action: 'kept_primary' | 'moved_from_secondary' | 'none' | 'conflict_unresolved' | null; warnings: string[]; } +export type StripeCustomerResolution = 'keep_primary' | 'use_secondary' | 'keep_both_unlinked'; + /** * Merge two organizations, moving all data from secondary to primary * * @param primaryOrgId - The organization to keep (merge into) * @param secondaryOrgId - The organization to remove (merge from) * @param mergedBy - WorkOS user ID of person initiating the merge + * @param options.stripeCustomerResolution - How to handle stripe_customer_id conflict * @returns Summary of the merge operation */ export async function mergeOrganizations( primaryOrgId: string, secondaryOrgId: string, - mergedBy: string + mergedBy: string, + options?: { + stripeCustomerResolution?: StripeCustomerResolution; + } ): Promise { const pool = getPool(); const client = await pool.connect(); @@ -54,6 +61,7 @@ export async function mergeOrganizations( tables_merged: [], prospect_notes_merged: false, enrichment_data_preserved: false, + stripe_customer_action: null, warnings: [], }; @@ -69,6 +77,7 @@ export async function mergeOrganizations( // Validate both organizations exist and fetch all needed fields const orgsResult = await client.query( `SELECT workos_organization_id, name, is_personal, prospect_notes, + stripe_customer_id, enrichment_at, enrichment_industry, enrichment_sub_industry, enrichment_employee_count, enrichment_revenue, enrichment_revenue_range, enrichment_country, enrichment_city, enrichment_description @@ -98,6 +107,80 @@ export async function mergeOrganizations( 'Merging organizations' ); + // ===================================================== + // 0. Handle Stripe customer ID + // ===================================================== + const primaryHasStripe = !!primaryOrg.stripe_customer_id; + const secondaryHasStripe = !!secondaryOrg.stripe_customer_id; + + if (primaryHasStripe && secondaryHasStripe) { + // Both orgs have Stripe customers - require explicit resolution + const resolution = options?.stripeCustomerResolution; + if (!resolution) { + throw new Error( + `Both organizations have Stripe customers (primary: ${primaryOrg.stripe_customer_id}, secondary: ${secondaryOrg.stripe_customer_id}). ` + + `Provide stripeCustomerResolution option: 'keep_primary', 'use_secondary', or 'keep_both_unlinked'` + ); + } + + if (resolution === 'keep_primary') { + // Keep primary's customer, unlink secondary's (will be deleted with the org) + summary.stripe_customer_action = 'kept_primary'; + summary.warnings.push( + `Secondary org's Stripe customer ${secondaryOrg.stripe_customer_id} will be orphaned - may need manual cleanup in Stripe` + ); + logger.info( + { primaryCustomer: primaryOrg.stripe_customer_id, orphanedCustomer: secondaryOrg.stripe_customer_id }, + 'Keeping primary Stripe customer, secondary customer will be orphaned' + ); + } else if (resolution === 'use_secondary') { + // Replace primary's customer with secondary's + await client.query( + `UPDATE organizations SET stripe_customer_id = $1 WHERE workos_organization_id = $2`, + [secondaryOrg.stripe_customer_id, primaryOrgId] + ); + summary.stripe_customer_action = 'moved_from_secondary'; + summary.warnings.push( + `Primary org's previous Stripe customer ${primaryOrg.stripe_customer_id} was replaced - may need manual cleanup in Stripe` + ); + logger.info( + { newCustomer: secondaryOrg.stripe_customer_id, orphanedCustomer: primaryOrg.stripe_customer_id }, + 'Replaced primary Stripe customer with secondary' + ); + } else if (resolution === 'keep_both_unlinked') { + // Unlink primary's customer, don't transfer secondary's + await client.query( + `UPDATE organizations SET stripe_customer_id = NULL WHERE workos_organization_id = $1`, + [primaryOrgId] + ); + summary.stripe_customer_action = 'conflict_unresolved'; + summary.warnings.push( + `Both Stripe customers (${primaryOrg.stripe_customer_id}, ${secondaryOrg.stripe_customer_id}) were unlinked - manual linking required` + ); + logger.info( + { orphanedCustomers: [primaryOrg.stripe_customer_id, secondaryOrg.stripe_customer_id] }, + 'Both Stripe customers unlinked, manual resolution required' + ); + } + } else if (secondaryHasStripe && !primaryHasStripe) { + // Only secondary has Stripe - move it to primary + await client.query( + `UPDATE organizations SET stripe_customer_id = $1 WHERE workos_organization_id = $2`, + [secondaryOrg.stripe_customer_id, primaryOrgId] + ); + summary.stripe_customer_action = 'moved_from_secondary'; + logger.info( + { stripeCustomerId: secondaryOrg.stripe_customer_id }, + 'Moved Stripe customer from secondary to primary org' + ); + } else if (primaryHasStripe) { + // Only primary has Stripe - keep it + summary.stripe_customer_action = 'kept_primary'; + } else { + // Neither has Stripe + summary.stripe_customer_action = 'none'; + } + // ===================================================== // 1. Merge organization_memberships // ===================================================== @@ -657,13 +740,19 @@ export async function previewMerge( table_name: string; rows_to_move: number; }[]; + stripe_customer_conflict: { + has_conflict: boolean; + primary_customer_id: string | null; + secondary_customer_id: string | null; + requires_resolution: boolean; + }; warnings: string[]; }> { const pool = getPool(); - // Get organization names and personal workspace status + // Get organization names, personal workspace status, and stripe info const orgsResult = await pool.query( - `SELECT workos_organization_id, name, is_personal FROM organizations + `SELECT workos_organization_id, name, is_personal, stripe_customer_id FROM organizations WHERE workos_organization_id = ANY($1)`, [[primaryOrgId, secondaryOrgId]] ); @@ -798,9 +887,19 @@ export async function previewMerge( warnings.push('Both organizations have member profiles - secondary profile will be deleted'); } - // Stripe-specific warnings (these need manual handling regardless of which is primary) - if (secondaryData?.stripe_customer_id) { - warnings.push(`⚠️ STRIPE: Secondary org has Stripe customer ${secondaryData.stripe_customer_id} - this will NOT be merged automatically. Manual Stripe cleanup required.`); + // Build Stripe customer conflict info + const primaryCustomerId = primaryData?.stripe_customer_id || primaryOrg.stripe_customer_id || null; + const secondaryCustomerId = secondaryData?.stripe_customer_id || secondaryOrg.stripe_customer_id || null; + const bothHaveStripe = !!primaryCustomerId && !!secondaryCustomerId; + + // Stripe-specific warnings + if (bothHaveStripe) { + warnings.push( + `🔴 STRIPE CONFLICT: Both orgs have Stripe customers (primary: ${primaryCustomerId}, secondary: ${secondaryCustomerId}). ` + + `You must specify stripeCustomerResolution: 'keep_primary', 'use_secondary', or 'keep_both_unlinked'` + ); + } else if (secondaryCustomerId && !primaryCustomerId) { + warnings.push(`⚠️ STRIPE: Secondary org's Stripe customer ${secondaryCustomerId} will be moved to primary org`); } if (secondaryData?.stripe_subscription_id) { @@ -822,6 +921,12 @@ export async function previewMerge( primary_org: { id: primaryOrg.workos_organization_id, name: primaryOrg.name }, secondary_org: { id: secondaryOrg.workos_organization_id, name: secondaryOrg.name }, estimated_changes: estimatedChanges, + stripe_customer_conflict: { + has_conflict: bothHaveStripe, + primary_customer_id: primaryCustomerId, + secondary_customer_id: secondaryCustomerId, + requires_resolution: bothHaveStripe, + }, warnings, }; } diff --git a/server/src/db/organization-db.ts b/server/src/db/organization-db.ts index ed15b2bdc3..cfadb2495d 100644 --- a/server/src/db/organization-db.ts +++ b/server/src/db/organization-db.ts @@ -518,6 +518,51 @@ export class OrganizationDatabase { return conflicts; } + /** + * Find Stripe customer mismatches where an org has a different customer ID in the DB + * than what Stripe metadata says it should have. + * + * This detects the case where: + * - Org has stripe_customer_id = cus_X in the database + * - But a different Stripe customer (cus_Y) has metadata saying it belongs to that org + * + * This often indicates an org has multiple Stripe customers (e.g., someone created + * a new customer instead of using the existing one). + */ + async findStripeCustomerMismatches(): Promise> { + const mismatches: Array<{ + org_id: string; + org_name: string; + db_customer_id: string; + stripe_metadata_customer_id: string; + }> = []; + + // Get all Stripe customers with org metadata + const stripeCustomers = await listCustomersWithOrgIds(); + + for (const { stripeCustomerId, workosOrgId } of stripeCustomers) { + const localOrg = await this.getOrganization(workosOrgId); + + // Check if org exists and has a DIFFERENT customer ID than Stripe metadata suggests + if (localOrg && localOrg.stripe_customer_id && localOrg.stripe_customer_id !== stripeCustomerId) { + // Mismatch: Org has cus_X in DB, but Stripe customer cus_Y claims to belong to this org + mismatches.push({ + org_id: workosOrgId, + org_name: localOrg.name, + db_customer_id: localOrg.stripe_customer_id, + stripe_metadata_customer_id: stripeCustomerId, + }); + } + } + + return mismatches; + } + /** * Get subscription info for an organization * Tries Stripe first if customer ID is available, falls back to local DB fields diff --git a/server/src/routes/admin/cleanup.ts b/server/src/routes/admin/cleanup.ts index 24afee2c36..ac410735de 100644 --- a/server/src/routes/admin/cleanup.ts +++ b/server/src/routes/admin/cleanup.ts @@ -11,7 +11,7 @@ import { isCleanupConfigured, } from "../../services/prospect-cleanup.js"; import { isLushaConfigured } from "../../services/lusha.js"; -import { mergeOrganizations, previewMerge } from "../../db/org-merge-db.js"; +import { mergeOrganizations, previewMerge, StripeCustomerResolution } from "../../db/org-merge-db.js"; import { getPool } from "../../db/client.js"; const logger = createLogger("admin-cleanup"); @@ -257,6 +257,7 @@ export function setupCleanupRoutes(apiRouter: Router): void { has_notes: !!secondaryOrg.prospect_notes, }, estimated_changes: preview.estimated_changes, + stripe_customer_conflict: preview.stripe_customer_conflict, warnings: preview.warnings, }); } catch (error) { @@ -272,7 +273,7 @@ export function setupCleanupRoutes(apiRouter: Router): void { // POST /api/admin/cleanup/merge - Execute organization merge apiRouter.post("/cleanup/merge", requireAuth, requireAdmin, async (req, res) => { try { - const { primary_org_id, secondary_org_id } = req.body; + const { primary_org_id, secondary_org_id, stripe_customer_resolution } = req.body; if (!primary_org_id || !secondary_org_id) { return res.status(400).json({ @@ -281,18 +282,28 @@ export function setupCleanupRoutes(apiRouter: Router): void { }); } + // Validate stripe_customer_resolution if provided + const validResolutions: StripeCustomerResolution[] = ['keep_primary', 'use_secondary', 'keep_both_unlinked']; + if (stripe_customer_resolution && !validResolutions.includes(stripe_customer_resolution)) { + return res.status(400).json({ + error: "Invalid stripe_customer_resolution", + message: `Must be one of: ${validResolutions.join(', ')}`, + }); + } + const user = (req as any).user; const userId = user?.id || "unknown"; logger.info( - { primary_org_id, secondary_org_id, userId }, + { primary_org_id, secondary_org_id, userId, stripe_customer_resolution }, "Executing organization merge" ); const result = await mergeOrganizations( primary_org_id, secondary_org_id, - userId + userId, + stripe_customer_resolution ? { stripeCustomerResolution: stripe_customer_resolution } : undefined ); logger.info( @@ -300,6 +311,7 @@ export function setupCleanupRoutes(apiRouter: Router): void { primary_org_id, secondary_org_id, tables_merged: result.tables_merged.length, + stripe_customer_action: result.stripe_customer_action, }, "Organization merge completed" ); @@ -308,8 +320,11 @@ export function setupCleanupRoutes(apiRouter: Router): void { } catch (error) { logger.error({ err: error }, "Error executing merge"); - // Return 400 for validation errors (e.g., personal workspace merge attempts) - if (error instanceof Error && error.message.startsWith('Cannot merge personal workspaces')) { + // Return 400 for validation errors (e.g., personal workspace merge attempts, Stripe conflicts) + if (error instanceof Error && ( + error.message.startsWith('Cannot merge personal workspaces') || + error.message.startsWith('Both organizations have Stripe customers') + )) { return res.status(400).json({ error: "Validation error", message: error.message, diff --git a/server/src/routes/billing.ts b/server/src/routes/billing.ts index f05d28bf2f..f6230d275b 100644 --- a/server/src/routes/billing.ts +++ b/server/src/routes/billing.ts @@ -1052,6 +1052,333 @@ export function createBillingRouter(): { pageRouter: Router; apiRouter: Router } } }); + /** + * Helper to get customer activity data from Stripe + */ + async function getCustomerActivity(customerId: string): Promise<{ + customer_id: string; + name: string | null; + email: string | null; + created: number; + has_payment_method: boolean; + active_subscriptions: number; + open_invoice_count: number; + open_invoice_total: number; + paid_invoice_count: number; + total_paid: number; + has_activity: boolean; + } | null> { + if (!stripe) return null; + + try { + const customer = await stripe.customers.retrieve(customerId, { + expand: ["subscriptions"], + }); + + if (customer.deleted) { + return null; + } + + // Count paid invoices and total paid + let paidInvoiceCount = 0; + let totalPaid = 0; + for await (const invoice of stripe.invoices.list({ + customer: customerId, + status: "paid", + limit: 100, + })) { + paidInvoiceCount++; + totalPaid += invoice.amount_paid; + } + + // Count open invoices + let openInvoiceCount = 0; + let openInvoiceTotal = 0; + for await (const invoice of stripe.invoices.list({ + customer: customerId, + status: "open", + limit: 100, + })) { + openInvoiceCount++; + openInvoiceTotal += invoice.amount_due; + } + + const activeSubscriptions = + customer.subscriptions?.data.filter((s) => s.status === "active" || s.status === "trialing").length ?? 0; + + const hasPaymentMethod = !!customer.default_source || !!customer.invoice_settings?.default_payment_method; + + // Customer has activity if: active subs, open invoices, or paid invoices + const hasActivity = activeSubscriptions > 0 || openInvoiceCount > 0 || paidInvoiceCount > 0; + + return { + customer_id: customerId, + name: customer.name ?? null, + email: customer.email ?? null, + created: customer.created, + has_payment_method: hasPaymentMethod, + active_subscriptions: activeSubscriptions, + open_invoice_count: openInvoiceCount, + open_invoice_total: openInvoiceTotal, + paid_invoice_count: paidInvoiceCount, + total_paid: totalPaid, + has_activity: hasActivity, + }; + } catch (error) { + logger.warn({ err: error, customerId }, "Failed to fetch customer activity"); + return null; + } + } + + /** + * GET /api/admin/stripe-mismatches + * List Stripe customer mismatches where an org has a different customer ID in the DB + * than what Stripe metadata says it should have. + * + * This detects orgs that appear to have multiple Stripe customers. + * Returns activity data for both customers and suggests which one to keep. + */ + apiRouter.get("/stripe-mismatches", requireAuth, requireAdmin, async (_req, res) => { + try { + const orgDb = new OrganizationDatabase(); + const baseMismatches = await orgDb.findStripeCustomerMismatches(); + + // Enrich each mismatch with activity data from Stripe + const mismatches = await Promise.all( + baseMismatches.map(async (mismatch) => { + const [dbCustomerActivity, metadataCustomerActivity] = await Promise.all([ + getCustomerActivity(mismatch.db_customer_id), + getCustomerActivity(mismatch.stripe_metadata_customer_id), + ]); + + // Determine suggested action based on activity + let suggested_action: 'use_db' | 'use_stripe_metadata' | 'manual_review' | null = null; + let can_auto_resolve = false; + + if (dbCustomerActivity && metadataCustomerActivity) { + if (dbCustomerActivity.has_activity && !metadataCustomerActivity.has_activity) { + // DB customer has activity, metadata customer doesn't - archive metadata customer + suggested_action = 'use_db'; + can_auto_resolve = true; + } else if (!dbCustomerActivity.has_activity && metadataCustomerActivity.has_activity) { + // Metadata customer has activity, DB customer doesn't - use metadata customer + suggested_action = 'use_stripe_metadata'; + can_auto_resolve = true; + } else if (dbCustomerActivity.has_activity && metadataCustomerActivity.has_activity) { + // Both have activity - needs manual review + suggested_action = 'manual_review'; + can_auto_resolve = false; + } else { + // Neither has activity - prefer keeping the DB one (it's already linked) + suggested_action = 'use_db'; + can_auto_resolve = true; + } + } + + return { + ...mismatch, + db_customer: dbCustomerActivity, + metadata_customer: metadataCustomerActivity, + suggested_action, + can_auto_resolve, + }; + }) + ); + + const autoResolvable = mismatches.filter((m) => m.can_auto_resolve).length; + + res.json({ + mismatches, + count: mismatches.length, + auto_resolvable: autoResolvable, + message: mismatches.length > 0 + ? `Found ${mismatches.length} org(s) with multiple Stripe customers. ${autoResolvable} can be auto-resolved.` + : "No Stripe customer mismatches found", + }); + } catch (error) { + logger.error({ err: error }, "Error finding Stripe customer mismatches"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Failed to find mismatches", + }); + } + }); + + /** + * POST /api/admin/stripe-mismatches/resolve + * Resolve a Stripe customer mismatch by choosing which customer to keep for an org. + * Body: { org_id, action: "use_db" | "use_stripe_metadata", delete_inactive?: boolean } + * + * - use_db: Keep the customer currently in DB, archive/delete the other customer + * - use_stripe_metadata: Use the customer from Stripe metadata, archive/delete the DB customer + * - delete_inactive: If true, delete the inactive customer in Stripe (only if it has no activity) + * + * Safety: Will refuse to proceed if both customers have activity (open invoices, subscriptions, paid invoices) + */ + apiRouter.post("/stripe-mismatches/resolve", requireAuth, requireAdmin, async (req, res) => { + try { + const { org_id, action, delete_inactive } = req.body; + + if (!org_id || !action) { + return res.status(400).json({ + error: "Missing required fields", + message: "Required: org_id, action ('use_db' or 'use_stripe_metadata')", + }); + } + + if (!["use_db", "use_stripe_metadata"].includes(action)) { + return res.status(400).json({ + error: "Invalid action", + message: "action must be 'use_db' or 'use_stripe_metadata'", + }); + } + + const orgDb = new OrganizationDatabase(); + const org = await orgDb.getOrganization(org_id); + + if (!org) { + return res.status(404).json({ + error: "Organization not found", + message: `Organization ${org_id} not found`, + }); + } + + if (!org.stripe_customer_id) { + return res.status(400).json({ + error: "No customer to resolve", + message: `Organization ${org_id} has no Stripe customer linked`, + }); + } + + // Find the mismatch for this org + const mismatches = await orgDb.findStripeCustomerMismatches(); + const mismatch = mismatches.find(m => m.org_id === org_id); + + if (!mismatch) { + return res.status(404).json({ + error: "No mismatch found", + message: `Organization ${org_id} has no Stripe customer mismatch`, + }); + } + + // Get activity data for both customers + const [dbCustomerActivity, metadataCustomerActivity] = await Promise.all([ + getCustomerActivity(mismatch.db_customer_id), + getCustomerActivity(mismatch.stripe_metadata_customer_id), + ]); + + // Safety check: refuse if both have activity + if (dbCustomerActivity?.has_activity && metadataCustomerActivity?.has_activity) { + return res.status(400).json({ + error: "Both customers have activity", + message: "Both Stripe customers have activity (invoices/subscriptions). Manual review required - resolve in Stripe dashboard.", + db_customer: dbCustomerActivity, + metadata_customer: metadataCustomerActivity, + }); + } + + // Determine which customer to keep and which to remove + const keepCustomerId = action === "use_db" ? mismatch.db_customer_id : mismatch.stripe_metadata_customer_id; + const removeCustomerId = action === "use_db" ? mismatch.stripe_metadata_customer_id : mismatch.db_customer_id; + const removeCustomerActivity = action === "use_db" ? metadataCustomerActivity : dbCustomerActivity; + + // Safety check: if requesting deletion, ensure we could fetch activity data + if (delete_inactive && !removeCustomerActivity) { + return res.status(400).json({ + error: "Unable to verify customer activity", + message: `Could not fetch activity data for ${removeCustomerId}. Cannot safely proceed with deletion.`, + }); + } + + // Safety check: don't delete a customer that has activity + if (delete_inactive && removeCustomerActivity?.has_activity) { + return res.status(400).json({ + error: "Cannot delete customer with activity", + message: `Customer ${removeCustomerId} has activity (${removeCustomerActivity.paid_invoice_count} paid invoices, ${removeCustomerActivity.active_subscriptions} subscriptions). Cannot auto-delete.`, + customer_activity: removeCustomerActivity, + }); + } + + // Perform the resolution + if (action === "use_db") { + // Keep the DB customer (already linked), remove the metadata customer + if (stripe) { + if (delete_inactive && !removeCustomerActivity?.has_activity) { + // Delete the inactive customer + await stripe.customers.del(removeCustomerId); + logger.info( + { orgId: org_id, keptCustomer: keepCustomerId, deletedCustomer: removeCustomerId, adminEmail: (req as any).user?.email }, + "Resolved Stripe customer mismatch - kept DB customer, deleted inactive metadata customer" + ); + } else { + // Just clear the metadata + await stripe.customers.update(removeCustomerId, { + metadata: { workos_organization_id: "" }, + }); + logger.info( + { orgId: org_id, keptCustomer: keepCustomerId, clearedMetadata: removeCustomerId, adminEmail: (req as any).user?.email }, + "Resolved Stripe customer mismatch - kept DB customer, cleared metadata from other" + ); + } + } + + res.json({ + success: true, + message: delete_inactive && !removeCustomerActivity?.has_activity + ? `Kept ${keepCustomerId} for "${org.name}". Deleted inactive customer ${removeCustomerId}.` + : `Kept ${keepCustomerId} for "${org.name}". Cleared metadata from ${removeCustomerId}.`, + action_taken: "use_db", + kept_customer: keepCustomerId, + removed_customer: removeCustomerId, + deleted: delete_inactive && !removeCustomerActivity?.has_activity, + }); + + } else if (action === "use_stripe_metadata") { + // Switch to metadata customer, remove the DB customer + await orgDb.updateOrganization(org_id, { + stripe_customer_id: keepCustomerId, + }); + + if (stripe) { + if (delete_inactive && !removeCustomerActivity?.has_activity) { + // Delete the inactive customer + await stripe.customers.del(removeCustomerId); + logger.info( + { orgId: org_id, newCustomer: keepCustomerId, deletedCustomer: removeCustomerId, adminEmail: (req as any).user?.email }, + "Resolved Stripe customer mismatch - switched to metadata customer, deleted inactive DB customer" + ); + } else { + // Just clear the metadata + await stripe.customers.update(removeCustomerId, { + metadata: { workos_organization_id: "" }, + }); + logger.info( + { orgId: org_id, newCustomer: keepCustomerId, clearedMetadata: removeCustomerId, adminEmail: (req as any).user?.email }, + "Resolved Stripe customer mismatch - switched to metadata customer, cleared metadata from old" + ); + } + } + + res.json({ + success: true, + message: delete_inactive && !removeCustomerActivity?.has_activity + ? `Switched "${org.name}" to ${keepCustomerId}. Deleted inactive customer ${removeCustomerId}.` + : `Switched "${org.name}" to ${keepCustomerId}. Cleared metadata from ${removeCustomerId}.`, + action_taken: "use_stripe_metadata", + kept_customer: keepCustomerId, + removed_customer: removeCustomerId, + deleted: delete_inactive && !removeCustomerActivity?.has_activity, + }); + } + } catch (error) { + logger.error({ err: error }, "Error resolving Stripe customer mismatch"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Failed to resolve mismatch", + }); + } + }); + /** * POST /api/admin/stripe-customer/unlink * Unlink a Stripe customer from an organization (without deleting the customer in Stripe) From c1cedfc14404c116f81fa228b0516df9319206be Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 05:51:40 -0500 Subject: [PATCH 45/58] docs: Add threat model and clarify security mitigations (#805) * docs: Add threat model and clarify security mitigations Addresses issue #177 by adding explicit threat model documentation and clarifying which mitigations address which threats. Key changes: - Add Threat Model section with categorized threats and mitigations - Add Domain Trust Assumption section explaining OAuth limitations - Clarify that OAuth doesn't protect against domain hijacking - Update high-risk operations to focus on actual controls (request signing, short-lived tokens, spending limits) rather than auth mechanism - Rewrite Bearer Token Risks with precise threat/mitigation mapping The key insight: authentication mechanism alone doesn't determine security level. A well-implemented bearer token system with request signing and spending limits is more secure than a long-lived OAuth token without those controls. Co-Authored-By: Claude Opus 4.5 * chore: Add empty changeset Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/true-buttons-fall.md | 2 + docs/reference/security.mdx | 122 +++++++++++++++++++++++++------- 2 files changed, 97 insertions(+), 27 deletions(-) create mode 100644 .changeset/true-buttons-fall.md diff --git a/.changeset/true-buttons-fall.md b/.changeset/true-buttons-fall.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/true-buttons-fall.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/docs/reference/security.mdx b/docs/reference/security.mdx index 44d946041f..74b01e63de 100644 --- a/docs/reference/security.mdx +++ b/docs/reference/security.mdx @@ -21,24 +21,84 @@ AdCP operates in a high-stakes environment where: This document provides security requirements and best practices for AdCP implementations. +## Threat Model + +Understanding the specific threats that AdCP implementations face helps choose appropriate mitigations. Different authentication mechanisms address different threats. + +### Threat Categories + +| Threat | Description | Primary Mitigations | +|--------|-------------|---------------------| +| **Credential Theft** | Attacker obtains valid tokens through phishing, malware, or database breach | Short token expiry, credential rotation, anomaly detection | +| **Token Replay** | Attacker intercepts and reuses a valid token | Short expiry, request signing, one-time-use tokens | +| **Domain Hijacking** | Attacker takes control of a domain (expiry, DNS hijack, BGP hijack) and receives tokens intended for legitimate server | Certificate pinning, domain monitoring, out-of-band verification | +| **Man-in-the-Middle** | Attacker intercepts traffic between client and server | TLS, certificate pinning, mTLS | +| **Privilege Escalation** | Authenticated principal accesses another principal's data | Principal isolation, row-level security, authorization checks | +| **Budget Fraud** | Attacker creates unauthorized financial commitments | Approval workflows, spending limits, anomaly detection | + +### Domain Trust Assumption + +AdCP, like most APIs, operates on the assumption that the domain you're connecting to is controlled by the intended party. This is the **domain trust assumption**. + + +**Critical**: OAuth 2.0 does not protect against domain hijacking. If an attacker controls `api.adplatform.com`, they can complete OAuth authorization flows just as easily as they can accept bearer tokens. The client trusts the domain, regardless of authentication mechanism. + + +**What OAuth provides:** +- Delegated access (user authorizes agent to act on their behalf) +- Scoped permissions (limit what the token can do) +- Token revocation without rotating credentials +- Separation of authentication from authorization + +**What OAuth does NOT provide:** +- Protection against compromised domains +- Verification that the server is the intended recipient +- Protection against man-in-the-middle attacks (beyond what TLS provides) + +**Mitigations for domain trust threats:** + +| Mitigation | How It Helps | +|------------|--------------| +| **TLS Certificate Pinning** | Rejects connections even if attacker has valid cert for the domain | +| **DNSSEC** | Protects against DNS spoofing | +| **Domain Monitoring** | Alerts when domain registration changes or expires | +| **Out-of-Band Verification** | Verify platform identity through secondary channel before large commitments | +| **ads.txt/sellers.json** | Industry-standard publisher authorization verification | + +### Authentication Mechanism Selection + +Choose authentication based on the threats you need to address: + +| Scenario | Recommended Auth | Rationale | +|----------|------------------|-----------| +| Server-to-server integration | API keys with IP allowlist | Simple, server identity verified by IP | +| Agent acting on user's behalf | OAuth 2.0 with PKCE | User controls delegation, scoped permissions | +| High-value financial operations | Request signing + short-lived tokens | Integrity verification beyond auth | +| Zero-trust environment | mTLS | Cryptographic verification of both parties | + ## Risk Classification AdCP operations fall into three risk categories based on their potential impact: ### High-Risk Operations (Financial) -These operations commit real advertising budgets and require the strongest authentication: +These operations commit real advertising budgets and require strong controls: -| Operation | Risk | Recommended Auth | -|-----------|------|------------------| -| `create_media_buy` | Creates financial commitments | OAuth 2.0 or mTLS | -| `update_media_buy` | Modifies budgets and campaign parameters | OAuth 2.0 or mTLS | +| Operation | Risk | Primary Threat | +|-----------|------|----------------| +| `create_media_buy` | Creates financial commitments | Budget fraud, credential theft | +| `update_media_buy` | Modifies budgets and campaign parameters | Unauthorized modifications | **Requirements for High-Risk Operations**: -- Implementations SHOULD use OAuth 2.0 with proper token validation or mutual TLS -- Bearer tokens alone provide weaker security guarantees (see [Bearer Token Risks](#bearer-token-risks)) -- Multi-factor authentication or approval workflows recommended for large budgets -- All requests must be logged with full audit trail +- Short-lived credentials (tokens expiring in ≤15 minutes) +- Request signing for transaction integrity (see [Request Signing](#request-signing-for-financial-operations)) +- Multi-factor authentication or approval workflows for large budgets +- Spending limits and anomaly detection +- Full audit trail with immutable logging + + +**Authentication mechanism alone does not determine security level.** A properly implemented bearer token system with short expiry, request signing, and spending limits provides stronger protection than a long-lived OAuth token without additional controls. + ### Medium-Risk Operations (Data Access) @@ -196,30 +256,38 @@ function verifyWebhookSignature( ### Bearer Token Risks -While bearer tokens (including JWT) are widely used across the industry, they carry specific risks that implementations should understand, particularly for [high-risk financial operations](#high-risk-operations-financial): +Bearer tokens are the industry standard for API authentication (Google Ads, Meta Marketing API, The Trade Desk, etc.). Understanding their specific risks helps implement appropriate mitigations. See [Threat Model](#threat-model) for context. + +**Inherent Risks of Bearer Tokens**: -**Token Interception Risks**: -- **Domain Hijacking**: If a domain expires or is compromised, attackers could receive valid bearer tokens intended for legitimate endpoints. Unlike OAuth flows, there's no bilateral verification that the server is the intended recipient. -- **Man-in-the-Middle**: Without certificate pinning, compromised certificate authorities could enable token interception even over HTTPS. -- **Token Replay**: Stolen tokens can be replayed until expiration. +| Risk | Description | Impact | +|------|-------------|--------| +| **Token Replay** | Stolen tokens can be reused until expiration | Unauthorized access for token lifetime | +| **Credential Theft** | Tokens extracted from logs, memory, or storage | Full account access until detected | +| **No Request Binding** | Token authorizes any request, not specific operations | Attacker can perform any authorized action | -**Mitigations for Bearer Token Usage**: +**Mitigations**: -| Risk | Mitigation | -|------|------------| -| Domain hijacking | Certificate pinning, domain monitoring, short token expiry | -| Token interception | mTLS, OAuth 2.0 with PKCE, token binding | -| Token replay | Short expiration (15 min), one-time-use tokens, request signing | -| Credential theft | Hardware security modules, secure enclaves | +| Risk | Mitigation | Implementation | +|------|------------|----------------| +| Token replay | Short expiration | ≤15 minute token lifetime | +| Token replay | Request signing | HMAC signature on payload + timestamp | +| Credential theft | Secure storage | HSM, secure enclaves, encrypted at rest | +| Credential theft | Anomaly detection | Alert on unusual access patterns | +| No request binding | Request signing | Signature covers specific operation parameters | + + +**Common Misconception**: OAuth 2.0 does not protect against domain hijacking. See [Domain Trust Assumption](#domain-trust-assumption) for why switching to OAuth doesn't address this threat. + -**When to Use Stronger Authentication**: +**When to Add Request Signing**: -For high-risk operations (`create_media_buy`, `update_media_buy`), consider: -- **OAuth 2.0 with PKCE**: Provides bilateral verification through the authorization flow -- **Mutual TLS (mTLS)**: Cryptographically verifies both client and server identity -- **Request Signing**: HMAC or asymmetric signatures on request payloads +For financial operations, request signing provides transaction integrity regardless of authentication mechanism: +- Proves the request wasn't modified in transit +- Binds the token to specific operation parameters +- Provides non-repudiation for audit purposes -**Example: Request Signing for Financial Operations**: +### Request Signing for Financial Operations ```typescript // Additional security layer for high-value transactions interface SignedRequest { From 7770bc6750ca7471a5c2f8b61a4f119859144b5b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 06:22:19 -0500 Subject: [PATCH 46/58] chore: sync v2.6-rc docs and schemas from 2.6.x branch (#798) Co-authored-by: bokelley <134922+bokelley@users.noreply.github.com> --- v2.6-rc/docs/creative/brand-manifest.mdx | 96 ++++- .../content-standards/artifacts.mdx | 304 ++++++++++++++ .../implementation-guide.mdx | 381 ++++++++++++++++++ .../governance/content-standards/index.mdx | 358 ++++++++++++++++ .../tasks/calibrate_content.mdx | 228 +++++++++++ .../tasks/create_content_standards.mdx | 90 +++++ .../tasks/delete_content_standards.mdx | 70 ++++ .../tasks/get_content_standards.mdx | 77 ++++ .../tasks/get_media_buy_artifacts.mdx | 205 ++++++++++ .../tasks/list_content_standards.mdx | 67 +++ .../tasks/update_content_standards.mdx | 72 ++++ .../tasks/validate_content_delivery.mdx | 183 +++++++++ .../task-reference/create_media_buy.mdx | 6 +- .../task-reference/sync_creatives.mdx | 8 +- .../task-reference/update_media_buy.mdx | 45 ++- v2.6-rc/docs/reference/error-codes.mdx | 60 ++- 16 files changed, 2233 insertions(+), 17 deletions(-) create mode 100644 v2.6-rc/docs/governance/content-standards/artifacts.mdx create mode 100644 v2.6-rc/docs/governance/content-standards/implementation-guide.mdx create mode 100644 v2.6-rc/docs/governance/content-standards/index.mdx create mode 100644 v2.6-rc/docs/governance/content-standards/tasks/calibrate_content.mdx create mode 100644 v2.6-rc/docs/governance/content-standards/tasks/create_content_standards.mdx create mode 100644 v2.6-rc/docs/governance/content-standards/tasks/delete_content_standards.mdx create mode 100644 v2.6-rc/docs/governance/content-standards/tasks/get_content_standards.mdx create mode 100644 v2.6-rc/docs/governance/content-standards/tasks/get_media_buy_artifacts.mdx create mode 100644 v2.6-rc/docs/governance/content-standards/tasks/list_content_standards.mdx create mode 100644 v2.6-rc/docs/governance/content-standards/tasks/update_content_standards.mdx create mode 100644 v2.6-rc/docs/governance/content-standards/tasks/validate_content_delivery.mdx diff --git a/v2.6-rc/docs/creative/brand-manifest.mdx b/v2.6-rc/docs/creative/brand-manifest.mdx index 692a6fe993..cb80d8f985 100644 --- a/v2.6-rc/docs/creative/brand-manifest.mdx +++ b/v2.6-rc/docs/creative/brand-manifest.mdx @@ -22,6 +22,7 @@ Brand manifests solve a key problem: how to efficiently identify advertisers and ### Key Benefits - **Know Your Customer**: Publishers can verify advertisers meet their standards +- **Privacy Transparency**: Link to privacy policy for consumer consent flows - **Minimal Friction**: Start with just a name or URL, expand as needed - **Cacheable**: Same brand manifest reused across all requests - **Standardized**: Consistent format across all AdCP implementations @@ -243,6 +244,7 @@ The structure of the brand manifest object itself (whether provided inline or ho | Field | Type | Description | |-------|------|-------------| | `name` | string | Brand or business name | +| `privacy_policy_url` | string (uri) | URL to the brand's privacy policy for consumer consent flows | | `logos` | Logo[] | Brand logo assets with semantic tags | | `colors` | Colors | Brand color palette (hex format) | | `fonts` | Fonts | Brand typography guidelines | @@ -325,10 +327,21 @@ The structure of the brand manifest object itself (whether provided inline or ho ```typescript { feed_url: string; // URL to product catalog feed - feed_format?: string; // Format of the product feed (default: "google_merchant_center") + feed_format?: string; // Format: "google_merchant_center" | "facebook_catalog" | "openai_product_feed" | "custom" categories?: string[]; // Product categories available in the catalog last_updated?: string; // When the product catalog was last updated (ISO 8601) update_frequency?: string; // How frequently the catalog is updated + agentic_checkout?: AgenticCheckout; // Optional checkout endpoint configuration +} +``` + +### AgenticCheckout Object + +```typescript +{ + endpoint: string; // Base URL for checkout session API + spec: string; // Checkout API specification (e.g., "openai_agentic_checkout_v1") + supported_payment_providers?: string[]; // Payment providers (e.g., ["stripe", "adyen"]) } ``` @@ -459,7 +472,7 @@ Large retailers should provide product feeds: } ``` -**Supported Feed Formats**: RSS, JSON Feed, Product CSV +**Supported Feed Formats**: Google Merchant Center, Facebook Catalog, [OpenAI Product Feed](https://developers.openai.com/commerce/specs/feed), Custom ### 5. Asset Libraries for Enterprise @@ -492,6 +505,85 @@ Enterprise brands with large asset libraries should provide explicit assets: } ``` +## Privacy Integration + +Brand manifests support privacy transparency through the `privacy_policy_url` field. This enables AI platforms to present explicit privacy choices to users before sharing personal data with advertisers. + +### Consumer Consent Flow + +When an AI assistant helps a user engage with an advertiser (booking a flight, making a purchase, etc.), the platform can use the brand manifest's privacy policy URL to: + +1. **Present explicit consent**: "May I share your details with Delta? [View their privacy policy]" +2. **Enable informed decisions**: Users can review data practices before data handoff +3. **Support machine-readable terms**: Works alongside [MyTerms/IEEE P7012](https://myterms.info) for automated privacy negotiation + +### Example with Privacy Policy + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/v2/core/brand-manifest.json", + "name": "Delta Airlines", + "url": "https://delta.com", + "privacy_policy_url": "https://delta.com/privacy" +} +``` + +### MyTerms Discovery + +For advertisers implementing [IEEE P7012 (MyTerms)](https://myterms.info), AI platforms can discover machine-readable privacy terms from the advertiser's domain (e.g., `/.well-known/myterms`). The brand manifest's `privacy_policy_url` serves as the human-readable fallback and explicit consent path. + +## Agentic Commerce Integration + +Brand manifests support integration with AI commerce platforms through the `product_catalog` field. This enables AI agents to discover products and complete purchases on behalf of users. + +### OpenAI Commerce + +For merchants implementing [OpenAI's Commerce specifications](https://developers.openai.com/commerce), the brand manifest provides a bridge: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/v2/core/brand-manifest.json", + "name": "Shop Example", + "url": "https://shopexample.com", + "privacy_policy_url": "https://shopexample.com/privacy", + "product_catalog": { + "feed_url": "https://shopexample.com/products.jsonl.gz", + "feed_format": "openai_product_feed", + "update_frequency": "daily", + "agentic_checkout": { + "endpoint": "https://api.shopexample.com/checkout_sessions", + "spec": "openai_agentic_checkout_v1", + "supported_payment_providers": ["stripe", "adyen"] + } + } +} +``` + +**Key fields for OpenAI Commerce:** + +| Field | Description | +|-------|-------------| +| `feed_format: "openai_product_feed"` | Indicates the feed conforms to [OpenAI's Product Feed spec](https://developers.openai.com/commerce/specs/feed) | +| `agentic_checkout.endpoint` | Base URL for [OpenAI's Agentic Checkout API](https://developers.openai.com/commerce/specs/checkout) | +| `agentic_checkout.spec` | Version identifier for the checkout spec | + +### Feed Format Mapping + +If you have an existing Google Merchant Center feed, here's how key fields map to OpenAI's spec: + +| OpenAI Field | Google Merchant Center | Notes | +|--------------|----------------------|-------| +| `item_id` | `id` | Direct mapping | +| `title` | `title` | Direct mapping | +| `description` | `description` | Direct mapping | +| `url` | `link` | Direct mapping | +| `brand` | `brand` | Direct mapping | +| `price` | `price` | OpenAI uses number + currency code | +| `availability` | `availability` | Same enum values | +| `image_url` | `image_link` | Direct mapping | +| `is_eligible_search` | N/A | OpenAI-specific flag | +| `is_eligible_checkout` | N/A | OpenAI-specific flag | + ## Evolution and Versioning Brand cards are versioned using the `metadata.version` field: diff --git a/v2.6-rc/docs/governance/content-standards/artifacts.mdx b/v2.6-rc/docs/governance/content-standards/artifacts.mdx new file mode 100644 index 0000000000..7d2f834a2a --- /dev/null +++ b/v2.6-rc/docs/governance/content-standards/artifacts.mdx @@ -0,0 +1,304 @@ +--- +title: Artifacts +sidebar_position: 2 +--- + +# Artifacts + +An **artifact** is a unit of content adjacent to an ad placement. When evaluating brand safety and suitability, you're asking: "Is this artifact appropriate for my brand's ads?" + +## What Is an Artifact? + +Artifacts represent the content context where an ad appears: + +- A **news article** on a website +- A **podcast segment** between ad breaks +- A **video chapter** in a YouTube video +- A **social media post** in a feed +- A **scene** in a CTV show +- An **AI-generated image** in a chat conversation + +Artifacts are identified by `property_id` + `artifact_id` - the property defines where the content lives, and the artifact_id is an opaque identifier for that specific piece of content. The artifact_id scheme is flexible - it could be a URL path, a platform-specific ID, or any consistent identifier the property owner uses internally. + +## Structure + +**Schema**: [artifact.json](https://adcontextprotocol.org/schemas/v2/content-standards/artifact.json) + +```json +{ + "property_id": {"type": "domain", "value": "reddit.com"}, + "artifact_id": "r_fitness_post_abc123", + "assets": [ + {"type": "text", "role": "title", "content": "Best protein sources for muscle building", "language": "en"}, + {"type": "text", "role": "paragraph", "content": "Looking for recommendations on high-quality protein sources...", "language": "en"}, + {"type": "image", "url": "https://cdn.reddit.com/fitness-image.jpg", "alt_text": "Person lifting weights"} + ] +} +``` + +### Required Fields + +| Field | Description | +|-------|-------------| +| `property_id` | Where this artifact lives - uses standard identifier types (`domain`, `app_id`, `apple_podcast_id`, etc.) | +| `artifact_id` | Unique identifier within the property - the property owner defines their scheme | +| `assets` | Content in document order - text blocks, images, video, audio | + +### Optional Fields + +| Field | Description | +|-------|-------------| +| `variant_id` | Identifies a specific variant (A/B test, translation, temporal version) | +| `format_id` | Reference to format registry (same as creative formats) | +| `url` | Web URL if the artifact has one | +| `metadata` | Artifact-level metadata (Open Graph, JSON-LD, author info) | +| `published_time` | When the artifact was published | +| `last_update_time` | When the artifact was last modified | + +## Variants + +The same artifact may have multiple variants: + +- **Translations** - English version vs Spanish version +- **A/B tests** - Different headlines being tested +- **Temporal versions** - Content that changed on Wednesday + +Use `variant_id` to distinguish between them: + +```json +// English version +{ + "property_id": {"type": "domain", "value": "nytimes.com"}, + "artifact_id": "article_12345", + "variant_id": "en", + "assets": [ + {"type": "text", "role": "title", "content": "Breaking News Story", "language": "en"} + ] +} + +// Spanish translation +{ + "property_id": {"type": "domain", "value": "nytimes.com"}, + "artifact_id": "article_12345", + "variant_id": "es", + "assets": [ + {"type": "text", "role": "title", "content": "Noticia de última hora", "language": "es"} + ] +} + +// A/B test variant +{ + "property_id": {"type": "domain", "value": "nytimes.com"}, + "artifact_id": "article_12345", + "variant_id": "headline_test_b", + "assets": [ + {"type": "text", "role": "title", "content": "Alternative Headline Being Tested", "language": "en"} + ] +} +``` + +The combination of `artifact_id` + `variant_id` must be unique within a property. This lets you track which variant a user saw and correlate it with delivery reports. + +## Asset Types + +Assets are the actual content within an artifact. Everything is an asset - titles, paragraphs, images, videos. + +### Text + +```json +{"type": "text", "role": "title", "content": "Article Title", "language": "en"} +{"type": "text", "role": "paragraph", "content": "The article body text...", "language": "en"} +{"type": "text", "role": "description", "content": "A summary of the article", "language": "en"} +{"type": "text", "role": "heading", "content": "Section Header", "heading_level": 2} +{"type": "text", "role": "quote", "content": "A quoted statement"} +``` + +Roles: `title`, `description`, `paragraph`, `heading`, `caption`, `quote`, `list_item` + +Each text asset can have its own `language` tag for mixed-language content. + +### Image + +```json +{ + "type": "image", + "url": "https://cdn.example.com/photo.jpg", + "alt_text": "Description of the image" +} +``` + +### Video + +```json +{ + "type": "video", + "url": "https://cdn.example.com/video.mp4", + "transcript": "Full transcript of the video content...", + "duration_ms": 180000 +} +``` + +### Audio + +```json +{ + "type": "audio", + "url": "https://cdn.example.com/podcast.mp3", + "transcript": "Today we're discussing...", + "duration_ms": 3600000 +} +``` + +## Metadata + +Artifact-level metadata describes the artifact as a whole, not individual assets: + +```json +{ + "metadata": { + "author": "Jane Smith", + "canonical": "https://example.com/article/12345", + "open_graph": { + "og:type": "article", + "og:site_name": "Example News" + }, + "json_ld": [ + { + "@type": "NewsArticle", + "datePublished": "2025-01-15" + } + ] + } +} +``` + +This is separate from assets because it's about the artifact container, not the content itself. + +## Secured Asset Access + +Many assets aren't publicly accessible - AI-generated images, private conversations, paywalled content. The artifact schema supports authenticated access. + +### Pre-Configuration (Recommended) + +For ongoing partnerships, configure access once during onboarding rather than per-request: + +1. **Service account sharing** - Grant the verification agent access to your cloud storage +2. **OAuth client credentials** - Set up machine-to-machine authentication +3. **API key exchange** - Share long-lived API keys during setup + +This happens during the activation phase when the seller first receives content standards from a buyer. + +### Per-Asset Authentication + +When pre-configuration isn't possible, include access credentials with individual assets: + +```json +{ + "type": "image", + "url": "https://cdn.openai.com/secured/img_abc123.png", + "access": { + "method": "bearer_token", + "token": "eyJhbGciOiJIUzI1NiIs..." + } +} +``` + +**Note on token size**: For artifacts with many assets, per-asset tokens can significantly increase payload size. Consider: + +1. **Pre-configured access** - Set up service account access once during onboarding +2. **Shared token reference** - Define tokens at the artifact level and reference by ID +3. **Signed URLs** - Use pre-signed URLs where the URL itself is the credential + +The `url` field is the access URL - it may differ from the artifact's canonical/published URL. For example, a published article at `https://news.example.com/article/123` might have assets served from `https://cdn.example.com/secured/...`. + +### Access Methods + +| Method | Use Case | +|--------|----------| +| `bearer_token` | OAuth2 bearer token in Authorization header | +| `service_account` | GCP/AWS service account credentials | +| `signed_url` | Pre-signed URL with embedded credentials (URL itself is the credential) | + +### Service Account Setup + +For GCP: + +```json +{ + "access": { + "method": "service_account", + "provider": "gcp", + "credentials": { + "type": "service_account", + "project_id": "my-project", + "private_key_id": "...", + "private_key": "-----BEGIN PRIVATE KEY-----\n...", + "client_email": "verification-agent@my-project.iam.gserviceaccount.com" + } + } +} +``` + +For AWS: + +```json +{ + "access": { + "method": "service_account", + "provider": "aws", + "credentials": { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "...", + "region": "us-east-1" + } + } +} +``` + +### Pre-Signed URLs + +For one-off access without sharing credentials: + +```json +{ + "type": "video", + "url": "https://storage.googleapis.com/bucket/video.mp4?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=...&X-Goog-Signature=...", + "access": { + "method": "signed_url" + } +} +``` + +The URL itself contains the credentials - no additional authentication needed. + +## Property Identifier Types + +The `property_id` uses standard identifier types from the AdCP property schema: + +| Type | Example | Use Case | +|------|---------|----------| +| `domain` | `reddit.com` | Websites | +| `app_id` | `com.spotify.music` | Mobile apps | +| `apple_podcast_id` | `1234567890` | Apple Podcasts | +| `spotify_show_id` | `4rOoJ6Egrf8K2IrywzwOMk` | Spotify podcasts | +| `youtube_channel_id` | `UCddiUEpeqJcYeBxX1IVBKvQ` | YouTube channels | +| `rss_url` | `https://feeds.example.com/podcast.xml` | RSS feeds | + +## Artifact ID Schemes + +The property owner defines their artifact_id scheme. Examples: + +| Property Type | Artifact ID Pattern | Example | +|---------------|---------------------|---------| +| News website | `article_{id}` | `article_12345` | +| Reddit | `r_{subreddit}_{post_id}` | `r_fitness_abc123` | +| Podcast | `episode_{num}_segment_{num}` | `episode_42_segment_2` | +| CTV | `show_{id}_s{season}e{episode}_scene_{num}` | `show_abc_s3e5_scene_12` | +| Social feed | `post_{id}` | `post_xyz789` | + +The verification agent doesn't need to understand the scheme - it's opaque. The property owner uses it to correlate artifacts with their content. + +## Related + +- [Content Standards Overview](/docs/governance/content-standards) - How artifacts fit into the content standards workflow +- [calibrate_content](/docs/governance/content-standards/tasks/calibrate_content) - Sending artifacts for calibration diff --git a/v2.6-rc/docs/governance/content-standards/implementation-guide.mdx b/v2.6-rc/docs/governance/content-standards/implementation-guide.mdx new file mode 100644 index 0000000000..c8efbcb77c --- /dev/null +++ b/v2.6-rc/docs/governance/content-standards/implementation-guide.mdx @@ -0,0 +1,381 @@ +--- +title: Implementation Guide +description: How to implement the Content Standards Protocol as a sales agent, orchestrator, or governance agent +--- + +This guide covers implementation patterns for the Content Standards Protocol from three perspectives: + +1. **Sales agents** accepting and enforcing brand safety standards +2. **Orchestrators** coordinating content standards across publishers +3. **Governance agents** providing content evaluation services + +## Roles Overview + +Before diving in, understand who does what: + +| Role | Examples | Responsibilities | +|------|----------|-----------------| +| **Orchestrator** | DSP, trading desk, agency platform | Coordinates media buying; passes standards refs to sellers; receives artifacts for validation | +| **Sales Agent** | Publisher ad server, SSP | Accepts standards; calibrates local model; enforces during delivery; pushes artifacts | +| **Governance Agent** | IAS, DoubleVerify, brand safety service | Hosts standards; implements `calibrate_content` and `validate_content_delivery` | + +The typical flow: + +``` +1. Brand sets up standards with governance agent (via orchestrator) +2. Orchestrator sends standards_ref with get_products/create_media_buy +3. Sales agent accepts or rejects based on capability +4. Sales agent calibrates against governance agent +5. Sales agent enforces during delivery +6. Sales agent provides artifacts (push via webhook or pull via get_media_buy_artifacts) +7. Orchestrator forwards artifacts to governance agent for validation +``` + +--- + +## For Sales Agents + +If you're a sales agent (publisher ad server, SSP, or platform), implementing Content Standards means accepting orchestrator policies and enforcing them during delivery. + +### The Core Model + +When an orchestrator includes a `content_standards_ref` in their request, you must: + +1. **Fetch the standards** from the governance agent and evaluate if you can fulfill them +2. **Accept or reject** the buy based on your capabilities +3. **Calibrate** your evaluation model against the governance agent's expectations +4. **Enforce** the standards during delivery +5. **Provide artifacts** to the orchestrator for validation + +If you cannot fulfill the content standards requirements, **reject the buy**. Don't accept a campaign you can't properly enforce. + +### What You Need to Implement + +**1. Accept content standards references on `get_products` and `create_media_buy`** + +Orchestrators pass their standards via reference: + +```json +{ + "content_standards_ref": { + "standards_id": "nike_emea_brand_safety", + "agent_url": "https://brandsafety.ias.com" + } +} +``` + +When you receive this: +- Fetch the standards document from the governance agent at `agent_url` +- Evaluate whether you can enforce these requirements +- If you cannot meet the standards, reject the request +- If you can, accept and store the association with the media buy + +**2. Decide: Can you fulfill this?** + +The standards document contains: +- Policy (natural language description of acceptable/unacceptable content) +- Calibration exemplars (pass/fail examples to interpret edge cases) +- Floor (reference to external baseline safety standards) + +Review these requirements against your capabilities. Different publishers have different definitions of "adjacency" - Reddit might include comments, YouTube might include related videos, a news site might mean the article body. That's fine - as long as you can meaningfully enforce the brand's intent, accept the buy. + +If you can't - for example, they need adjacency data for a channel where it doesn't apply (like billboards) - reject the buy. + +**3. Build your evaluation capability** + +Use the standards document to train or configure your content evaluation system. This could be: +- An LLM with the rules as system prompt +- A classifier trained on the calibration examples +- A rules engine for deterministic evaluation +- A third-party brand safety vendor + +The protocol doesn't prescribe your implementation - just that you honor the standards. + +**4. Calibrate against the governance agent** + +After accepting the buy, calibrate your local model by calling `calibrate_content` on the governance agent. You send sample artifacts from your inventory, they tell you how they would rate them: + +```json +// You send examples from your inventory to the governance agent +{ + "standards_id": "nike_emea_brand_safety", + "artifacts": [ + { + "property_id": { "type": "domain", "value": "espn.com" }, + "artifact_id": "article_123", + "assets": [{ "type": "text", "role": "title", "content": "Marathon Runner Collapses at Finish Line" }] + } + ] +} + +// Governance agent responds with their interpretation +{ + "evaluations": [{ + "artifact_id": "article_123", + "suitable": true, + "confidence": 0.9, + "explanation": "Sports injury coverage in athletic context - aligns with brand's sports marketing positioning" + }] +} +``` + +Use these responses to train your local model. If you disagree with a rating, ask follow-up questions to understand the governance agent's reasoning. + +**5. Push artifacts to the orchestrator** + +After delivery, push artifacts to the orchestrator so they can validate against the governance agent. Configure via `artifact_webhook` in the media buy: + +```json +// Artifact webhook payload (you send this to the orchestrator) +{ + "media_buy_id": "mb_nike_reddit_q1", + "batch_id": "batch_20250115_001", + "timestamp": "2025-01-15T11:00:00Z", + "artifacts": [ + { + "artifact": { + "property_id": { "type": "domain", "value": "reddit.com" }, + "artifact_id": "r_fitness_abc123", + "assets": [{ "type": "text", "role": "title", "content": "Best protein sources" }] + }, + "delivered_at": "2025-01-15T10:30:00Z", + "impression_id": "imp_abc123" + } + ] +} +``` + +Also support `get_media_buy_artifacts` for orchestrators who prefer to poll. + +### Implementation Checklist + +- [ ] Parse `content_standards_ref` in `get_products` and `create_media_buy` +- [ ] Fetch and evaluate standards documents from governance agents +- [ ] Reject buys you cannot fulfill - don't accept campaigns you can't enforce +- [ ] Build content evaluation against the standards document +- [ ] Call `calibrate_content` on the governance agent to align interpretation +- [ ] Implement `get_media_buy_artifacts` so orchestrators can retrieve content for validation +- [ ] Support `artifact_webhook` for push-based artifact delivery +- [ ] Support `reporting_webhook` for delivery metrics + +--- + +## For Orchestrators + +If you're an orchestrator (DSP, trading desk, or agency platform), you coordinate content standards between brands, governance agents, and publishers. + +### The Orchestration Pattern + +``` +Brand → Orchestrator → Governance Agent (setup) + → Sales Agent (buying) + ← Sales Agent (artifacts) + → Governance Agent (validation) + → Brand (reporting) +``` + +**1. Help brands set up standards with governance agents** + +Brands create content standards through a governance agent. You might facilitate this or the brand may do it directly: + +```json +// Standards stored at the governance agent +{ + "standards_id": "nike_emea_brand_safety", + "name": "Nike EMEA Brand Safety Policy", + "brand_id": "nike", + "policy": "Sports and fitness content is ideal. Avoid violence, adult themes, drugs.", + "calibration_exemplars": { + "pass": [ + { "type": "url", "value": "https://espn.com/nba/story/_/id/12345/lakers-win", "language": "en" } + ], + "fail": [ + { "type": "url", "value": "https://tabloid.example.com/celebrity-scandal", "language": "en" } + ] + } +} +``` + +**2. Pass standards references when buying** + +When discovering products or creating media buys, include the governance agent reference: + +```json +{ + "product_id": "espn_sports_display", + "packages": [...], + "content_standards_ref": { + "standards_id": "nike_emea_brand_safety", + "agent_url": "https://brandsafety.ias.com" + }, + "artifact_webhook": { + "url": "https://your-platform.com/webhooks/artifacts", + "authentication": { + "schemes": ["HMAC-SHA256"], + "credentials": "your-shared-secret-min-32-chars" + }, + "delivery_mode": "batched", + "batch_frequency": "hourly", + "sampling_rate": 0.25 + } +} +``` + +If the publisher cannot fulfill the standards, they should reject the buy. Handle rejections gracefully and find alternative inventory. + +**3. Receive artifacts from sales agents** + +Sales agents push artifacts to your `artifact_webhook` endpoint. Forward them to the governance agent for validation: + +```python +# Receive artifact webhook from sales agent +@app.post("/webhooks/artifacts") +async def receive_artifacts(payload: ArtifactWebhookPayload): + # Forward to governance agent for validation + validation_result = await governance_agent.validate_content_delivery( + standards_id=get_standards_id(payload.media_buy_id), + records=[ + {"artifact": a.artifact, "record_id": a.impression_id} + for a in payload.artifacts + ] + ) + + # Log any failures + for result in validation_result.results: + if any(f.status == "failed" for f in result.features): + log_brand_safety_incident(payload.media_buy_id, result) + + return {"status": "received", "batch_id": payload.batch_id} +``` + +**4. Report to brands** + +Surface validation results to the brand: +- **Incidents**: Content that didn't meet standards +- **Coverage**: What percentage of delivery was validated +- **Trends**: Changes in content safety over time + +### Implementation Checklist + +- [ ] Facilitate brand setup with governance agents +- [ ] Include `content_standards_ref` in `get_products` and `create_media_buy` requests +- [ ] Configure `artifact_webhook` to receive artifacts from sales agents +- [ ] Handle rejections from publishers who can't fulfill standards +- [ ] Forward artifacts to governance agent via `validate_content_delivery` +- [ ] Build reporting for brands + +--- + +## For Governance Agents + +If you're a governance agent (IAS, DoubleVerify, or brand safety service), you provide content evaluation as a service. + +### What You Implement + +**1. Host and serve content standards** + +Store standards configurations and expose them via `get_content_standards`: + +```json +// Response to get_content_standards +{ + "standards_id": "nike_emea_brand_safety", + "version": "1.2.0", + "name": "Nike EMEA - all digital channels", + "policy": "Sports and fitness content is ideal. Lifestyle content about health is good...", + "calibration_exemplars": { + "pass": [...], + "fail": [...] + } +} +``` + +**2. Implement `calibrate_content`** + +Sales agents call this to align their local models before campaign execution. They send sample artifacts, you respond with how the brand would rate them: + +```python +def calibrate_content(standards_id: str, artifacts: list) -> dict: + standards = get_standards(standards_id) + evaluations = [] + + for artifact in artifacts: + # Evaluate against brand's policy + result = evaluate_against_policy(artifact, standards) + evaluations.append({ + "artifact_id": artifact["artifact_id"], + "suitable": result.suitable, + "confidence": result.confidence, + "explanation": result.explanation # Help them understand your reasoning + }) + + return {"evaluations": evaluations} +``` + +Calibration is a dialogue - be prepared for follow-up questions and edge cases. + +**3. Implement `validate_content_delivery`** + +Orchestrators call this to validate artifacts after delivery. Batch evaluation at scale: + +```python +def validate_content_delivery(standards_id: str, records: list) -> dict: + standards = get_standards(standards_id) + results = [] + + for record in records: + features = [] + for feature in ["brand_safety", "brand_suitability"]: + evaluation = evaluate_feature(record["artifact"], standards, feature) + features.append({ + "feature_id": feature, + "status": "passed" if evaluation.passed else "failed", + "value": evaluation.value, + "message": evaluation.message if not evaluation.passed else None + }) + results.append({ + "record_id": record["record_id"], + "features": features + }) + + return { + "summary": compute_summary(results), + "results": results + } +``` + +### Implementation Checklist + +- [ ] Implement `create_content_standards` for brands to set up policies +- [ ] Implement `get_content_standards` for sales agents to fetch policies +- [ ] Implement `calibrate_content` for sales agents to align their models +- [ ] Implement `validate_content_delivery` for orchestrators to validate delivery +- [ ] Support dialogue in calibration (follow-up questions, edge cases) + +--- + +## Content Access Pattern + +All three roles may need to exchange content securely. The `content_access` pattern provides authenticated access to a URL namespace: + +```json +{ + "content_access": { + "url_pattern": "https://cache.example.com/*", + "auth": { + "type": "bearer", + "token": "eyJ..." + } + } +} +``` + +- **url_pattern**: URLs matching this pattern use this auth +- **auth.type**: Authentication method (`bearer`, `api_key`, `signed_url`) +- **auth.token**: The credential + +Include this in: +- `get_content_standards` response (governance agent → sales agent: "fetch examples here") +- `get_media_buy_artifacts` response (sales agent → orchestrator: "fetch content here") + +This avoids per-asset tokens and keeps payloads small while enabling secure content exchange. diff --git a/v2.6-rc/docs/governance/content-standards/index.mdx b/v2.6-rc/docs/governance/content-standards/index.mdx new file mode 100644 index 0000000000..eda9db83a5 --- /dev/null +++ b/v2.6-rc/docs/governance/content-standards/index.mdx @@ -0,0 +1,358 @@ +--- +title: Overview +sidebar_position: 1 +--- + +# Content Standards Protocol + +The Content Standards Protocol enables **privacy-preserving brand safety** for ephemeral and sensitive content that cannot leave a publisher's infrastructure. + +## The Problem + +Traditional brand safety relies on third-party verification: send your content to IAS or DoubleVerify, they evaluate it, return a verdict. This works for static web pages. It fundamentally cannot work for: + +- **AI-generated content** - ChatGPT responses, DALL-E images that exist only in a user session +- **Private conversations** - Content in messaging apps, private social feeds +- **Ephemeral content** - Stories, live streams, real-time feeds that disappear +- **Privacy-regulated content** - GDPR-protected data that cannot be exported + +For these platforms, **there is no traditional verification option**. The content simply cannot leave. OpenAI cannot send user conversations to an external service. A messaging app cannot export private chats. A streaming platform cannot share real-time content before it disappears. + +Yet these are exactly the environments where advertising is growing fastest - and where brands most need safety guarantees. Without a privacy-preserving approach, brands either avoid these channels entirely or accept unknown risk. + +## The Solution: Calibration-Based Alignment + +Content Standards solves this by **using agents to protect privacy**. It's a three-phase model where no sensitive content ever leaves the publisher's infrastructure: + +| Phase | Where It Runs | What Happens | +|-------|---------------|--------------| +| **1. Calibration** | External (safe data only) | Publisher and verification agent align on policy interpretation using synthetic examples or public samples - no PII, no sensitive content | +| **2. Local Execution** | Inside publisher's walls | Publisher runs evaluation on every impression using a local model trained during calibration - content never leaves | +| **3. Validation** | Statistical sampling | Verification agent audits a sample to detect drift - both parties can verify the system is working without exposing PII | + +This inverts the traditional model. Instead of "send us your content, we'll evaluate it," it's "we'll teach you our standards, you evaluate locally, we'll audit statistically." + +**The key insight**: The execution engine runs entirely inside the publisher's infrastructure. For OpenAI, that means brand safety evaluation happens within their firewall - user conversations never leave. For a messaging app, it means private content stays private. The calibration and validation phases provide confidence that the local model is working correctly, without ever requiring access to sensitive data. + +## What It Covers + +- **Brand safety** - Is this content safe for *any* brand? (universal thresholds like hate speech, illegal content) +- **Brand suitability** - Is this content appropriate for *my* brand? (brand-specific preferences and tone) + +## Key Concepts + +Content standards evaluation involves four key questions that buyers and sellers negotiate: + +1. **What content?** - What [artifacts](/docs/governance/content-standards/artifacts) to evaluate (the ad-adjacent content) +2. **How much adjacency?** - How many artifacts around the ad slot to consider +3. **What sampling rate?** - What percentage of traffic to evaluate +4. **How to calibrate?** - How to align on policy interpretation before runtime + +These parameters are negotiated between buyer and seller during product discovery and media buy creation. + +## Workflow + +```mermaid +sequenceDiagram + participant Brand + participant Buyer as Buyer Agent + participant Seller as Seller Agent + participant Verifier as Verification Agent + + Note over Brand,Verifier: 1. SETUP PHASE + Brand->>Verifier: create_content_standards (policy + calibration examples) + Verifier-->>Brand: standards_id + + Note over Brand,Verifier: 2. ACTIVATION PHASE + Brand->>Buyer: "Buy inventory from Reddit, use standards_id X" + Buyer->>Seller: create_media_buy (includes content_standards reference) + + Seller->>Verifier: calibrate_content (sample artifacts) + Verifier-->>Seller: verdict + explanation + Seller->>Verifier: "What about this edge case?" + Verifier-->>Seller: clarification + Note over Seller: Seller builds local model + + Note over Brand,Verifier: 3. RUNTIME PHASE + loop High-volume decisioning + Note over Seller: Local model evaluates artifacts + end + + Buyer->>Seller: get_media_buy_artifacts (sampled) + Seller-->>Buyer: Content artifacts + Buyer->>Verifier: validate_content_delivery + Verifier-->>Buyer: Validation results +``` + +**Key insight**: Runtime decisioning happens locally at the seller (for scale). Buyers pull content samples from sellers and validate against the verification agent. + +## Adjacency + +How much content around the ad slot should be evaluated? + +| Context | Adjacency Examples | +|---------|-------------------| +| **News article** | The article where the ad appears | +| **Social feed** | 1-2 posts above and below the ad slot | +| **Podcast** | The segment before and after the ad break | +| **CTV** | 1-2 scenes before and after the ad pod | +| **Infinite scroll** | Posts within the visible viewport | + +Adjacency requirements are defined by the seller in their product catalog (`get_products`). The buyer can filter products based on adjacency guarantees: + +```json +{ + "product_id": "reddit_feed_standard", + "content_standards_adjacency_definition": { + "before": 2, + "after": 2, + "unit": "posts" + } +} +``` + +### Adjacency Units + +| Unit | Use Case | +|------|----------| +| `posts` | Social feeds, forums, comment threads | +| `scenes` | CTV, streaming video content | +| `segments` | Podcasts, audio content | +| `seconds` | Time-based adjacency in video/audio | +| `viewports` | Infinite scroll contexts | +| `articles` | News sites, content aggregators | + +Different products may offer different adjacency guarantees at different price points. + +## Sampling Rate + +What percentage of traffic should be evaluated by the verification agent? + +| Rate | Use Case | +|------|----------| +| **100%** | Premium brand safety - every impression validated | +| **10-25%** | Standard monitoring - statistical confidence | +| **1-5%** | Spot checking - drift detection only | + +Sampling rate is negotiated in the media buy: + +```json +{ + "governance": { + "content_standards": { + "agent_url": "https://safety.ias.com/adcp", + "standards_id": "nike_brand_safety", + "sampling_rate": 0.25 + } + } +} +``` + +Higher sampling rates typically cost more but provide stronger guarantees. The seller is responsible for implementing the agreed sampling rate and reporting actual coverage. + +## Validation Thresholds + +When a seller calibrates their local model against a verification agent, there's an expected drift - the local model won't match the verification agent 100% of the time. **Validation thresholds** define acceptable drift between local execution and validation samples. + +Sellers advertise their content safety capabilities in their product catalog: + +```json +{ + "product_id": "reddit_feed_premium", + "content_standards": { + "validation_threshold": 0.95, + "validation_threshold_description": "Local model matches verification agent 95% of the time" + } +} +``` + +| Threshold | Meaning | +|-----------|---------| +| **0.99** | Premium - local model is 99% aligned with verification agent | +| **0.95** | Standard - local model is 95% aligned | +| **0.90** | Budget - local model is 90% aligned | + +**This is a contractual guarantee.** If the seller's validation results show more drift than the advertised threshold, buyers can expect remediation (makegoods, refunds, etc.) just like any other delivery discrepancy. + +The threshold answers the key buyer question: "If I accept your local model, how confident can I be that you're enforcing my standards correctly?" + +## Policies + +Content Standards uses **natural language prompts** rather than rigid keyword lists: + +```json +{ + "policy": "Sports and fitness content is ideal. Lifestyle content about health is good. Entertainment is generally acceptable. Avoid content about violence, controversial politics, adult themes, or content portraying sedentary lifestyle positively. Block hate speech, illegal activities, or ongoing litigation against our company.", + "calibration_exemplars": { + "pass": [ + { + "property_id": {"type": "domain", "value": "espn.com"}, + "artifact_id": "nba_championship_recap_2024", + "assets": [{"type": "text", "role": "title", "content": "Championship Game Recap"}] + } + ], + "fail": [ + { + "property_id": {"type": "domain", "value": "tabloid.example.com"}, + "artifact_id": "scandal_story_123", + "assets": [{"type": "text", "role": "title", "content": "Celebrity Scandal Exposed"}] + } + ] + } +} +``` + +The policy prompt enables AI-powered verification agents to understand context and nuance. **Calibration** examples provide a training/test set that helps the agent interpret the policy correctly. + +See [Artifacts](/docs/governance/content-standards/artifacts) for details on artifact structure and secured asset access. + +## Scoped Standards + +Buyers typically maintain multiple standards configurations for different contexts - UK TV campaigns have different regulations than US display, and children's brands need stricter safety than adult beverages. + +```json +{ + "standards_id": "uk_tv_zero_calorie", + "name": "UK TV - zero-calorie brands", + "countries_all": ["GB"], + "channels_any": ["ctv", "linear_tv"] +} +``` + +**The buyer selects the appropriate `standards_id` when creating a media buy.** The seller receives a reference to the resolved standards - they don't need to do scope matching themselves. + +## Calibration + +Before running campaigns, sellers calibrate their local models against the verification agent. This is a **dialogue-based process** that may involve human review on either side: + +1. Seller sends sample artifacts to the verification agent +2. Verification agent returns verdicts with detailed explanations +3. Seller asks follow-up questions about edge cases +4. Process repeats until alignment is achieved + +**Human-in-the-loop**: Calibration often involves humans on both sides. A brand safety specialist at the buyer might review edge cases flagged by the verification agent. A content operations team at the seller might curate calibration samples and validate the local model's learning. The protocol supports async workflows where either party can pause for human review before responding. + +```json +// Seller: "Does this pass?" +{ + "artifact": { + "property_id": {"type": "domain", "value": "reddit.com"}, + "artifact_id": "r_news_politics_123", + "assets": [{"type": "text", "role": "title", "content": "Political News Article"}] + } +} + +// Verification agent: "No, because..." +{ + "verdict": "fail", + "explanation": "Political content is excluded by brand policy, even when balanced.", + "policy_alignment": { + "violations": [{ + "policy_text": "Avoid content about controversial politics", + "violation_reason": "Article discusses ongoing political controversy" + }] + } +} +``` + +See [calibrate_content](/docs/governance/content-standards/tasks/calibrate_content) for the full task specification. + +## Tasks + +### Discovery + +| Task | Description | +|------|-------------| +| [list_content_standards](/docs/governance/content-standards/tasks/list_content_standards) | List available standards configurations | +| [get_content_standards](/docs/governance/content-standards/tasks/get_content_standards) | Retrieve a specific standards configuration | + +### Management + +| Task | Description | +|------|-------------| +| [create_content_standards](/docs/governance/content-standards/tasks/create_content_standards) | Create a new standards configuration | +| [update_content_standards](/docs/governance/content-standards/tasks/update_content_standards) | Update an existing standards configuration | +| [delete_content_standards](/docs/governance/content-standards/tasks/delete_content_standards) | Delete a standards configuration | + +### Calibration & Validation + +| Task | Description | +|------|-------------| +| [calibrate_content](/docs/governance/content-standards/tasks/calibrate_content) | Collaborative dialogue to align on policy interpretation | +| [get_media_buy_artifacts](/docs/governance/content-standards/tasks/get_media_buy_artifacts) | Retrieve content artifacts from a media buy | +| [validate_content_delivery](/docs/governance/content-standards/tasks/validate_content_delivery) | Batch validation of content artifacts | + +## Typical Providers + +- **IAS** - Integral Ad Science +- **DoubleVerify** - Brand safety and verification +- **Scope3** - Sustainability-focused brand safety with prompt-based policies +- **Custom** - Brand-specific implementations + +## Future: Secure Enclaves + +The current model trusts the publisher to faithfully implement the calibrated standards. A future evolution uses **secure enclaves** (Trusted Execution Environments / TEEs) to provide cryptographic guarantees: + +```mermaid +flowchart TB + subgraph VS["Verification Service"] + Models["Models & Calibration Data"] + Results["Aggregate Results"] + end + + subgraph PUB["Publisher Infrastructure"] + subgraph TEE["Secure Enclave (TEE)"] + Agent["Containerized
Governance Agent"] + end + Content["Content Artifacts"] + end + + Models -->|"Pinhole IN:
models, policy, examples"| Agent + Agent -->|"Pinhole OUT:
pass rates, drift metrics"| Results + Content -->|"evaluate"| Agent + Agent -->|"pass/fail verdict"| Content + + style TEE fill:#e8f5e9,stroke:#4caf50 + style Agent fill:#c8e6c9,stroke:#388e3c + style PUB fill:#fafafa,stroke:#9e9e9e +``` + +**Content never crosses the pinhole** - only models flow in, only aggregates flow out. + +### The Pinhole Interface + +The enclave maintains a narrow, well-defined interface to the verification service: + +**Inbound (verification service → enclave):** +- Updated brand safety models +- Policy changes and calibration exemplars +- Configuration updates + +**Outbound (enclave → verification service):** +- Aggregated validation results (pass rates, drift metrics) +- Statistical summaries +- Attestation proofs + +**Never crosses the boundary:** +- Raw content artifacts +- User data or PII +- Individual impression-level data + +This pinhole is the interface that needs standardization - it defines exactly what flows in and out while keeping sensitive content locked inside the publisher's walls. + +### Why This Matters + +- **Publisher** hosts a secure enclave inside their infrastructure +- **Governance agent** (from IAS, DoubleVerify, etc.) runs as a container within the enclave +- **Content** flows into the enclave for evaluation but never leaves the publisher's walls +- **Both parties** can verify the governance code is running unmodified via attestation +- **Models stay current** - the enclave can receive updates without exposing content + +This provides the same privacy guarantees as local execution, but with cryptographic proof that the correct algorithm is running. The brand knows their standards are being enforced faithfully. The publisher proves compliance without exposing content. + +This architecture aligns with the [IAB Tech Lab ARTF (Agentic RTB Framework)](https://iabtechlab.com/standards/artf/), which defines how service providers can package offerings as containers deployed into host infrastructure. ARTF enables hosts to "provide greater access to data and more interaction opportunities to service agents without concerns about leakage, misappropriation or latency" - exactly the model Content Standards requires for privacy-preserving brand safety. + +## Related + +- [Artifacts](/docs/governance/content-standards/artifacts) - What artifacts are and how to structure them +- [Brand Manifest](/docs/creative/brand-manifest) - Static brand identity that can link to standards agents diff --git a/v2.6-rc/docs/governance/content-standards/tasks/calibrate_content.mdx b/v2.6-rc/docs/governance/content-standards/tasks/calibrate_content.mdx new file mode 100644 index 0000000000..801ee3bb53 --- /dev/null +++ b/v2.6-rc/docs/governance/content-standards/tasks/calibrate_content.mdx @@ -0,0 +1,228 @@ +--- +title: calibrate_content +sidebar_position: 7 +--- + +# calibrate_content + +Collaborative calibration task for aligning on content standards interpretation. Used during setup to help sellers understand and internalize a buyer's content policies before campaign execution. + +Unlike high-volume runtime evaluation, calibration is a **dialogue-based process** where parties exchange examples and explanations until aligned. + +## When to Use + +- **Seller onboarding**: When a seller first receives content standards from a buyer +- **Policy clarification**: When a seller needs to understand why specific content passes or fails +- **Model training**: When building a local model to run against the standards +- **Drift detection**: Periodic re-calibration to ensure continued alignment + +## Request + +**Schema**: [calibrate-content-request.json](https://adcontextprotocol.org/schemas/v2/content-standards/calibrate-content-request.json) + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `standards_id` | string | Yes | Standards configuration to calibrate against | +| `artifact` | artifact | Yes | Artifact to evaluate | + +### Artifact + +**Schema**: [artifact.json](https://adcontextprotocol.org/schemas/v2/content-standards/artifact.json) + +An artifact represents content context where ad placements occur - identified by `property_id` + `artifact_id` and represented as a collection of assets: + +```json +{ + "property_id": {"type": "domain", "value": "reddit.com"}, + "artifact_id": "r_fitness_abc123", + "assets": [ + {"type": "text", "role": "title", "content": "Best protein sources for muscle building", "language": "en"}, + {"type": "text", "role": "paragraph", "content": "Looking for recommendations on high-quality protein sources...", "language": "en"}, + {"type": "text", "role": "paragraph", "content": "I've been lifting for 6 months and want to optimize my diet.", "language": "en"}, + {"type": "image", "url": "https://cdn.reddit.com/fitness-image.jpg", "alt_text": "Person lifting weights"} + ] +} +``` + +## Response + +**Schema**: [calibrate-content-response.json](https://adcontextprotocol.org/schemas/v2/content-standards/calibrate-content-response.json) + +### Passing Response + +```json +{ + "verdict": "pass", + "explanation": "This content aligns well with the brand's fitness-focused positioning. Health and fitness content is explicitly marked as 'ideal' in the policy. The discussion is constructive and educational.", + "features": [ + { + "feature_id": "brand_safety", + "status": "passed", + "explanation": "No safety concerns. Content is user-generated but constructive fitness discussion." + }, + { + "feature_id": "brand_suitability", + "status": "passed", + "explanation": "Fitness content matches brand's athletic positioning." + } + ] +} +``` + +### Failing Response with Detailed Explanation + +```json +{ + "verdict": "fail", + "explanation": "This content discusses political topics which the policy explicitly excludes. While the article itself is balanced journalism, the brand has requested to avoid all controversial political content regardless of tone.", + "features": [ + { + "feature_id": "brand_safety", + "status": "passed", + "explanation": "No hate speech, illegal content, or explicit material." + }, + { + "feature_id": "brand_suitability", + "status": "failed", + "explanation": "Political content is excluded by brand policy, even when balanced." + } + ] +} +``` + +### Response Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `verdict` | Yes | Overall `pass` or `fail` decision | +| `explanation` | No | Detailed natural language explanation of the decision | +| `features` | No | Per-feature breakdown with explanations | +| `confidence` | No | Model confidence in the verdict (0-1), when available | + +## Dialogue Flow + +Calibration supports back-and-forth dialogue using the protocol's conversation management. The seller sends content, the verification agent responds with an evaluation and explanation, and the seller can respond with questions or try different content - all within the same conversation context. + +### A2A Example + +```javascript +// Seller sends artifact to evaluate +const response1 = await a2a.send({ + message: { + parts: [{ + kind: "data", + data: { + skill: "calibrate_content", + parameters: { + standards_id: "nike_brand_safety", + artifact: { + property_id: { type: "domain", value: "reddit.com" }, + artifact_id: "r_news_politics_123", + assets: [ + { type: "text", role: "title", content: "Political News Article" } + ] + } + } + } + }] + } +}); +// Response: verdict=fail with feature breakdown + +// Seller asks follow-up question about the decision +const response2 = await a2a.send({ + contextId: response1.contextId, + message: { + parts: [{ + kind: "text", + text: "This is factual news, not opinion. Should balanced journalism be excluded?" + }] + } +}); +// Verification agent clarifies that brand policy excludes ALL political content + +// Seller tries different artifact +const response3 = await a2a.send({ + contextId: response1.contextId, + message: { + parts: [{ + kind: "data", + data: { + skill: "calibrate_content", + parameters: { + standards_id: "nike_brand_safety", + artifact: { + property_id: { type: "domain", value: "reddit.com" }, + artifact_id: "r_running_tips_456", + assets: [ + { type: "text", role: "title", content: "Running Tips" } + ] + } + } + } + }] + } +}); +// Response: verdict=pass - now seller understands the boundaries +``` + +### MCP Example + +```javascript +// Initial calibration request +const response1 = await mcp.call('calibrate_content', { + standards_id: "nike_brand_safety", + artifact: { + property_id: { type: "domain", value: "reddit.com" }, + artifact_id: "r_news_politics_123", + assets: [ + { type: "text", role: "title", content: "Political News Article" } + ] + } +}); +// Response includes context_id for conversation continuity + +// Continue dialogue with follow-up question +const response2 = await mcp.call('calibrate_content', { + context_id: response1.context_id, + standards_id: "nike_brand_safety", + artifact: { + property_id: { type: "domain", value: "reddit.com" }, + artifact_id: "r_news_politics_123", + assets: [ + { type: "text", role: "title", content: "Political News Article" } + ] + } +}); +// Include text message in the protocol envelope asking about balanced journalism + +// Try different artifact in same conversation +const response3 = await mcp.call('calibrate_content', { + context_id: response1.context_id, + standards_id: "nike_brand_safety", + artifact: { + property_id: { type: "domain", value: "reddit.com" }, + artifact_id: "r_running_tips_456", + assets: [ + { type: "text", role: "title", content: "Running Tips" } + ] + } +}); +``` + +The key insight is that the dialogue happens at the **protocol layer**, not the task layer. The verification agent maintains conversation context and can respond to follow-up questions, disagreements, or requests for clarification - just like any agent-to-agent conversation. + +## Calibration vs Runtime + +| Aspect | calibrate_content | Runtime (local model) | +|--------|-------------------|----------------------| +| **Purpose** | Alignment & understanding | High-volume decisioning | +| **Volume** | Low (setup/periodic) | High (every impression) | +| **Response** | Verbose explanations | Pass/fail only | +| **Latency** | Seconds acceptable | Milliseconds required | +| **Dialogue** | Multi-turn conversation | Stateless | + +## Related Tasks + +- [get_content_standards](/docs/governance/content-standards/tasks/get_content_standards) - Retrieve the policies being calibrated against +- [validate_content_delivery](/docs/governance/content-standards/tasks/validate_content_delivery) - Post-campaign delivery validation diff --git a/v2.6-rc/docs/governance/content-standards/tasks/create_content_standards.mdx b/v2.6-rc/docs/governance/content-standards/tasks/create_content_standards.mdx new file mode 100644 index 0000000000..51859ca853 --- /dev/null +++ b/v2.6-rc/docs/governance/content-standards/tasks/create_content_standards.mdx @@ -0,0 +1,90 @@ +--- +title: create_content_standards +sidebar_position: 5 +--- + +# create_content_standards + +Create a new content standards configuration. + +**Response time**: < 1s + +## Request + +**Schema**: [create-content-standards-request.json](https://adcontextprotocol.org/schemas/v2/content-standards/create-content-standards-request.json) + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `scope` | object | Yes | Where this standards configuration applies (must include `languages_any`) | +| `policy` | string | Yes | Natural language policy prompt | +| `calibration_exemplars` | object | No | Training set of pass/fail artifacts for calibration | + +:::note[Brand Safety Floor Requirement] +Implementors MUST apply a brand safety floor regardless of what policy is defined in content standards. Content that violates the floor (hate speech, illegal content, etc.) must be excluded even when no content standards are specified. AdCP does not define the floor specification; this is left to implementors and industry standards (e.g., GARM categories). +::: + +### Example Request + +```json +{ + "scope": { + "countries_all": ["GB", "DE", "FR"], + "channels_any": ["display", "video", "ctv"], + "languages_any": ["en", "de", "fr"], + "description": "EMEA - all digital channels" + }, + "policy": "Sports and fitness content is ideal. Lifestyle content about health and wellness is good. Entertainment content is generally acceptable. Avoid content about violence, controversial political topics, adult themes, or content that portrays sedentary lifestyle positively.", + "calibration_exemplars": { + "pass": [ + { "type": "url", "value": "https://espn.com/nba/story/_/id/12345/lakers-championship", "language": "en" }, + { "type": "url", "value": "https://healthline.com/fitness/cardio-workout", "language": "en" } + ], + "fail": [ + { "type": "url", "value": "https://tabloid.example.com/celebrity-scandal", "language": "en" }, + { "type": "url", "value": "https://news.example.com/controversial-politics-article", "language": "en" } + ] + } +} +``` + +## Response + +**Schema**: [create-content-standards-response.json](https://adcontextprotocol.org/schemas/v2/content-standards/create-content-standards-response.json) + +### Success Response + +```json +{ + "standards_id": "emea_digital_safety" +} +``` + +### Error Responses + +**Scope Conflict:** + +```json +{ + "errors": [ + { + "code": "SCOPE_CONFLICT", + "message": "Standards already exist for country 'DE' on channel 'display'", + "conflicting_standards_id": "emea_digital_safety" + } + ] +} +``` + +## Scope Conflict Handling + +Multiple standards cannot have overlapping scopes for the same country/channel/language combination. When creating standards that would conflict: + +1. **Check existing standards** - Use [list_content_standards](/docs/governance/content-standards/tasks/list_content_standards) filtered by your scope +2. **Update rather than create** - If standards already exist, use [update_content_standards](/docs/governance/content-standards/tasks/update_content_standards) +3. **Narrow the scope** - Adjust countries or channels to avoid overlap + +## Related Tasks + +- [list_content_standards](/docs/governance/content-standards/tasks/list_content_standards) - List all configurations +- [update_content_standards](/docs/governance/content-standards/tasks/update_content_standards) - Update a configuration +- [delete_content_standards](/docs/governance/content-standards/tasks/delete_content_standards) - Delete a configuration diff --git a/v2.6-rc/docs/governance/content-standards/tasks/delete_content_standards.mdx b/v2.6-rc/docs/governance/content-standards/tasks/delete_content_standards.mdx new file mode 100644 index 0000000000..2f38244c51 --- /dev/null +++ b/v2.6-rc/docs/governance/content-standards/tasks/delete_content_standards.mdx @@ -0,0 +1,70 @@ +--- +title: delete_content_standards +sidebar_position: 7 +--- + +# delete_content_standards + +Delete a content standards configuration. + +**Response time**: < 500ms + +## Request + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `standards_id` | string | Yes | ID of the standards configuration to delete | + +### Example Request + +```json +{ + "standards_id": "nike_emea_safety" +} +``` + +## Response + +### Success Response + +```json +{ + "deleted": true, + "standards_id": "nike_emea_safety" +} +``` + +### Error Responses + +**Not Found:** + +```json +{ + "errors": [ + { + "code": "STANDARDS_NOT_FOUND", + "message": "No standards found with ID 'invalid_id'" + } + ] +} +``` + +**Standards In Use:** + +```json +{ + "errors": [ + { + "code": "STANDARDS_IN_USE", + "message": "Cannot delete standards 'nike_emea_safety' - currently referenced by active media buys" + } + ] +} +``` + +Standards cannot be deleted while they are referenced by active media buys. Use [list_content_standards](/docs/governance/content-standards/tasks/list_content_standards) to identify usage, or archive standards by setting an expiration date rather than deleting. + +## Related Tasks + +- [list_content_standards](/docs/governance/content-standards/tasks/list_content_standards) - List all configurations +- [create_content_standards](/docs/governance/content-standards/tasks/create_content_standards) - Create a new configuration diff --git a/v2.6-rc/docs/governance/content-standards/tasks/get_content_standards.mdx b/v2.6-rc/docs/governance/content-standards/tasks/get_content_standards.mdx new file mode 100644 index 0000000000..dec5de452e --- /dev/null +++ b/v2.6-rc/docs/governance/content-standards/tasks/get_content_standards.mdx @@ -0,0 +1,77 @@ +--- +title: get_content_standards +sidebar_position: 2 +--- + +# get_content_standards + +Retrieve content safety policies for a specific standards configuration. + +## Request + +**Schema**: [get-content-standards-request.json](https://adcontextprotocol.org/schemas/v2/content-standards/get-content-standards-request.json) + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `standards_id` | string | Yes | Identifier for the standards configuration | + +## Response + +**Schema**: [get-content-standards-response.json](https://adcontextprotocol.org/schemas/v2/content-standards/get-content-standards-response.json) + +### Success Response + +```json +{ + "standards_id": "emea_digital_safety", + "name": "EMEA - all digital channels", + "countries_all": ["GB", "DE", "FR"], + "channels_any": ["display", "video", "ctv"], + "languages_any": ["en", "de", "fr"], + "policy": "Sports and fitness content is ideal. Lifestyle content about health and wellness is good. Entertainment content is generally acceptable. Avoid content about violence, controversial political topics, adult themes, or content that portrays sedentary lifestyle positively. Block hate speech, illegal activities, or content disparaging athletes.", + "calibration_exemplars": { + "pass": [ + { "type": "url", "value": "https://espn.com/nba/story/_/id/12345/lakers-championship", "language": "en" }, + { "type": "url", "value": "https://healthline.com/fitness/cardio-workout", "language": "en" } + ], + "fail": [ + { "type": "url", "value": "https://tabloid.example.com/celebrity-scandal", "language": "en" }, + { "type": "url", "value": "https://news.example.com/controversial-politics-article", "language": "en" } + ] + } +} +``` + +### Fields + +| Field | Description | +|-------|-------------| +| `standards_id` | Unique identifier for this standards configuration | +| `name` | Human-readable name | +| `countries_all` | ISO country codes - standards apply in ALL listed countries | +| `channels_any` | Ad channels - standards apply to ANY of the listed channels | +| `languages_any` | BCP 47 language tags - standards apply to content in ANY of these languages | +| `policy` | Natural language policy describing acceptable and unacceptable content contexts | +| `calibration_exemplars` | Training/test set of content contexts (pass/fail) to calibrate policy interpretation | + +:::note[Brand Safety Floor Requirement] +Implementors MUST apply a brand safety floor regardless of what policy is defined. AdCP does not define the floor specification. +::: + +### Error Response + +```json +{ + "errors": [ + { + "code": "STANDARDS_NOT_FOUND", + "message": "No standards found with ID 'invalid_id'" + } + ] +} +``` + +## Related Tasks + +- [calibrate_content](/docs/governance/content-standards/tasks/calibrate_content) - Collaborative calibration against these standards +- [list_content_standards](/docs/governance/content-standards/tasks/list_content_standards) - List available standards configurations diff --git a/v2.6-rc/docs/governance/content-standards/tasks/get_media_buy_artifacts.mdx b/v2.6-rc/docs/governance/content-standards/tasks/get_media_buy_artifacts.mdx new file mode 100644 index 0000000000..7b430da991 --- /dev/null +++ b/v2.6-rc/docs/governance/content-standards/tasks/get_media_buy_artifacts.mdx @@ -0,0 +1,205 @@ +--- +title: get_media_buy_artifacts +sidebar_position: 8 +--- + +# get_media_buy_artifacts + +Retrieve content artifacts from a media buy for validation. This is separate from `get_media_buy_delivery` which returns performance metrics - artifacts contain the actual content (text, images, video) where ads were placed. + +**Response time**: < 5s (batch of 1,000 artifacts) + +## Data Flow + +```mermaid +sequenceDiagram + participant Buyer as Buyer Agent + participant Seller as Seller Agent + participant Verifier as Verification Agent + + Buyer->>Seller: get_media_buy_artifacts (sampled or full) + Seller-->>Buyer: Artifacts with content + Buyer->>Verifier: validate_content_delivery + Verifier-->>Buyer: Validation results +``` + +The buyer requests artifacts from the seller using the same media buy parameters. The seller returns content samples based on the agreed sampling rate. The buyer then validates these against the verification agent. + +## Request + +**Schema**: [get-media-buy-artifacts-request.json](https://adcontextprotocol.org/schemas/v2/content-standards/get-media-buy-artifacts-request.json) + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `media_buy_id` | string | Yes | Media buy to get artifacts from | +| `package_ids` | array | No | Filter to specific packages | +| `sampling` | object | No | Sampling parameters (defaults to media buy agreement) | +| `time_range` | object | No | Filter to specific time period | +| `limit` | integer | No | Maximum artifacts to return (default: 1000) | +| `cursor` | string | No | Pagination cursor for large result sets | + +### Sampling Options + +```json +{ + "sampling": { + "rate": 0.25, + "method": "random" + } +} +``` + +| Method | Description | +|--------|-------------| +| `random` | Random sample across all deliveries | +| `stratified` | Sample proportionally across packages/properties | +| `recent` | Most recent deliveries first | +| `failures_only` | Only artifacts that failed local evaluation | + +## Response + +**Schema**: [get-media-buy-artifacts-response.json](https://adcontextprotocol.org/schemas/v2/content-standards/get-media-buy-artifacts-response.json) + +### Success Response + +```json +{ + "media_buy_id": "mb_nike_reddit_q1", + "artifacts": [ + { + "record_id": "imp_12345", + "timestamp": "2025-01-15T10:30:00Z", + "package_id": "pkg_feed_standard", + "artifact": { + "property_id": {"type": "domain", "value": "reddit.com"}, + "artifact_id": "r_fitness_abc123", + "assets": [ + {"type": "text", "role": "title", "content": "Best protein sources for muscle building", "language": "en"}, + {"type": "text", "role": "paragraph", "content": "Looking for recommendations on high-quality protein sources...", "language": "en"}, + {"type": "image", "url": "https://cdn.reddit.com/fitness-image.jpg", "alt_text": "Person lifting weights"} + ] + }, + "country": "US", + "channel": "social", + "brand_context": {"brand_id": "nike_global", "sku_id": "air_max_2025"}, + "local_verdict": "pass" + }, + { + "record_id": "imp_12346", + "timestamp": "2025-01-15T10:35:00Z", + "package_id": "pkg_feed_standard", + "artifact": { + "property_id": {"type": "domain", "value": "reddit.com"}, + "artifact_id": "r_news_politics_456", + "assets": [ + {"type": "text", "role": "title", "content": "Election Results Analysis", "language": "en"}, + {"type": "text", "role": "paragraph", "content": "The latest polling data shows...", "language": "en"} + ] + }, + "country": "US", + "channel": "social", + "brand_context": {"brand_id": "nike_global", "sku_id": "air_max_2025"}, + "local_verdict": "fail" + } + ], + "sampling_info": { + "total_deliveries": 100000, + "sampled_count": 1000, + "effective_rate": 0.01, + "method": "random" + }, + "pagination": { + "cursor": "eyJvZmZzZXQiOjEwMDB9", + "has_more": true + } +} +``` + +### Response Fields + +| Field | Description | +|-------|-------------| +| `artifacts` | Array of delivery records with full artifact content | +| `artifacts[].country` | ISO 3166-1 alpha-2 country code where delivery occurred | +| `artifacts[].channel` | Channel type (display, video, audio, social) | +| `artifacts[].brand_context` | Brand/SKU information for policy evaluation (schema TBD) | +| `artifacts[].local_verdict` | Seller's local model verdict (pass/fail/unevaluated) | +| `sampling_info` | How the sample was generated | +| `pagination` | Cursor for fetching more results | + +## Use Cases + +### Validate Sample Against Standards + +```python +# Get artifacts from seller +artifacts_response = seller_agent.get_media_buy_artifacts( + media_buy_id="mb_nike_reddit_q1", + sampling={"rate": 0.25, "method": "random"} +) + +# Convert to validation records +records = [ + { + "record_id": a["record_id"], + "timestamp": a["timestamp"], + "media_buy_id": artifacts_response["media_buy_id"], + "artifact": a["artifact"], + "country": a.get("country"), + "channel": a.get("channel"), + "brand_context": a.get("brand_context") + } + for a in artifacts_response["artifacts"] +] + +# Validate against verification agent +validation = verification_agent.validate_content_delivery( + standards_id="nike_brand_safety", + records=records +) + +# Check for drift between local and verified verdicts +for i, result in enumerate(validation["results"]): + local = artifacts_response["artifacts"][i]["local_verdict"] + verified = result["verdict"] + if local != verified: + print(f"Drift detected: {result['record_id']} - local={local}, verified={verified}") +``` + +### Focus on Local Failures + +```python +# Get only artifacts that failed local evaluation +failures = seller_agent.get_media_buy_artifacts( + media_buy_id="mb_nike_reddit_q1", + sampling={"method": "failures_only"}, + limit=100 +) + +# Verify these were correctly flagged +validation = verification_agent.validate_content_delivery( + standards_id="nike_brand_safety", + records=[{"record_id": a["record_id"], "artifact": a["artifact"]} + for a in failures["artifacts"]] +) + +# Check false positive rate +false_positives = sum(1 for r in validation["results"] if r["verdict"] == "pass") +print(f"False positive rate: {false_positives / len(failures['artifacts']):.1%}") +``` + +## Delivery vs Artifacts + +| Aspect | get_media_buy_delivery | get_media_buy_artifacts | +|--------|------------------------|-------------------------| +| **Purpose** | Performance reporting | Content validation | +| **Data size** | Small (metrics) | Large (full content) | +| **Frequency** | Regular reporting | Sampled validation | +| **Contains** | Impressions, clicks, spend | Text, images, video | +| **Consumer** | Buyer for optimization | Verification agent | + +## Related Tasks + +- [validate_content_delivery](/docs/governance/content-standards/tasks/validate_content_delivery) - Validate the artifacts +- [calibrate_content](/docs/governance/content-standards/tasks/calibrate_content) - Understand why artifacts pass/fail +- [get_media_buy_delivery](/docs/media-buy/task-reference/get_media_buy_delivery) - Get performance metrics diff --git a/v2.6-rc/docs/governance/content-standards/tasks/list_content_standards.mdx b/v2.6-rc/docs/governance/content-standards/tasks/list_content_standards.mdx new file mode 100644 index 0000000000..cdaea651ae --- /dev/null +++ b/v2.6-rc/docs/governance/content-standards/tasks/list_content_standards.mdx @@ -0,0 +1,67 @@ +--- +title: list_content_standards +sidebar_position: 2 +--- + +# list_content_standards + +List available content standards configurations. + +**Response time**: < 500ms + +## Request + +**Schema**: [list-content-standards-request.json](https://adcontextprotocol.org/schemas/v2/content-standards/list-content-standards-request.json) + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `countries` | array | No | Filter by country codes | +| `channels` | array | No | Filter by channels | +| `languages` | array | No | Filter by BCP 47 language tags | + +## Response + +**Schema**: [list-content-standards-response.json](https://adcontextprotocol.org/schemas/v2/content-standards/list-content-standards-response.json) + +Returns an abbreviated list of standards configurations. Use [get_content_standards](/docs/governance/content-standards/tasks/get_content_standards) to retrieve full details including policy text and calibration data. + +### Success Response + +```json +{ + "standards": [ + { + "standards_id": "emea_digital_safety", + "name": "EMEA - all digital channels", + "countries_all": ["GB", "DE", "FR"], + "channels_any": ["display", "video", "ctv"], + "languages": ["en", "de", "fr"] + }, + { + "standards_id": "us_display_only", + "name": "US - display only", + "countries_all": ["US"], + "channels_any": ["display"], + "languages": ["en"] + } + ] +} +``` + +### Error Response + +```json +{ + "errors": [ + { + "code": "UNAUTHORIZED", + "message": "Invalid or expired token" + } + ] +} +``` + +## Related Tasks + +- [get_content_standards](/docs/governance/content-standards/tasks/get_content_standards) - Get a specific standards configuration +- [create_content_standards](/docs/governance/content-standards/tasks/create_content_standards) - Create a new configuration diff --git a/v2.6-rc/docs/governance/content-standards/tasks/update_content_standards.mdx b/v2.6-rc/docs/governance/content-standards/tasks/update_content_standards.mdx new file mode 100644 index 0000000000..4f2fb5bc29 --- /dev/null +++ b/v2.6-rc/docs/governance/content-standards/tasks/update_content_standards.mdx @@ -0,0 +1,72 @@ +--- +title: update_content_standards +sidebar_position: 6 +--- + +# update_content_standards + +Update an existing content standards configuration. Creates a new version. + +**Response time**: < 1s + +## Request + +**Schema**: [update-content-standards-request.json](https://adcontextprotocol.org/schemas/v2/content-standards/update-content-standards-request.json) + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `standards_id` | string | Yes | ID of the standards configuration to update | +| `scope` | object | No | Updated scope | +| `policy` | string | No | Updated policy prompt | +| `calibration_exemplars` | object | No | Updated training exemplars (pass/fail) | + +### Example Request + +```json +{ + "standards_id": "nike_emea_safety", + "policy": "Sports and fitness content is ideal. Lifestyle content about health and wellness is good. Entertainment content is generally acceptable. Avoid violence, controversial politics, adult themes. Block hate speech and illegal activities.", + "calibration_exemplars": { + "pass": [ + { "type": "url", "value": "https://espn.com/nba/story/_/id/12345/lakers-win", "language": "en" }, + { "type": "url", "value": "https://healthline.com/fitness/cardio-workout", "language": "en" }, + { "type": "url", "value": "https://runnersworld.com/training/marathon-tips", "language": "en" } + ], + "fail": [ + { "type": "url", "value": "https://tabloid.example.com/celebrity-scandal", "language": "en" }, + { "type": "url", "value": "https://gambling.example.com/betting-guide", "language": "en" } + ] + } +} +``` + +## Response + +**Schema**: [update-content-standards-response.json](https://adcontextprotocol.org/schemas/v2/content-standards/update-content-standards-response.json) + +### Success Response + +```json +{ + "standards_id": "nike_emea_safety" +} +``` + +### Error Response + +```json +{ + "errors": [ + { + "code": "STANDARDS_NOT_FOUND", + "message": "No standards found with ID 'invalid_id'" + } + ] +} +``` + +## Related Tasks + +- [get_content_standards](/docs/governance/content-standards/tasks/get_content_standards) - Get current configuration +- [create_content_standards](/docs/governance/content-standards/tasks/create_content_standards) - Create a new configuration +- [delete_content_standards](/docs/governance/content-standards/tasks/delete_content_standards) - Delete a configuration diff --git a/v2.6-rc/docs/governance/content-standards/tasks/validate_content_delivery.mdx b/v2.6-rc/docs/governance/content-standards/tasks/validate_content_delivery.mdx new file mode 100644 index 0000000000..97f36c2b71 --- /dev/null +++ b/v2.6-rc/docs/governance/content-standards/tasks/validate_content_delivery.mdx @@ -0,0 +1,183 @@ +--- +title: validate_content_delivery +sidebar_position: 4 +--- + +# validate_content_delivery + +Validate delivery records against content safety policies. Designed for batch auditing of where ads were actually delivered. + +**Asynchronous**: Accept immediately, process in background. Returns a `validation_id` for status polling. + +## Data Flow + +Content artifacts are separate from delivery metrics. Use `get_media_buy_artifacts` to retrieve content for validation: + +```mermaid +sequenceDiagram + participant Buyer as Buyer Agent + participant Seller as Seller Agent + participant Verifier as Verification Agent + + Buyer->>Seller: get_media_buy_artifacts (sampled) + Seller-->>Buyer: Artifacts with content + Buyer->>Verifier: validate_content_delivery + Verifier-->>Buyer: Validation results +``` + +**Why through the buyer?** + +- The **buyer** owns the media buy and knows which `standards_id` applies +- The **buyer** requests artifacts from sellers (separate from performance metrics) +- The **buyer** is accountable for brand safety compliance +- The **verification agent** works on behalf of the buyer + +This keeps responsibilities clear: sellers provide content samples via `get_media_buy_artifacts`, buyers validate samples against the verification agent. + +## Request + +**Schema**: [validate-content-delivery-request.json](https://adcontextprotocol.org/schemas/v2/content-standards/validate-content-delivery-request.json) + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `standards_id` | string | Yes | Standards configuration to validate against | +| `records` | array | Yes | Delivery records to validate (max 10,000) | +| `feature_ids` | array | No | Specific features to evaluate (defaults to all) | +| `include_passed` | boolean | No | Include passed records in results (default: true) | + +### Delivery Record + +```json +{ + "record_id": "imp_12345", + "timestamp": "2025-01-15T10:30:00Z", + "media_buy_id": "mb_nike_reddit_q1", + "artifact": { + "property_id": {"type": "domain", "value": "example.com"}, + "artifact_id": "article_12345", + "assets": [ + {"type": "text", "role": "title", "content": "Article Title"} + ] + }, + "country": "US", + "channel": "display", + "brand_context": { + "brand_id": "nike_global", + "sku_id": "air_max_2025" + } +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `record_id` | Yes | Unique identifier for this delivery record | +| `artifact` | Yes | Content artifact where ad was delivered | +| `media_buy_id` | No | Media buy this record belongs to (for multi-buy batches) | +| `timestamp` | No | When the delivery occurred | +| `country` | No | ISO 3166-1 alpha-2 country code for targeting context | +| `channel` | No | Channel type (display, video, audio, social) | +| `brand_context` | No | Brand/SKU information for policy evaluation (schema TBD) | + +## Response + +**Schema**: [validate-content-delivery-response.json](https://adcontextprotocol.org/schemas/v2/content-standards/validate-content-delivery-response.json) + +### Success Response + +```json +{ + "summary": { + "total_records": 1000, + "passed_records": 950, + "failed_records": 50, + "total_features": 5000, + "passed_features": 4750, + "failed_features": 250 + }, + "results": [ + { + "record_id": "imp_12345", + "features": [ + { + "feature_id": "brand_safety", + "status": "passed", + "value": "safe" + } + ] + }, + { + "record_id": "imp_12346", + "features": [ + { + "feature_id": "brand_safety", + "status": "failed", + "value": "high_risk", + "message": "Content contains violence" + } + ] + } + ] +} +``` + +## Use Cases + +### Post-Campaign Audit + +```python +def audit_campaign_delivery(campaign_id, standards_id, content_standards_agent): + """Audit all delivery records from a campaign.""" + # Fetch delivery records from your ad server + records = fetch_delivery_records(campaign_id) + + # Validate in batches + batch_size = 10000 + all_results = [] + + for i in range(0, len(records), batch_size): + batch = records[i:i + batch_size] + response = content_standards_agent.validate_content_delivery( + standards_id=standards_id, + records=batch + ) + all_results.extend(response["results"]) + + return all_results +``` + +### Real-Time Monitoring Sample + +```python +import random + +def sample_and_validate(records, standards_id, sample_size=1000): + """Validate a random sample for real-time monitoring.""" + sample = random.sample(records, min(sample_size, len(records))) + return content_standards_agent.validate_content_delivery( + standards_id=standards_id, + records=sample + ) +``` + +### Filter for Issues Only + +```python +# Only get failed records to reduce response size +response = content_standards_agent.validate_content_delivery( + standards_id="nike_emea_safety", + records=delivery_records, + include_passed=False # Only return failures +) + +for result in response["results"]: + print(f"Issue with {result['record_id']}") + for feature in result["features"]: + if feature["status"] == "failed": + print(f" - {feature['feature_id']}: {feature['message']}") +``` + +## Related Tasks + +- [get_media_buy_artifacts](/docs/governance/content-standards/tasks/get_media_buy_artifacts) - Get content artifacts from seller +- [calibrate_content](/docs/governance/content-standards/tasks/calibrate_content) - Understand why artifacts pass/fail +- [get_content_standards](/docs/governance/content-standards/tasks/get_content_standards) - Retrieve the policies diff --git a/v2.6-rc/docs/media-buy/task-reference/create_media_buy.mdx b/v2.6-rc/docs/media-buy/task-reference/create_media_buy.mdx index 80001c91ba..fa138364b3 100644 --- a/v2.6-rc/docs/media-buy/task-reference/create_media_buy.mdx +++ b/v2.6-rc/docs/media-buy/task-reference/create_media_buy.mdx @@ -182,8 +182,8 @@ npx adcp \ | `pacing` | string | No | `"even"` (default), `"asap"`, or `"front_loaded"` | | `bid_price` | number | No | Bid price for auction pricing (required when `is_fixed` is false) | | `targeting_overlay` | TargetingOverlay | No | Additional targeting criteria (see [Targeting](/docs/media-buy/advanced-topics/targeting)) | -| `creative_ids` | string[] | No | Existing library creative IDs to assign | -| `creatives` | CreativeAsset[] | No | Full creative objects to upload and assign | +| `creative_assignments` | CreativeAssignment[] | No | Assign existing library creatives with optional weights and placement targeting | +| `creatives` | CreativeAsset[] | No | Upload new creative assets and assign (`creative_id` must not already exist in library) | ## Response @@ -550,6 +550,7 @@ Common errors and resolutions: | `TARGETING_TOO_NARROW` | Targeting yields zero inventory | Broaden geographic or audience criteria | | `POLICY_VIOLATION` | Brand/product violates policy | Review publisher's content policies | | `INVALID_PRICING_OPTION` | pricing_option_id not found | Use ID from product's `pricing_options` | +| `CREATIVE_ID_EXISTS` | Creative ID already exists in library | Use a different `creative_id`, assign existing creatives via `creative_assignments`, or update via `sync_creatives` | Example error response: @@ -1025,6 +1026,7 @@ For complete async handling patterns, see [Task Management](/docs/protocols/task - AXE segments enable advanced audience targeting - Pending states (`working`, `submitted`) are normal, not errors - Orchestrators MUST handle pending states as part of normal workflow +- **Inline creatives**: The `creatives` array creates NEW creatives only. To update existing creatives, use [`sync_creatives`](/docs/media-buy/task-reference/sync_creatives). To assign existing library creatives, use `creative_assignments` instead. ## Policy Compliance diff --git a/v2.6-rc/docs/media-buy/task-reference/sync_creatives.mdx b/v2.6-rc/docs/media-buy/task-reference/sync_creatives.mdx index e5133c59d2..30a5182714 100644 --- a/v2.6-rc/docs/media-buy/task-reference/sync_creatives.mdx +++ b/v2.6-rc/docs/media-buy/task-reference/sync_creatives.mdx @@ -106,7 +106,7 @@ asyncio.run(main()) | `assignments` | object | No | Map of creative_id to array of package_ids for bulk assignment | | `dry_run` | boolean | No | When true, preview changes without applying them (default: false) | | `validation_mode` | string | No | Validation strictness: `"strict"` (default) or `"lenient"` | -| `delete_missing` | boolean | No | When true, creatives not in this sync are archived (default: false) | +| `delete_missing` | boolean | No | When true, creatives not in this sync are archived (default: false). Cannot delete creatives assigned to active, non-paused packages. | ### Creative Object @@ -651,11 +651,11 @@ The operation status (`completed`) means the review process finished. Individual | `PACKAGE_NOT_FOUND` | Package ID doesn't exist in media buy | Verify `package_id` from `create_media_buy` response | | `BRAND_SAFETY_VIOLATION` | Creative failed brand safety scan | Review content against publisher's brand safety guidelines | | `FORMAT_MISMATCH` | Assets don't match format requirements | Verify asset types and specifications match format definition | -| `DUPLICATE_CREATIVE_ID` | Creative ID already exists in different media buy | Use unique `creative_id` or sync to correct media buy | +| `CREATIVE_IN_ACTIVE_DELIVERY` | Creative is assigned to an active, non-paused package (blocks updates and `delete_missing` deletions) | Pause the package first, or create a new creative version | ## Best Practices -1. **Use upsert semantics** - Same `creative_id` updates existing creative rather than creating duplicates. This allows iterative creative development. +1. **Use upsert semantics** - Same `creative_id` updates existing creative rather than creating duplicates. This allows iterative creative development. Note: updates are blocked for creatives in active delivery (see #7). 2. **Validate first** - Use `dry_run: true` to catch errors before actual upload. This saves bandwidth and processing time. @@ -667,6 +667,8 @@ The operation status (`completed`) means the review process finished. Individual 6. **Check format support** - Use `list_creative_formats` to verify product supports your creative formats before uploading. +7. **Active delivery protection** - Creatives assigned to active, non-paused packages cannot be updated or deleted via `delete_missing`. Pause the package first, unassign the creative via `update_media_buy`, or create a new creative with a different `creative_id`. + ## Next Steps - [list_creative_formats](/docs/media-buy/task-reference/list_creative_formats) - Check supported formats before upload diff --git a/v2.6-rc/docs/media-buy/task-reference/update_media_buy.mdx b/v2.6-rc/docs/media-buy/task-reference/update_media_buy.mdx index 237b6847e7..0dd78ea270 100644 --- a/v2.6-rc/docs/media-buy/task-reference/update_media_buy.mdx +++ b/v2.6-rc/docs/media-buy/task-reference/update_media_buy.mdx @@ -149,11 +149,25 @@ asyncio.run(create_and_pause_campaign()) | `end_time` | string | No | Updated campaign end time | | `paused` | boolean | No | Pause/resume entire media buy (`true` = paused, `false` = active) | | `packages` | PackageUpdate[] | No | Package-level updates (see below) | -| `creatives` | CreativeAsset[] | No | Upload and assign new creative assets inline | -| `creative_assignments` | CreativeAssignment[] | No | Update creative rotation weights and placement targeting | +| `reporting_webhook` | object | No | Update reporting webhook configuration (see below) | +| `push_notification_config` | object | No | Webhook for async operation notifications | *Either `media_buy_id` OR `buyer_ref` is required (not both) +### Reporting Webhook Object + +Configure automated delivery reporting for this media buy: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `url` | string | Yes | Webhook endpoint URL | +| `authentication` | object | Yes | Auth config with `schemes` and `credentials` | +| `reporting_frequency` | string | Yes | `hourly`, `daily`, or `monthly` | +| `requested_metrics` | string[] | No | Specific metrics to include (defaults to all) | +| `token` | string | No | Client token for validation (min 16 chars) | + +**Note**: `reporting_webhook` configures ongoing campaign reporting. `push_notification_config` is for async operation notifications (e.g., "notify me when this update completes"). + ### Package Update Object | Parameter | Type | Description | @@ -166,7 +180,8 @@ asyncio.run(create_and_pause_campaign()) | `pacing` | string | Updated pacing strategy | | `bid_price` | number | Updated bid price (auction products only) | | `targeting_overlay` | TargetingOverlay | Updated targeting restrictions | -| `creative_ids` | string[] | Replace assigned creatives | +| `creative_assignments` | CreativeAssignment[] | Replace assigned creatives with optional weights and placement targeting | +| `creatives` | CreativeAsset[] | Upload and assign new creatives inline (must not exist in library) | *Either `package_id` OR `buyer_ref` is required for each package update @@ -372,7 +387,7 @@ asyncio.run(update_targeting()) ### Replace Creatives -Swap out creative assets for a package: +Swap out creative assignments for a package: @@ -384,7 +399,10 @@ const result = await testAgent.updateMediaBuy({ media_buy_id: 'mb_12345', packages: [{ buyer_ref: 'ctv_package', - creative_ids: ['creative_video_v2', 'creative_display_v2'] + creative_assignments: [ + { creative_id: 'creative_video_v2' }, + { creative_id: 'creative_display_v2', weight: 60 } + ] }] }); @@ -415,7 +433,10 @@ async def replace_creatives(): packages=[ { 'buyer_ref': 'ctv_package', - 'creative_ids': ['creative_video_v2', 'creative_display_v2'] + 'creative_assignments': [ + {'creative_id': 'creative_video_v2'}, + {'creative_id': 'creative_display_v2', 'weight': 60} + ] } ] ) @@ -513,6 +534,7 @@ asyncio.run(update_multiple_packages()) ✅ **Can update:** - Start/end times (subject to seller approval) - Campaign status (active/paused) +- Reporting webhook configuration (URL, frequency, metrics) ❌ **Cannot update:** - Media buy ID @@ -548,6 +570,7 @@ Common errors and resolutions: | `BUDGET_INSUFFICIENT` | New budget below minimum | Increase budget amount | | `POLICY_VIOLATION` | Update violates content policy | Review policy requirements | | `INVALID_STATE` | Operation not allowed in current state | Check campaign status | +| `CREATIVE_ID_EXISTS` | Creative ID already exists in library | Use a different `creative_id`, assign existing creatives via `creative_assignments`, or update via `sync_creatives` | Example error response: @@ -596,13 +619,16 @@ Only specified fields are updated - omitted fields remain unchanged: } ``` -**Array replacement**: When updating arrays (like `creative_ids`), provide the complete new array: +**Array replacement**: When updating arrays (like `creative_assignments`), provide the complete new array: ```json { "packages": [{ "buyer_ref": "ctv_package", - "creative_ids": ["creative_video_v2", "creative_display_v2"] + "creative_assignments": [ + { "creative_id": "creative_video_v2" }, + { "creative_id": "creative_display_v2", "weight": 60 } + ] }] } ``` @@ -678,13 +704,14 @@ Check `affected_packages` in response to confirm changes were applied correctly. - Pending states (`working`, `submitted`) are normal, not errors - Orchestrators MUST handle pending states as part of normal workflow - `implementation_date` indicates when changes take effect (null if pending approval) +- **Inline creatives**: The `creatives` array creates NEW creatives only. To update existing creatives, use [`sync_creatives`](/docs/media-buy/task-reference/sync_creatives). To assign existing library creatives, use `creative_assignments` in the Package Update object. ## Next Steps After updating a media buy: 1. **Verify Changes**: Use [`get_media_buy_delivery`](/docs/media-buy/task-reference/get_media_buy_delivery) to confirm updates -2. **Upload New Creatives**: Use [`sync_creatives`](/docs/media-buy/task-reference/sync_creatives) if creative_ids changed +2. **Upload New Creatives**: Use [`sync_creatives`](/docs/media-buy/task-reference/sync_creatives) if creative assignments changed 3. **Monitor Performance**: Track impact of changes on campaign metrics 4. **Optimize Further**: Use [`provide_performance_feedback`](/docs/media-buy/task-reference/provide_performance_feedback) for ongoing optimization diff --git a/v2.6-rc/docs/reference/error-codes.mdx b/v2.6-rc/docs/reference/error-codes.mdx index 0132e64cdf..0eac0041c3 100644 --- a/v2.6-rc/docs/reference/error-codes.mdx +++ b/v2.6-rc/docs/reference/error-codes.mdx @@ -425,6 +425,61 @@ Request exceeded maximum processing time. **Resolution**: Refine request parameters or retry. +## Content Standards Errors + +### STANDARDS_NOT_FOUND +Specified standards ID doesn't exist. + +**Example**: +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/v2/core/error.json", + "code": "STANDARDS_NOT_FOUND", + "message": "No standards found with ID 'invalid_id'", + "details": { + "standards_id": "invalid_id" + } +} +``` + +**Resolution**: Use `list_content_standards` to find valid standards IDs. + +### STANDARDS_IN_USE +Cannot delete standards that are referenced by active media buys. + +**Example**: +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/v2/core/error.json", + "code": "STANDARDS_IN_USE", + "message": "Cannot delete standards 'nike_emea_safety' - currently referenced by active media buys", + "details": { + "standards_id": "nike_emea_safety", + "active_media_buy_count": 3 + } +} +``` + +**Resolution**: Wait for media buys to complete before deleting. + +### STANDARDS_SCOPE_CONFLICT +New standards configuration conflicts with existing standards for the same scope. + +**Example**: +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/v2/core/error.json", + "code": "STANDARDS_SCOPE_CONFLICT", + "message": "Standards already exist for brand 'nike' in countries ['GB', 'DE']", + "details": { + "conflicting_standards_id": "nike_emea_safety", + "overlapping_countries": ["GB", "DE"] + } +} +``` + +**Resolution**: Update existing standards or narrow scope to avoid overlap. + ## Data Errors ### DATA_QUALITY_ISSUE @@ -506,7 +561,10 @@ const PERMANENT_ERRORS = [ 'INSUFFICIENT_PERMISSIONS', 'SEGMENT_NOT_FOUND', 'PLATFORM_UNAUTHORIZED', - 'UNSUPPORTED_VERSION' + 'UNSUPPORTED_VERSION', + 'STANDARDS_NOT_FOUND', + 'STANDARDS_IN_USE', + 'STANDARDS_SCOPE_CONFLICT' ]; function isRetryable(errorCode: string): boolean { From 93087701bd45923f0b8490d0187544b4b56c32be Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 07:03:04 -0500 Subject: [PATCH 47/58] feat: Auto-sync navigation from 2.6.x docs.json (#821) The sync workflow now extracts navigation groups from 2.6.x's docs.json, transforms paths by prepending "v2.6-rc/", and updates main's docs.json. This ensures new sections (like governance) automatically appear in v2.6-rc navigation without manual docs.json updates on main. Co-authored-by: Claude Opus 4.5 --- .github/workflows/sync-versioned-docs.yml | 48 ++++++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-versioned-docs.yml b/.github/workflows/sync-versioned-docs.yml index 5295000d84..a641090206 100644 --- a/.github/workflows/sync-versioned-docs.yml +++ b/.github/workflows/sync-versioned-docs.yml @@ -39,7 +39,7 @@ jobs: if git ls-tree -d origin/2.6.x -- dist/schemas/2.6.0 > /dev/null 2>&1; then # Remove existing 2.6.0 schemas to ensure clean sync rm -rf dist/schemas/2.6.0 - + # Extract dist/schemas/2.6.0 folder from 2.6.x branch git archive origin/2.6.x -- dist/schemas/2.6.0 | tar -x echo "Synced dist/schemas/2.6.0 from 2.6.x branch" @@ -47,6 +47,48 @@ jobs: echo "dist/schemas/2.6.0 does not exist in 2.6.x branch, skipping" fi + - name: Sync navigation from 2.6.x docs.json + run: | + # Extract docs.json from 2.6.x branch + git show origin/2.6.x:docs.json > /tmp/source-docs.json + + # Create jq script to transform navigation + # Takes the first version's groups from 2.6.x (which has the actual content) + # and transforms all paths by prepending "v2.6-rc/" + cat << 'JQSCRIPT' > /tmp/transform.jq + def transform_paths: + if type == "array" then + map(transform_paths) + elif type == "object" then + if has("pages") then + .pages = (.pages | transform_paths) + else + . + end + elif type == "string" then + "v2.6-rc/" + . + else + . + end; + + # $source is from --slurpfile (array), so use $source[0] + ($source[0].navigation.versions[0].groups | transform_paths) as $transformed | + (.navigation.versions | to_entries | map(select(.value.version == "2.6-rc")) | .[0].key) as $idx | + if $idx != null then + .navigation.versions[$idx].groups = $transformed + else + . + end + JQSCRIPT + + # Apply transformation: read source groups from 2.6.x, transform paths, update main's docs.json + jq --slurpfile source /tmp/source-docs.json -f /tmp/transform.jq docs.json > /tmp/updated-docs.json + + # Replace docs.json with updated version + mv /tmp/updated-docs.json docs.json + + echo "Synced navigation from 2.6.x docs.json to v2.6-rc version" + - name: Create Pull Request uses: peter-evans/create-pull-request@v7 with: @@ -57,10 +99,12 @@ jobs: This PR syncs from the 2.6.x branch: - Documentation to `v2.6-rc/docs/` - Schemas to `dist/schemas/2.6.0/` (if available) - + - Navigation config from 2.6.x's docs.json to main's v2.6-rc version + Auto-generated by the sync-versioned-docs workflow. branch: auto/sync-v2.6-rc delete-branch: true add-paths: | v2.6-rc/ dist/schemas/2.6.0/ + docs.json From af274d28fc7fbc02ca2b2cf498436a6fad5158ec Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 07:21:44 -0500 Subject: [PATCH 48/58] fix: Align account view counts with results queries (#819) The view counts endpoint was showing different numbers than actual results because the count queries used different filtering logic than the results queries: - needs_attention: Count included all orgs with action items, but results only showed non-members OR members with expiring subscriptions - new_insights: Count used 7-day window, results used 30-day window - hot: Count required both engagement AND interest, results used OR Also removed a dead duplicate endpoint that was unreachable. Co-authored-by: Claude Opus 4.5 --- .changeset/tough-bottles-roll.md | 2 + server/src/routes/admin/accounts.ts | 198 ++++------------------------ 2 files changed, 28 insertions(+), 172 deletions(-) create mode 100644 .changeset/tough-bottles-roll.md diff --git a/.changeset/tough-bottles-roll.md b/.changeset/tough-bottles-roll.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/tough-bottles-roll.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/routes/admin/accounts.ts b/server/src/routes/admin/accounts.ts index ba5fd2d140..55375fe569 100644 --- a/server/src/routes/admin/accounts.ts +++ b/server/src/routes/admin/accounts.ts @@ -171,7 +171,7 @@ export function setupAccountRoutes( members, disqualified, ] = await Promise.all([ - // Needs attention - all accounts needing action + // Needs attention - prospects with action items OR members with real problems pool.query(` SELECT COUNT(DISTINCT o.workos_organization_id) as count FROM organizations o @@ -183,18 +183,30 @@ export function setupAccountRoutes( AND oi.status IN ('draft', 'open') WHERE COALESCE(o.prospect_status, 'prospect') != 'disqualified' AND ( - na.id IS NOT NULL - OR oi.stripe_invoice_id IS NOT NULL - OR ( - COALESCE(o.engagement_score, 0) >= 50 - AND NOT EXISTS ( - SELECT 1 FROM org_stakeholders os WHERE os.organization_id = o.workos_organization_id + -- Non-members: show if they have action items + ( + (${NOT_MEMBER_ALIASED}) + AND ( + na.id IS NOT NULL + OR oi.stripe_invoice_id IS NOT NULL + OR ( + COALESCE(o.engagement_score, 0) >= 50 + AND NOT EXISTS ( + SELECT 1 FROM org_stakeholders os WHERE os.organization_id = o.workos_organization_id + ) + ) ) ) + OR + -- Members: only show if they have a real problem (expiring soon) + ( + ${MEMBER_FILTER_ALIASED} + AND o.subscription_current_period_end <= NOW() + INTERVAL '30 days' + ) ) `), - // New insights - prospects with recent Slack activity + // New insights - prospects with recent Slack activity (30 days) pool.query(` SELECT COUNT(DISTINCT o.workos_organization_id) as count FROM organizations o @@ -202,19 +214,21 @@ export function setupAccountRoutes( SELECT 1 FROM organization_memberships om JOIN slack_user_mappings sm ON sm.workos_user_id = om.workos_user_id WHERE om.workos_organization_id = o.workos_organization_id - AND sm.last_slack_activity_at >= NOW() - INTERVAL '7 days' + AND sm.last_slack_activity_at >= NOW() - INTERVAL '30 days' ) AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' AND (${NOT_MEMBER_ALIASED}) `), - // Hot prospects + // Hot prospects (engagement >= 50 OR high interest) pool.query(` SELECT COUNT(*) as count FROM organizations o WHERE (${NOT_MEMBER_ALIASED}) - AND COALESCE(o.engagement_score, 0) >= 50 - AND o.interest_level IN ('high', 'very_high') + AND ( + COALESCE(o.engagement_score, 0) >= 50 + OR o.interest_level IN ('high', 'very_high') + ) AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' `), @@ -1270,166 +1284,6 @@ export function setupAccountRoutes( } }); - // GET /api/admin/accounts/view-counts - Get counts for each view tab - apiRouter.get( - "/accounts/view-counts", - requireAuth, - requireAdmin, - async (req, res) => { - try { - const pool = getPool(); - const currentUserId = req.user?.id; - - const [ - needsAttention, - newInsights, - hot, - newProspects, - goingCold, - myAccounts, - renewals, - members, - disqualified, - ] = await Promise.all([ - // Needs attention - prospects with action items OR members with real problems - pool.query(` - SELECT COUNT(DISTINCT o.workos_organization_id) as count - FROM organizations o - LEFT JOIN org_activities na ON na.organization_id = o.workos_organization_id - AND na.is_next_step = TRUE - AND na.next_step_completed_at IS NULL - AND (na.next_step_due_date IS NULL OR na.next_step_due_date <= NOW() + INTERVAL '7 days') - LEFT JOIN org_invoices oi ON oi.workos_organization_id = o.workos_organization_id - AND oi.status IN ('draft', 'open') - WHERE COALESCE(o.prospect_status, 'prospect') != 'disqualified' - AND ( - -- Non-members: show if they have action items - ( - (${NOT_MEMBER_ALIASED}) - AND ( - na.id IS NOT NULL - OR oi.stripe_invoice_id IS NOT NULL - OR ( - COALESCE(o.engagement_score, 0) >= 50 - AND NOT EXISTS ( - SELECT 1 FROM org_stakeholders os WHERE os.organization_id = o.workos_organization_id - ) - ) - ) - ) - OR - -- Members: only show if they have a real problem (expiring soon) - ( - ${MEMBER_FILTER_ALIASED} - AND o.subscription_current_period_end <= NOW() + INTERVAL '30 days' - ) - ) - `), - - // New insights - prospects with recent Slack activity (30 days) - pool.query(` - SELECT COUNT(DISTINCT o.workos_organization_id) as count - FROM organizations o - WHERE EXISTS ( - SELECT 1 FROM organization_memberships om - JOIN slack_user_mappings sm ON sm.workos_user_id = om.workos_user_id - WHERE om.workos_organization_id = o.workos_organization_id - AND sm.last_slack_activity_at >= NOW() - INTERVAL '30 days' - ) - AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' - AND (${NOT_MEMBER_ALIASED}) - `), - - // Hot prospects (engagement >= 50 OR high interest) - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE (${NOT_MEMBER_ALIASED}) - AND ( - COALESCE(o.engagement_score, 0) >= 50 - OR o.interest_level IN ('high', 'very_high') - ) - AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' - `), - - // New prospects - recently created non-members - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE o.created_at >= NOW() - INTERVAL '14 days' - AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' - AND (${NOT_MEMBER_ALIASED}) - `), - - // Going cold - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE o.last_activity_at IS NOT NULL - AND o.last_activity_at < NOW() - INTERVAL '30 days' - AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' - AND (${NOT_MEMBER_ALIASED}) - `), - - // My accounts - currentUserId - ? pool.query( - ` - SELECT COUNT(DISTINCT o.workos_organization_id) as count - FROM organizations o - INNER JOIN org_stakeholders os ON os.organization_id = o.workos_organization_id AND os.user_id = $1 - WHERE COALESCE(o.prospect_status, 'prospect') != 'disqualified' - `, - [currentUserId] - ) - : Promise.resolve({ rows: [{ count: 0 }] }), - - // Renewals - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE ${MEMBER_FILTER_ALIASED} - AND o.subscription_current_period_end IS NOT NULL - AND o.subscription_current_period_end <= NOW() + INTERVAL '60 days' - AND o.subscription_current_period_end > NOW() - `), - - // Members - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE ${MEMBER_FILTER_ALIASED} - `), - - // Disqualified - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE o.prospect_status = 'disqualified' - `), - ]); - - res.json({ - needs_attention: parseInt(needsAttention.rows[0].count), - new_insights: parseInt(newInsights.rows[0].count), - hot: parseInt(hot.rows[0].count), - new_prospects: parseInt(newProspects.rows[0].count), - going_cold: parseInt(goingCold.rows[0].count), - my_accounts: parseInt(myAccounts.rows[0].count), - renewals: parseInt(renewals.rows[0].count), - members: parseInt(members.rows[0].count), - disqualified: parseInt(disqualified.rows[0].count), - }); - } catch (error) { - logger.error({ err: error }, "Error fetching view counts"); - res.status(500).json({ - error: "Internal server error", - message: "Unable to fetch view counts", - }); - } - } - ); - // GET /api/admin/activity-feed - Unified activity stream across all sources apiRouter.get( "/activity-feed", From 0a76316c28421654bf94b28d678f5631c080e166 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 08:27:44 -0500 Subject: [PATCH 49/58] fix: Admin dashboard performance metrics and model tracking (#824) - Fix latency distribution chart not rendering (CSS height issue) - Add haiku tracking for background API calls (router, insight extraction) - Add channel breakdown columns (LLM time, tool time, iterations) - Fix precision model (Opus) not used in streaming API calls - Fix effective model not recorded when saving messages - Update precision model to claude-opus-4-5 Co-authored-by: Claude Opus 4.5 --- .changeset/crisp-goats-sink.md | 2 + server/public/admin-addie.html | 15 +++-- server/src/addie/bolt-app.ts | 3 +- server/src/addie/claude-client.ts | 12 +++- server/src/addie/router.ts | 10 ++++ server/src/addie/services/api-tracker.ts | 52 +++++++++++++++++ .../src/addie/services/insight-extractor.ts | 13 +++++ server/src/addie/thread-service.ts | 56 ++++++++++++++----- server/src/config/models.ts | 4 +- .../src/db/migrations/182_addie_api_calls.sql | 18 ++++++ 10 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 .changeset/crisp-goats-sink.md create mode 100644 server/src/addie/services/api-tracker.ts create mode 100644 server/src/db/migrations/182_addie_api_calls.sql diff --git a/.changeset/crisp-goats-sink.md b/.changeset/crisp-goats-sink.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/crisp-goats-sink.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/admin-addie.html b/server/public/admin-addie.html index 1cbf9a2f92..cf5c55ce6e 100644 --- a/server/public/admin-addie.html +++ b/server/public/admin-addie.html @@ -1771,6 +1771,9 @@

By Channel

Channel Messages Avg Latency + LLM Time + Tool Time + Iterations @@ -2593,11 +2596,12 @@

${escapeHtml(doc.title)}

// Latency distribution chart (simple bar chart) const chartContainer = document.getElementById('latency-chart'); const maxCount = Math.max(...data.latency_distribution.map(d => d.count), 1); + const maxBarHeight = 110; // pixels, leaving room for labels chartContainer.innerHTML = data.latency_distribution.map(d => { - const height = Math.max(5, (d.count / maxCount) * 100); + const barHeight = Math.max(4, Math.round((d.count / maxCount) * maxBarHeight)); return ` -
-
+
+
${d.bucket}
${d.count}
@@ -2636,8 +2640,11 @@

${escapeHtml(doc.title)}

${c.channel} ${c.message_count} ${c.avg_latency_ms ? (c.avg_latency_ms / 1000).toFixed(1) + 's' : '-'} + ${c.avg_llm_ms ? (c.avg_llm_ms / 1000).toFixed(1) + 's' : '-'} + ${c.avg_tool_ms ? (c.avg_tool_ms / 1000).toFixed(1) + 's' : '-'} + ${c.avg_iterations || '-'} - `).join('') || 'No data'; + `).join('') || 'No data'; } catch (error) { console.error('Error loading performance:', error); diff --git a/server/src/addie/bolt-app.ts b/server/src/addie/bolt-app.ts index 5cec58c153..5fc919a61a 100644 --- a/server/src/addie/bolt-app.ts +++ b/server/src/addie/bolt-app.ts @@ -2578,6 +2578,7 @@ async function handleChannelMessage({ // Generate a response with the specified tools (includes admin tools if user is admin, meeting tools with channel context) const { tools: userTools, isAdmin: userIsAdmin } = await createUserScopedTools(memberContext, userId, thread.thread_id, channelContext); // Use precision model (Opus) for billing/financial queries + const effectiveModel = plan.requires_precision ? ModelConfig.precision : AddieModelConfig.chat; const processOptions = { ...(userIsAdmin ? { maxIterations: ADMIN_MAX_ITERATIONS } : {}), ...(plan.requires_precision ? { modelOverride: ModelConfig.precision } : {}), @@ -2609,7 +2610,7 @@ async function handleChannelMessage({ duration_ms: exec.duration_ms, is_error: exec.is_error, })), - model: AddieModelConfig.chat, + model: effectiveModel, latency_ms: Date.now() - startTime, tokens_input: response.usage?.input_tokens, tokens_output: response.usage?.output_tokens, diff --git a/server/src/addie/claude-client.ts b/server/src/addie/claude-client.ts index e3350f76d2..4b5e3da508 100644 --- a/server/src/addie/claude-client.ts +++ b/server/src/addie/claude-client.ts @@ -814,6 +814,12 @@ export class AddieClaudeClient { // Get config version ID for this interaction (for tracking/analysis) const configVersionId = await getCurrentConfigVersionId(ruleIds, rulesSnapshot); + // Determine effective model (support precision mode override for billing/financial) + const effectiveModel = options?.modelOverride ?? this.model; + if (options?.modelOverride && options.modelOverride !== this.model) { + logger.info({ model: effectiveModel, defaultModel: this.model }, 'Addie Stream: Using precision model for billing/financial query'); + } + // Combine global tools with per-request tools // Calculate tool count first to inform token budget for conversation history const allTools = [...this.tools, ...(requestTools?.tools || [])]; @@ -825,7 +831,7 @@ export class AddieClaudeClient { // Token-aware: automatically trims older messages if conversation exceeds limits // Pass tool count for more accurate token budget calculation const messageTurnsResult = buildMessageTurnsWithMetadata(userMessage, threadContext, { - model: this.model, + model: effectiveModel, toolCount, }); @@ -834,7 +840,7 @@ export class AddieClaudeClient { { messagesRemoved: messageTurnsResult.messagesRemoved, estimatedTokens: formatTokenCount(messageTurnsResult.estimatedTokens), - tokenLimit: formatTokenCount(getConversationTokenLimit(this.model, toolCount)), + tokenLimit: formatTokenCount(getConversationTokenLimit(effectiveModel, toolCount)), toolCount, }, 'Addie Stream: Trimmed conversation history to fit context limit' @@ -877,7 +883,7 @@ export class AddieClaudeClient { try { // Use streaming API const stream = this.client.messages.stream({ - model: this.model, + model: effectiveModel, max_tokens: 4096, system: systemPrompt, tools: customTools, diff --git a/server/src/addie/router.ts b/server/src/addie/router.ts index d21c1fd683..5d7d30b137 100644 --- a/server/src/addie/router.ts +++ b/server/src/addie/router.ts @@ -25,6 +25,7 @@ import type { AddieTool } from './types.js'; import { KNOWLEDGE_TOOLS } from './mcp/knowledge-search.js'; import { MEMBER_TOOLS } from './mcp/member-tools.js'; import { InsightsDatabase, type MemberInsight } from '../db/insights-db.js'; +import { trackApiCall, ApiPurpose } from './services/api-tracker.js'; /** * Execution plan types @@ -514,6 +515,15 @@ export class AddieRouter { requiresPrecision, }, 'Router: Execution plan generated'); + // Track for performance metrics (fire-and-forget, errors handled internally) + void trackApiCall({ + model: ModelConfig.fast, + purpose: ApiPurpose.ROUTER, + tokens_input: response.usage?.input_tokens, + tokens_output: response.usage?.output_tokens, + latency_ms: latencyMs, + }); + return plan; } catch (error) { logger.error({ error }, 'Router: Failed to generate execution plan'); diff --git a/server/src/addie/services/api-tracker.ts b/server/src/addie/services/api-tracker.ts new file mode 100644 index 0000000000..9a8d0a06fd --- /dev/null +++ b/server/src/addie/services/api-tracker.ts @@ -0,0 +1,52 @@ +/** + * API Call Tracker + * + * Tracks all Anthropic API calls for performance metrics. + * This captures both chat messages and background tasks (router, insight extraction, etc.) + */ + +import { query } from '../../db/client.js'; +import { logger } from '../../logger.js'; + +export interface ApiCallRecord { + model: string; + purpose: string; + tokens_input?: number; + tokens_output?: number; + latency_ms?: number; + thread_id?: string; +} + +/** + * Track an API call for performance metrics + */ +export async function trackApiCall(record: ApiCallRecord): Promise { + try { + await query( + `INSERT INTO addie_api_calls (model, purpose, tokens_input, tokens_output, latency_ms, thread_id) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + record.model, + record.purpose, + record.tokens_input ?? null, + record.tokens_output ?? null, + record.latency_ms ?? null, + record.thread_id ?? null, + ] + ); + } catch (error) { + // Don't fail the main operation if tracking fails + logger.warn({ error, record }, 'Failed to track API call'); + } +} + +/** + * API call purposes for consistent tracking + * Add new purposes here as tracking is implemented for other background tasks + */ +export const ApiPurpose = { + ROUTER: 'router', + INSIGHT_EXTRACTION: 'insight_extraction', +} as const; + +export type ApiPurpose = (typeof ApiPurpose)[keyof typeof ApiPurpose]; diff --git a/server/src/addie/services/insight-extractor.ts b/server/src/addie/services/insight-extractor.ts index 5e5be1488f..b482401c4d 100644 --- a/server/src/addie/services/insight-extractor.ts +++ b/server/src/addie/services/insight-extractor.ts @@ -21,6 +21,7 @@ import { } from '../../db/insights-db.js'; import { ModelConfig } from '../../config/models.js'; import { invalidateInsightsCache } from '../insights-cache.js'; +import { trackApiCall, ApiPurpose } from './api-tracker.js'; const insightsDb = new InsightsDatabase(); @@ -219,6 +220,7 @@ export async function extractInsights( const prompt = buildExtractionPrompt(message, insightTypes, activeGoals); const client = new Anthropic({ apiKey }); + const startTime = Date.now(); const response = await client.messages.create({ model: EXTRACTOR_MODEL, max_tokens: 1000, @@ -229,6 +231,17 @@ export async function extractInsights( }, ], }); + const latencyMs = Date.now() - startTime; + + // Track for performance metrics (fire-and-forget, errors handled internally) + void trackApiCall({ + model: EXTRACTOR_MODEL, + purpose: ApiPurpose.INSIGHT_EXTRACTION, + tokens_input: response.usage?.input_tokens, + tokens_output: response.usage?.output_tokens, + latency_ms: latencyMs, + thread_id: context.threadId, + }); // Parse response const responseText = response.content[0].type === 'text' ? response.content[0].text : ''; diff --git a/server/src/addie/thread-service.ts b/server/src/addie/thread-service.ts index 24799c6528..497ffd8482 100644 --- a/server/src/addie/thread-service.ts +++ b/server/src/addie/thread-service.ts @@ -932,9 +932,23 @@ export class ThreadService { [days] ); - // Latency distribution + // Latency distribution (includes background API calls) const latencyResult = await query<{ bucket: string; count: string }>( - `SELECT + `WITH all_api_calls AS ( + -- Chat responses from thread messages + SELECT latency_ms + FROM addie_thread_messages + WHERE role = 'assistant' + AND latency_ms IS NOT NULL + AND created_at > NOW() - make_interval(days => $1) + UNION ALL + -- Background API calls (router, insight extraction, etc.) + SELECT latency_ms + FROM addie_api_calls + WHERE latency_ms IS NOT NULL + AND created_at > NOW() - make_interval(days => $1) + ) + SELECT CASE WHEN latency_ms < 5000 THEN '0-5s' WHEN latency_ms < 10000 THEN '5-10s' @@ -944,16 +958,13 @@ export class ThreadService { ELSE '45s+' END as bucket, COUNT(*) as count - FROM addie_thread_messages - WHERE role = 'assistant' - AND latency_ms IS NOT NULL - AND created_at > NOW() - make_interval(days => $1) + FROM all_api_calls GROUP BY 1 ORDER BY MIN(latency_ms)`, [days] ); - // By model + // By model (combines thread messages + background API calls) const modelResult = await query<{ model: string; count: string; @@ -962,16 +973,26 @@ export class ThreadService { total_input_tokens: string; total_output_tokens: string; }>( - `SELECT + `WITH all_api_calls AS ( + -- Chat responses from thread messages + SELECT model, latency_ms, tokens_input, tokens_output + FROM addie_thread_messages + WHERE role = 'assistant' + AND created_at > NOW() - make_interval(days => $1) + UNION ALL + -- Background API calls (router, insight extraction, etc.) + SELECT model, latency_ms, tokens_input, tokens_output + FROM addie_api_calls + WHERE created_at > NOW() - make_interval(days => $1) + ) + SELECT COALESCE(model, 'unknown') as model, COUNT(*) as count, ROUND((AVG(latency_ms))::numeric, 0) as avg_latency_ms, ROUND((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY latency_ms))::numeric, 0) as p50_latency_ms, COALESCE(SUM(tokens_input), 0) as total_input_tokens, COALESCE(SUM(tokens_output), 0) as total_output_tokens - FROM addie_thread_messages - WHERE role = 'assistant' - AND created_at > NOW() - make_interval(days => $1) + FROM all_api_calls GROUP BY model ORDER BY count DESC`, [days] @@ -1007,16 +1028,22 @@ export class ThreadService { [days] ); - // By channel + // By channel (with timing breakdown to explain latency differences) const channelResult = await query<{ channel: string; message_count: string; avg_latency_ms: string | null; + avg_llm_ms: string | null; + avg_tool_ms: string | null; + avg_iterations: string | null; }>( `SELECT t.channel, COUNT(m.message_id) as message_count, - ROUND((AVG(m.latency_ms) FILTER (WHERE m.role = 'assistant'))::numeric, 0) as avg_latency_ms + ROUND((AVG(m.latency_ms) FILTER (WHERE m.role = 'assistant'))::numeric, 0) as avg_latency_ms, + ROUND((AVG(m.timing_total_llm_ms) FILTER (WHERE m.role = 'assistant'))::numeric, 0) as avg_llm_ms, + ROUND((AVG(m.timing_total_tool_ms) FILTER (WHERE m.role = 'assistant'))::numeric, 0) as avg_tool_ms, + ROUND((AVG(m.processing_iterations) FILTER (WHERE m.role = 'assistant'))::numeric, 1) as avg_iterations FROM addie_threads t JOIN addie_thread_messages m ON t.thread_id = m.thread_id WHERE m.created_at > NOW() - make_interval(days => $1) @@ -1084,6 +1111,9 @@ export class ThreadService { channel: r.channel, message_count: parseInt(r.message_count, 10) || 0, avg_latency_ms: r.avg_latency_ms ? parseInt(r.avg_latency_ms, 10) : null, + avg_llm_ms: r.avg_llm_ms ? parseInt(r.avg_llm_ms, 10) : null, + avg_tool_ms: r.avg_tool_ms ? parseInt(r.avg_tool_ms, 10) : null, + avg_iterations: r.avg_iterations ? parseFloat(r.avg_iterations) : null, })), daily_trend: dailyResult.rows.map(r => ({ date: r.date, diff --git a/server/src/config/models.ts b/server/src/config/models.ts index 193ebe0205..a8db242dcd 100644 --- a/server/src/config/models.ts +++ b/server/src/config/models.ts @@ -25,13 +25,13 @@ export const ModelConfig = { /** * Precision model for high-stakes tasks (billing, financial, legal) - * Default: claude-opus-4 (most capable, but more expensive) + * Default: claude-opus-4-5 (most capable, but more expensive) * Override: CLAUDE_MODEL_PRECISION * * Use this when accuracy is critical and hallucinations are costly. * Examples: sending invoices, quoting prices, handling payments. */ - precision: process.env.CLAUDE_MODEL_PRECISION || 'claude-opus-4', + precision: process.env.CLAUDE_MODEL_PRECISION || 'claude-opus-4-5', } as const; /** diff --git a/server/src/db/migrations/182_addie_api_calls.sql b/server/src/db/migrations/182_addie_api_calls.sql new file mode 100644 index 0000000000..5b4bf73148 --- /dev/null +++ b/server/src/db/migrations/182_addie_api_calls.sql @@ -0,0 +1,18 @@ +-- Track all Anthropic API calls for performance metrics +-- Captures both chat messages (Sonnet) and background tasks (Haiku) + +CREATE TABLE addie_api_calls ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + model TEXT NOT NULL, + purpose TEXT NOT NULL, -- 'chat', 'router', 'insight_extraction', 'outbound_planning', etc. + tokens_input INTEGER, + tokens_output INTEGER, + latency_ms INTEGER, + thread_id UUID REFERENCES addie_threads(thread_id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for performance queries +CREATE INDEX idx_addie_api_calls_created_at ON addie_api_calls(created_at); +CREATE INDEX idx_addie_api_calls_model ON addie_api_calls(model); +CREATE INDEX idx_addie_api_calls_purpose ON addie_api_calls(purpose); From d1a4b673e4d1ff124af11403176ce9bea82664af Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 09:26:28 -0500 Subject: [PATCH 50/58] feat: Tool-set based routing for Addie (#825) * feat: Tool-set based routing for Addie Replaces individual tool recommendations with category-based routing: - Router selects tool SETS (knowledge, member, directory, etc.) - Sonnet receives focused tools based on selected sets - Reduces context from ~4K to ~1-1.5K tokens per message - Includes always-available escape hatches (escalate_to_admin, etc.) - Adds unavailable sets hint so Sonnet can redirect if needed - Billing set triggers precision mode (Opus) Migration 183 adds backward-compatible view update. Co-Authored-By: Claude Opus 4.5 * feat: Add AdCP protocol tools with proper MCP schemas Adds standard MCP tools that match the AdCP protocol specification: - Media Buy tools: get_products, create_media_buy, sync_creatives, etc. - Creative tools: build_creative, preview_creative - Signals tools: get_signals, activate_signal These tools expose the same functionality as call_adcp_agent but with proper schemas enabling skills to work correctly. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/silent-adults-nail.md | 2 + server/scripts/analyze-tool-sets.sql | 242 +++++++ server/scripts/test-tool-sets-curl.sh | 59 ++ server/scripts/test-tool-sets-local.ts | 375 ++++++++++ server/scripts/test-tool-sets.ts | 329 +++++++++ server/src/addie/bolt-app.ts | 82 ++- server/src/addie/handler.ts | 12 + server/src/addie/mcp/adcp-tools.ts | 674 ++++++++++++++++++ server/src/addie/router.ts | 96 ++- server/src/addie/tool-sets.ts | 313 ++++++++ .../db/migrations/183_router_tool_sets.sql | 49 ++ server/src/routes/addie-admin.ts | 2 +- skills/adcp-creative/SKILL.md | 2 +- skills/adcp-media-buy/SKILL.md | 2 +- skills/adcp-signals/SKILL.md | 2 +- 15 files changed, 2179 insertions(+), 62 deletions(-) create mode 100644 .changeset/silent-adults-nail.md create mode 100644 server/scripts/analyze-tool-sets.sql create mode 100755 server/scripts/test-tool-sets-curl.sh create mode 100644 server/scripts/test-tool-sets-local.ts create mode 100644 server/scripts/test-tool-sets.ts create mode 100644 server/src/addie/mcp/adcp-tools.ts create mode 100644 server/src/addie/tool-sets.ts create mode 100644 server/src/db/migrations/183_router_tool_sets.sql diff --git a/.changeset/silent-adults-nail.md b/.changeset/silent-adults-nail.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/silent-adults-nail.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/scripts/analyze-tool-sets.sql b/server/scripts/analyze-tool-sets.sql new file mode 100644 index 0000000000..02b116dd23 --- /dev/null +++ b/server/scripts/analyze-tool-sets.sql @@ -0,0 +1,242 @@ +-- Analysis of Tool Sets Coverage +-- Run against production database to analyze how well tool sets would cover actual usage +-- +-- Usage: +-- fly proxy 15432:5432 -a agentic-advertising-db +-- psql postgres://user:pass@localhost:15432/dbname -f scripts/analyze-tool-sets.sql + +-- ============================================================================ +-- TOOL SETS DEFINITION (must match tool-sets.ts) +-- ============================================================================ + +CREATE TEMP TABLE tool_sets ( + set_name TEXT PRIMARY KEY, + tools TEXT[] +); + +INSERT INTO tool_sets VALUES + ('knowledge', ARRAY['search_docs', 'get_doc', 'search_repos', 'search_slack', 'get_channel_activity', 'search_resources', 'get_recent_news', 'fetch_url', 'read_slack_file']), + ('member', ARRAY['get_my_profile', 'update_my_profile', 'list_working_groups', 'get_working_group', 'join_working_group', 'get_my_working_groups', 'express_council_interest', 'withdraw_council_interest', 'get_my_council_interests', 'list_perspectives', 'create_working_group_post', 'propose_content', 'get_my_content', 'bookmark_resource']), + ('directory', ARRAY['search_members', 'request_introduction', 'get_my_search_analytics', 'list_members', 'get_member', 'list_agents', 'get_agent', 'list_publishers', 'lookup_domain']), + ('agent_testing', ARRAY['validate_adagents', 'probe_adcp_agent', 'check_publisher_authorization', 'test_adcp_agent', 'validate_agent']), + ('adcp_operations', ARRAY['get_products', 'create_media_buy', 'sync_creatives', 'list_creative_formats', 'list_authorized_properties', 'get_media_buy_delivery', 'build_creative', 'preview_creative', 'get_signals', 'activate_signal', 'call_adcp_agent', 'save_agent', 'list_saved_agents', 'remove_saved_agent', 'setup_test_agent']), + ('content', ARRAY['draft_github_issue', 'propose_news_source', 'list_pending_content', 'approve_content', 'reject_content', 'add_committee_document', 'list_committee_documents', 'update_committee_document', 'delete_committee_document']), + ('billing', ARRAY['find_membership_products', 'create_payment_link', 'send_invoice', 'send_payment_request', 'grant_discount', 'remove_discount', 'list_discounts', 'create_promotion_code']), + ('meetings', ARRAY['schedule_meeting', 'check_availability']); + +-- Always available tools +CREATE TEMP TABLE always_available (tool_name TEXT PRIMARY KEY); +INSERT INTO always_available VALUES + ('escalate_to_admin'), + ('get_account_link'), + ('capture_learning'), + ('web_search'); + +-- ============================================================================ +-- ANALYSIS QUERIES +-- ============================================================================ + +\echo '' +\echo '============================================================================' +\echo 'TOOL USAGE ANALYSIS (Last 30 Days)' +\echo '============================================================================' +\echo '' + +-- Overall tool usage frequency +\echo 'TOOL USAGE BY FREQUENCY:' +\echo '------------------------' +SELECT + tool, + COUNT(*) as usage_count, + ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 1) as pct +FROM addie_thread_messages, + LATERAL unnest(tools_used) as tool +WHERE role = 'assistant' + AND created_at > NOW() - INTERVAL '30 days' + AND tools_used IS NOT NULL +GROUP BY tool +ORDER BY usage_count DESC +LIMIT 25; + +\echo '' +\echo 'TOOL SET MEMBERSHIP:' +\echo '--------------------' +-- Which set does each tool belong to? +SELECT + tool, + COUNT(*) as usage_count, + COALESCE( + (SELECT set_name FROM tool_sets WHERE tool = ANY(tools) LIMIT 1), + CASE WHEN tool IN (SELECT tool_name FROM always_available) THEN 'ALWAYS_AVAILABLE' ELSE 'UNKNOWN' END + ) as belongs_to_set +FROM addie_thread_messages, + LATERAL unnest(tools_used) as tool +WHERE role = 'assistant' + AND created_at > NOW() - INTERVAL '30 days' + AND tools_used IS NOT NULL +GROUP BY tool +ORDER BY usage_count DESC +LIMIT 30; + +\echo '' +\echo 'TOOL COMBINATIONS ANALYSIS:' +\echo '---------------------------' +-- What sets would cover each message's tool usage? +WITH tool_usage AS ( + SELECT + message_id, + tools_used, + ARRAY( + SELECT DISTINCT set_name + FROM tool_sets + WHERE tools_used && tools + ) as sets_needed + FROM addie_thread_messages + WHERE role = 'assistant' + AND created_at > NOW() - INTERVAL '30 days' + AND tools_used IS NOT NULL + AND array_length(tools_used, 1) > 0 +) +SELECT + array_length(sets_needed, 1) as num_sets_needed, + sets_needed, + COUNT(*) as message_count, + ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 1) as pct +FROM tool_usage +GROUP BY sets_needed, array_length(sets_needed, 1) +ORDER BY message_count DESC +LIMIT 20; + +\echo '' +\echo 'ROUTER ACCURACY (Old Tools vs Sonnet Actual):' +\echo '----------------------------------------------' +SELECT + router_decision->'tools' as haiku_recommended, + tools_used as sonnet_called, + (router_decision->'tools')::jsonb = to_jsonb(tools_used) as exact_match, + (SELECT array_agg(t) FROM unnest(tools_used) t + WHERE NOT (router_decision->'tools') ? t) as extra_tools, + (SELECT jsonb_agg(t) FROM jsonb_array_elements_text(router_decision->'tools') t + WHERE NOT t = ANY(tools_used)) as unused_recommendations +FROM addie_thread_messages +WHERE router_decision IS NOT NULL + AND tools_used IS NOT NULL + AND created_at > NOW() - INTERVAL '30 days' +LIMIT 30; + +\echo '' +\echo 'ROUTER MATCH RATE:' +\echo '------------------' +SELECT + ROUND( + COUNT(*) FILTER (WHERE (router_decision->'tools')::jsonb = to_jsonb(tools_used)) * 100.0 / + NULLIF(COUNT(*), 0), + 1 + ) as exact_match_pct, + ROUND( + COUNT(*) FILTER (WHERE + (SELECT COUNT(*) FROM unnest(tools_used) t WHERE NOT (router_decision->'tools') ? t) = 0 + ) * 100.0 / + NULLIF(COUNT(*), 0), + 1 + ) as all_tools_recommended_pct, + COUNT(*) as total_messages +FROM addie_thread_messages +WHERE router_decision IS NOT NULL + AND tools_used IS NOT NULL + AND created_at > NOW() - INTERVAL '30 days'; + +\echo '' +\echo 'HYPOTHETICAL: Tool Sets That Would Be Needed:' +\echo '----------------------------------------------' +-- For each message, show what tool sets would have covered the tools Sonnet used +WITH message_tools AS ( + SELECT + message_id, + tools_used, + created_at + FROM addie_thread_messages + WHERE role = 'assistant' + AND created_at > NOW() - INTERVAL '30 days' + AND tools_used IS NOT NULL + AND array_length(tools_used, 1) > 0 +), +coverage AS ( + SELECT + m.message_id, + m.tools_used, + ARRAY( + SELECT DISTINCT set_name + FROM tool_sets + WHERE m.tools_used && tools + ) as required_sets, + ARRAY( + SELECT t + FROM unnest(m.tools_used) t + WHERE NOT EXISTS ( + SELECT 1 FROM tool_sets WHERE t = ANY(tools) + ) + AND NOT EXISTS ( + SELECT 1 FROM always_available WHERE t = tool_name + ) + ) as uncovered_tools + FROM message_tools m +) +SELECT + required_sets, + uncovered_tools, + COUNT(*) as message_count +FROM coverage +GROUP BY required_sets, uncovered_tools +ORDER BY message_count DESC +LIMIT 20; + +\echo '' +\echo 'UNCOVERED TOOLS (Tools Used But Not in Any Set):' +\echo '-------------------------------------------------' +SELECT + tool, + COUNT(*) as usage_count +FROM addie_thread_messages, + LATERAL unnest(tools_used) as tool +WHERE role = 'assistant' + AND created_at > NOW() - INTERVAL '30 days' + AND tools_used IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM tool_sets WHERE tool = ANY(tools) + ) + AND NOT EXISTS ( + SELECT 1 FROM always_available WHERE tool = tool_name + ) +GROUP BY tool +ORDER BY usage_count DESC; + +\echo '' +\echo 'ESTIMATED TOKEN SAVINGS:' +\echo '------------------------' +-- Assume ~100 tokens per tool definition +WITH total_tools AS ( + SELECT SUM(array_length(tools, 1)) as count FROM tool_sets +), +message_coverage AS ( + SELECT + message_id, + COALESCE( + (SELECT SUM(array_length(tools, 1)) + FROM tool_sets + WHERE tools_used && tools), + 0 + ) as tools_needed + FROM addie_thread_messages + WHERE role = 'assistant' + AND created_at > NOW() - INTERVAL '30 days' + AND tools_used IS NOT NULL +) +SELECT + (SELECT count FROM total_tools) as total_tools_in_sets, + ROUND(AVG(tools_needed), 1) as avg_tools_needed, + ROUND(100 - (AVG(tools_needed) * 100.0 / (SELECT count FROM total_tools)), 1) as avg_token_savings_pct +FROM message_coverage; + +DROP TABLE tool_sets; +DROP TABLE always_available; diff --git a/server/scripts/test-tool-sets-curl.sh b/server/scripts/test-tool-sets-curl.sh new file mode 100755 index 0000000000..97de92a9b4 --- /dev/null +++ b/server/scripts/test-tool-sets-curl.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Test tool sets routing via the admin API +# +# Usage: +# ./scripts/test-tool-sets-curl.sh [LOCAL_PORT] +# +# Requires the server to be running locally with docker compose up + +PORT=${1:-3000} +BASE_URL="http://localhost:$PORT" + +# Test messages for different tool sets +declare -A TEST_CASES=( + ["knowledge"]="What is AdCP and how does it work?" + ["agent_testing"]="Can you validate my adagents.json at https://example.com/.well-known/adagents.json?" + ["directory"]="I'm looking for a DSP that supports AdCP. Can you help me find vendors?" + ["member"]="I want to join the working group on signals" + ["adcp_operations"]="Can you help me create a media buy with The Trade Desk?" + ["billing"]="Can you send an invoice to acme@example.com for their annual membership?" + ["meetings"]="I need to schedule a meeting with the protocol committee" + ["content"]="Can you draft a GitHub issue for the missing creative validation?" + ["multi_intent"]="I need to update my profile and also look for measurement partners" +) + +echo "========================================" +echo "Tool Sets Router Test" +echo "========================================" +echo "Base URL: $BASE_URL" +echo "" + +for expected_set in "${!TEST_CASES[@]}"; do + message="${TEST_CASES[$expected_set]}" + echo "Testing: $expected_set" + echo " Message: \"$message\"" + + # Call the test-router endpoint (using dev login cookie if available) + response=$(curl -s -X POST "$BASE_URL/api/admin/addie/test-router" \ + -H "Content-Type: application/json" \ + -H "Cookie: dev_user=admin@test.com" \ + -d "{\"message\": \"$message\", \"source\": \"channel\"}" 2>/dev/null) + + if [ -z "$response" ]; then + echo " ERROR: No response from server" + else + # Extract tool_sets from response + tool_sets=$(echo "$response" | jq -r '.router_decision.tool_sets // .router_decision.tools // "[]"' 2>/dev/null) + reason=$(echo "$response" | jq -r '.router_decision.reason // "N/A"' 2>/dev/null) + latency=$(echo "$response" | jq -r '.router_decision.latency_ms // "N/A"' 2>/dev/null) + + echo " Tool sets: $tool_sets" + echo " Reason: $reason" + echo " Latency: ${latency}ms" + fi + echo "" +done + +echo "========================================" +echo "Done" +echo "========================================" diff --git a/server/scripts/test-tool-sets-local.ts b/server/scripts/test-tool-sets-local.ts new file mode 100644 index 0000000000..45856c5502 --- /dev/null +++ b/server/scripts/test-tool-sets-local.ts @@ -0,0 +1,375 @@ +#!/usr/bin/env npx ts-node --esm +/** + * Local Tool Sets Router Test Script + * + * Tests the new tool-set-based routing with sample messages locally. + * No database required - uses hardcoded test cases. + * + * Usage: + * npx tsx scripts/test-tool-sets-local.ts + */ + +import { config } from 'dotenv'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +// Load .env.local from server directory +const __dirname = dirname(fileURLToPath(import.meta.url)); +config({ path: resolve(__dirname, '..', '.env.local') }); + +import Anthropic from '@anthropic-ai/sdk'; + +// Get API key from environment (same pattern as other services) +const ANTHROPIC_API_KEY = process.env.ADDIE_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; + +if (!ANTHROPIC_API_KEY) { + console.error('ERROR: ANTHROPIC_API_KEY or ADDIE_ANTHROPIC_API_KEY is required'); + console.log('\nSet the environment variable:'); + console.log(' export ANTHROPIC_API_KEY="sk-ant-..."'); + console.log(' npx tsx scripts/test-tool-sets-local.ts'); + process.exit(1); +} +import { + TOOL_SETS, + getToolsForSets, + getToolSetDescriptionsForRouter, + buildUnavailableSetsHint, + ALWAYS_AVAILABLE_TOOLS, +} from '../src/addie/tool-sets.js'; + +const anthropic = new Anthropic({ apiKey: ANTHROPIC_API_KEY }); + +/** + * Sample test messages with expected tool sets and tools that might be needed + */ +const TEST_CASES = [ + { + message: "What is AdCP and how does it work?", + expectedSets: ['knowledge'], + expectedTools: ['search_docs'], + category: 'protocol_learning', + }, + { + message: "Can you validate my adagents.json at https://example.com/.well-known/adagents.json?", + expectedSets: ['agent_testing'], + expectedTools: ['validate_adagents'], + category: 'validation', + }, + { + message: "I'm looking for a DSP that supports AdCP. Can you help me find vendors?", + expectedSets: ['directory'], + expectedTools: ['search_members', 'request_introduction'], + category: 'vendor_search', + }, + { + message: "How do I set up the Python SDK?", + expectedSets: ['knowledge'], + expectedTools: ['search_repos', 'search_docs'], + category: 'implementation', + }, + { + message: "I want to join the working group on signals", + expectedSets: ['member'], + expectedTools: ['list_working_groups', 'join_working_group'], + category: 'membership', + }, + { + message: "Can you help me create a media buy with The Trade Desk?", + expectedSets: ['adcp_operations'], + expectedTools: ['get_products', 'create_media_buy'], + category: 'operations', + }, + { + message: "What's the latest news in agentic advertising?", + expectedSets: ['knowledge'], + expectedTools: ['get_recent_news', 'search_resources'], + category: 'news', + }, + { + message: "Can you test if my AdCP agent is responding correctly? It's at https://agent.example.com", + expectedSets: ['agent_testing'], + expectedTools: ['probe_adcp_agent', 'test_adcp_agent'], + category: 'testing', + }, + { + message: "I need to schedule a meeting with the protocol committee", + expectedSets: ['meetings'], + expectedTools: ['schedule_meeting', 'check_availability'], + category: 'meetings', + }, + { + message: "Can you send an invoice to acme@example.com for their annual membership?", + expectedSets: ['billing'], + expectedTools: ['send_invoice', 'find_membership_products'], + category: 'billing', + }, + { + message: "What did people say about the OpenRTB integration in Slack?", + expectedSets: ['knowledge'], + expectedTools: ['search_slack'], + category: 'community', + }, + { + message: "Can you draft a GitHub issue for the missing creative validation?", + expectedSets: ['content'], + expectedTools: ['draft_github_issue'], + category: 'content', + }, + { + message: "Hello! I'm new here.", + expectedSets: [], + expectedTools: [], + category: 'greeting', + }, + { + message: "How does OpenRTB 3.0 compare to AdCOM?", + expectedSets: ['knowledge'], + expectedTools: ['search_repos', 'search_docs'], + category: 'protocol_comparison', + }, + { + message: "I need to update my profile and also look for measurement partners", + expectedSets: ['member', 'directory'], + expectedTools: ['get_my_profile', 'update_my_profile', 'search_members'], + category: 'multi_intent', + }, + { + message: "Test my agent and also explain how signals work", + expectedSets: ['agent_testing', 'knowledge'], + expectedTools: ['test_adcp_agent', 'search_docs'], + category: 'multi_intent', + }, + { + message: "What's the TCF consent string format?", + expectedSets: ['knowledge'], + expectedTools: ['search_repos', 'search_docs'], + category: 'protocol_details', + }, + { + message: "I want to get signals from LiveRamp and activate them in DV360", + expectedSets: ['adcp_operations'], + expectedTools: ['get_signals', 'activate_signal'], + category: 'signals_operations', + }, + { + message: "Who are the founding members of AgenticAdvertising.org?", + expectedSets: ['directory'], + expectedTools: ['list_members'], + category: 'directory_query', + }, + { + message: "Can you build a creative for a 300x250 display ad?", + expectedSets: ['adcp_operations'], + expectedTools: ['build_creative', 'list_creative_formats'], + category: 'creative_operations', + }, +]; + +/** + * Build the router prompt + */ +function buildRouterPrompt(message: string, isAdmin = false): string { + const toolSetsSection = getToolSetDescriptionsForRouter(isAdmin); + + return `You are Addie's router. Analyze this message and select the appropriate tool SETS. + +## Available Tool Sets +Select which CATEGORIES of tools will be needed. Each set contains multiple related tools. +${toolSetsSection} + +## Tool Set Selection Guidelines +IMPORTANT: Select tool SETS based on the user's INTENT: +- Questions about AdCP, protocols, implementation → ["knowledge"] +- Questions about member profile, working groups, account → ["member"] +- Looking for vendors, partners, introductions → ["directory"] +- Testing/validating AdCP agent implementations → ["agent_testing"] +- Actually executing AdCP operations (media buys, creatives, signals) → ["adcp_operations"] +- Content workflows, GitHub issues, proposals → ["content"] +- Billing, invoices, payment links → ["billing"] +- Scheduling meetings, calendar → ["meetings"] +- Multiple intents? Include multiple sets: ["knowledge", "agent_testing"] +- General questions needing no tools → [] + +## Message +"${message.substring(0, 500)}" + +## Instructions +Respond with a JSON object: {"tool_sets": ["set1", "set2"], "reason": "brief reason"} +Valid sets: knowledge, member, directory, agent_testing, adcp_operations, content, billing, meetings${isAdmin ? ', admin' : ''} +Empty array [] means respond without tools (general knowledge) + +Respond with ONLY the JSON object, no other text.`; +} + +/** + * Run the router on a message + */ +async function runRouter(message: string): Promise<{ tool_sets: string[]; reason: string; latency_ms: number }> { + const start = Date.now(); + const response = await anthropic.messages.create({ + model: 'claude-haiku-4-5-20250514', + max_tokens: 200, + messages: [{ role: 'user', content: buildRouterPrompt(message) }], + }); + + const latency_ms = Date.now() - start; + const text = response.content[0].type === 'text' ? response.content[0].text : ''; + + try { + let jsonStr = text.trim(); + if (jsonStr.startsWith('```')) { + jsonStr = jsonStr.replace(/^```json?\n?/, '').replace(/\n?```$/, ''); + } + const parsed = JSON.parse(jsonStr); + return { + tool_sets: Array.isArray(parsed.tool_sets) ? parsed.tool_sets : [], + reason: parsed.reason || '', + latency_ms, + }; + } catch { + console.error('Failed to parse router response:', text); + return { tool_sets: ['knowledge'], reason: 'Parse error', latency_ms }; + } +} + +/** + * Check if expected tools would be available with selected sets + */ +function checkToolCoverage(selectedSets: string[], expectedTools: string[]): { + available: string[]; + covered: string[]; + missing: string[]; +} { + const available = getToolsForSets(selectedSets, false); + const covered = expectedTools.filter(t => available.includes(t)); + const missing = expectedTools.filter(t => !available.includes(t)); + return { available, covered, missing }; +} + +/** + * Run all tests + */ +async function main() { + console.log('=' .repeat(80)); + console.log('TOOL SETS ROUTER LOCAL TEST'); + console.log('=' .repeat(80)); + console.log('\nAvailable tool sets:'); + for (const [name, set] of Object.entries(TOOL_SETS)) { + console.log(` ${name}: ${set.tools.length} tools`); + } + console.log(`\nAlways available: ${ALWAYS_AVAILABLE_TOOLS.join(', ')}`); + console.log('\n' + '-'.repeat(80)); + console.log(`Running ${TEST_CASES.length} test cases...\n`); + + const results: { + message: string; + category: string; + expectedSets: string[]; + actualSets: string[]; + reason: string; + latency_ms: number; + setsMatch: boolean; + toolsCovered: boolean; + missingTools: string[]; + }[] = []; + + for (const tc of TEST_CASES) { + process.stdout.write(` Testing: ${tc.category}...`); + + try { + const result = await runRouter(tc.message); + const coverage = checkToolCoverage(result.tool_sets, tc.expectedTools); + + // Check if sets match (order-independent) + const expectedSet = new Set(tc.expectedSets); + const actualSet = new Set(result.tool_sets); + const setsMatch = expectedSet.size === actualSet.size && + [...expectedSet].every(s => actualSet.has(s)); + + results.push({ + message: tc.message.substring(0, 50) + (tc.message.length > 50 ? '...' : ''), + category: tc.category, + expectedSets: tc.expectedSets, + actualSets: result.tool_sets, + reason: result.reason, + latency_ms: result.latency_ms, + setsMatch, + toolsCovered: coverage.missing.length === 0, + missingTools: coverage.missing, + }); + + const status = setsMatch && coverage.missing.length === 0 ? '✓' : '✗'; + console.log(` ${status} (${result.latency_ms}ms)`); + + } catch (error) { + console.log(' ERROR'); + console.error(` ${error}`); + } + + // Small delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // Print summary + console.log('\n' + '='.repeat(80)); + console.log('RESULTS SUMMARY'); + console.log('='.repeat(80)); + + const totalTests = results.length; + const setsCorrect = results.filter(r => r.setsMatch).length; + const toolsCovered = results.filter(r => r.toolsCovered).length; + const avgLatency = results.reduce((sum, r) => sum + r.latency_ms, 0) / totalTests; + + console.log(`\nTotal tests: ${totalTests}`); + console.log(`Sets exactly matched: ${setsCorrect}/${totalTests} (${((setsCorrect / totalTests) * 100).toFixed(1)}%)`); + console.log(`Tools covered: ${toolsCovered}/${totalTests} (${((toolsCovered / totalTests) * 100).toFixed(1)}%)`); + console.log(`Average latency: ${avgLatency.toFixed(0)}ms`); + + // Detailed failures + const failures = results.filter(r => !r.setsMatch || !r.toolsCovered); + if (failures.length > 0) { + console.log('\n' + '-'.repeat(80)); + console.log('FAILURES/DISCREPANCIES:'); + console.log('-'.repeat(80)); + + for (const f of failures) { + console.log(`\n Category: ${f.category}`); + console.log(` Message: "${f.message}"`); + console.log(` Expected sets: [${f.expectedSets.join(', ')}]`); + console.log(` Actual sets: [${f.actualSets.join(', ')}]`); + console.log(` Reason: ${f.reason}`); + if (f.missingTools.length > 0) { + console.log(` Missing tools: [${f.missingTools.join(', ')}]`); + } + console.log(` Sets match: ${f.setsMatch ? 'YES' : 'NO'}`); + console.log(` Tools covered: ${f.toolsCovered ? 'YES' : 'NO'}`); + } + } + + // Token savings estimate + console.log('\n' + '-'.repeat(80)); + console.log('ESTIMATED TOKEN SAVINGS:'); + console.log('-'.repeat(80)); + + // Estimate tokens per tool definition (~100 tokens each) + const TOKENS_PER_TOOL = 100; + const allToolsCount = Object.values(TOOL_SETS).reduce((sum, set) => sum + set.tools.length, 0); + const tokensWithAllTools = allToolsCount * TOKENS_PER_TOOL; + + for (const r of results) { + if (r.actualSets.length > 0) { + const toolsInSets = getToolsForSets(r.actualSets, false).length; + const tokensSaved = (allToolsCount - toolsInSets) * TOKENS_PER_TOOL; + const savingsPct = ((tokensSaved / tokensWithAllTools) * 100).toFixed(0); + console.log(` ${r.category}: ${toolsInSets} tools loaded (${savingsPct}% token savings)`); + } + } + + // Show unavailable hint example + console.log('\n' + '-'.repeat(80)); + console.log('SAMPLE UNAVAILABLE SETS HINT (for knowledge set only):'); + console.log('-'.repeat(80)); + console.log(buildUnavailableSetsHint(['knowledge'], false)); +} + +main().catch(console.error); diff --git a/server/scripts/test-tool-sets.ts b/server/scripts/test-tool-sets.ts new file mode 100644 index 0000000000..a10c253eaa --- /dev/null +++ b/server/scripts/test-tool-sets.ts @@ -0,0 +1,329 @@ +#!/usr/bin/env npx ts-node --esm +/** + * Tool Sets Router Test Script + * + * Tests the new tool-set-based routing against real production conversations. + * Compares router tool set selections with what tools Sonnet actually called. + * + * Usage: + * npx ts-node --esm scripts/test-tool-sets.ts + * + * Or with DATABASE_URL: + * DATABASE_URL="postgres://..." npx ts-node --esm scripts/test-tool-sets.ts + */ + +import pg from 'pg'; +import Anthropic from '@anthropic-ai/sdk'; +import { TOOL_SETS, getToolsForSets, getToolSetDescriptionsForRouter } from '../src/addie/tool-sets.js'; + +const { Pool } = pg; + +// Database connection +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.DATABASE_URL?.includes('fly.io') ? { rejectUnauthorized: false } : undefined, +}); + +// Anthropic client for router simulation +const anthropic = new Anthropic(); + +interface TestMessage { + message_id: string; + content: string; + tools_used: string[] | null; + router_decision: { + action: string; + reason: string; + tools?: string[]; + tool_sets?: string[]; + } | null; + created_at: Date; +} + +/** + * Fetch recent messages with router decisions from production + */ +async function fetchTestMessages(limit = 50): Promise { + const query = ` + SELECT + m.message_id, + u.content as user_content, + m.tools_used, + m.router_decision, + m.created_at + FROM addie_thread_messages m + JOIN addie_thread_messages u ON u.thread_id = m.thread_id AND u.sequence_number = m.sequence_number - 1 + WHERE m.role = 'assistant' + AND u.role = 'user' + AND m.router_decision IS NOT NULL + AND m.created_at > NOW() - INTERVAL '30 days' + ORDER BY m.created_at DESC + LIMIT $1 + `; + + const result = await pool.query(query, [limit]); + return result.rows.map(row => ({ + message_id: row.message_id, + content: row.user_content, + tools_used: row.tools_used, + router_decision: row.router_decision, + created_at: row.created_at, + })); +} + +/** + * Build the new router prompt for tool set selection + */ +function buildToolSetRouterPrompt(message: string, isAdmin = false): string { + const toolSetsSection = getToolSetDescriptionsForRouter(isAdmin); + + return `You are Addie's router. Analyze this message and select the appropriate tool SETS. + +## Available Tool Sets +Select which CATEGORIES of tools will be needed. Each set contains multiple related tools. +${toolSetsSection} + +## Tool Set Selection Guidelines +IMPORTANT: Select tool SETS based on the user's INTENT: +- Questions about AdCP, protocols, implementation → ["knowledge"] +- Questions about member profile, working groups, account → ["member"] +- Looking for vendors, partners, introductions → ["directory"] +- Testing/validating AdCP agent implementations → ["agent_testing"] +- Actually executing AdCP operations (media buys, creatives, signals) → ["adcp_operations"] +- Content workflows, GitHub issues, proposals → ["content"] +- Billing, invoices, payment links → ["billing"] +- Scheduling meetings, calendar → ["meetings"] +- Multiple intents? Include multiple sets: ["knowledge", "agent_testing"] +- General questions needing no tools → [] + +## Message +"${message.substring(0, 500)}" + +## Instructions +Respond with a JSON object: {"tool_sets": ["set1", "set2"], "reason": "brief reason"} +Valid sets: knowledge, member, directory, agent_testing, adcp_operations, content, billing, meetings${isAdmin ? ', admin' : ''} +Empty array [] means respond without tools (general knowledge) + +Respond with ONLY the JSON object, no other text.`; +} + +/** + * Run the new router on a message + */ +async function runNewRouter(message: string): Promise<{ tool_sets: string[]; reason: string }> { + const response = await anthropic.messages.create({ + model: 'claude-haiku-4-5-20250514', + max_tokens: 200, + messages: [{ role: 'user', content: buildToolSetRouterPrompt(message) }], + }); + + const text = response.content[0].type === 'text' ? response.content[0].text : ''; + + try { + let jsonStr = text.trim(); + if (jsonStr.startsWith('```')) { + jsonStr = jsonStr.replace(/^```json?\n?/, '').replace(/\n?```$/, ''); + } + const parsed = JSON.parse(jsonStr); + return { + tool_sets: Array.isArray(parsed.tool_sets) ? parsed.tool_sets : [], + reason: parsed.reason || '', + }; + } catch { + console.error('Failed to parse router response:', text); + return { tool_sets: ['knowledge'], reason: 'Parse error' }; + } +} + +/** + * Determine which tool set(s) a tool belongs to + */ +function getToolSetsForTool(toolName: string): string[] { + const sets: string[] = []; + for (const [setName, setDef] of Object.entries(TOOL_SETS)) { + if (setDef.tools.includes(toolName)) { + sets.push(setName); + } + } + return sets; +} + +/** + * Analyze routing accuracy + */ +interface AnalysisResult { + message_id: string; + user_message: string; + tools_sonnet_used: string[]; + old_router_tools: string[]; + new_router_sets: string[]; + new_router_reason: string; + tools_available_with_new: string[]; + sonnet_tools_covered: boolean; + coverage_details: { + covered: string[]; + missing: string[]; + }; +} + +async function analyzeMessage(msg: TestMessage): Promise { + // Run new router + const newRouterResult = await runNewRouter(msg.content); + + // Get tools that would be available with new routing + const toolsAvailableWithNew = getToolsForSets(newRouterResult.tool_sets, false); + + // Check if Sonnet's tools would be covered + const toolsUsed = msg.tools_used || []; + const covered = toolsUsed.filter(t => toolsAvailableWithNew.includes(t)); + const missing = toolsUsed.filter(t => !toolsAvailableWithNew.includes(t)); + + return { + message_id: msg.message_id, + user_message: msg.content.substring(0, 100) + (msg.content.length > 100 ? '...' : ''), + tools_sonnet_used: toolsUsed, + old_router_tools: msg.router_decision?.tools || [], + new_router_sets: newRouterResult.tool_sets, + new_router_reason: newRouterResult.reason, + tools_available_with_new: toolsAvailableWithNew, + sonnet_tools_covered: missing.length === 0, + coverage_details: { covered, missing }, + }; +} + +/** + * Print analysis report + */ +function printReport(results: AnalysisResult[]): void { + console.log('\n' + '='.repeat(80)); + console.log('TOOL SETS ROUTER TEST REPORT'); + console.log('='.repeat(80) + '\n'); + + // Summary stats + const totalMessages = results.length; + const fullyCovered = results.filter(r => r.sonnet_tools_covered).length; + const coverageRate = ((fullyCovered / totalMessages) * 100).toFixed(1); + + console.log('SUMMARY'); + console.log('-'.repeat(40)); + console.log(`Total messages tested: ${totalMessages}`); + console.log(`Fully covered by new routing: ${fullyCovered} (${coverageRate}%)`); + console.log(`Missing tools: ${totalMessages - fullyCovered}`); + + // Tool set distribution + const setUsage: Record = {}; + for (const r of results) { + for (const set of r.new_router_sets) { + setUsage[set] = (setUsage[set] || 0) + 1; + } + } + console.log('\nTOOL SET USAGE:'); + for (const [set, count] of Object.entries(setUsage).sort((a, b) => b[1] - a[1])) { + const pct = ((count / totalMessages) * 100).toFixed(1); + console.log(` ${set}: ${count} (${pct}%)`); + } + + // Missing tools analysis + const missingToolCounts: Record = {}; + for (const r of results) { + for (const tool of r.coverage_details.missing) { + missingToolCounts[tool] = (missingToolCounts[tool] || 0) + 1; + } + } + if (Object.keys(missingToolCounts).length > 0) { + console.log('\nMISSING TOOLS (tools Sonnet used that wouldn\'t be available):'); + for (const [tool, count] of Object.entries(missingToolCounts).sort((a, b) => b[1] - a[1])) { + const belongsTo = getToolSetsForTool(tool); + console.log(` ${tool}: ${count} times (belongs to: ${belongsTo.join(', ') || 'NONE'})`); + } + } + + // Detailed results for failures + const failures = results.filter(r => !r.sonnet_tools_covered); + if (failures.length > 0) { + console.log('\n' + '='.repeat(80)); + console.log('DETAILED FAILURES'); + console.log('='.repeat(80)); + + for (const f of failures.slice(0, 10)) { + console.log('\n' + '-'.repeat(60)); + console.log(`Message: "${f.user_message}"`); + console.log(`New router sets: [${f.new_router_sets.join(', ')}]`); + console.log(`Reason: ${f.new_router_reason}`); + console.log(`Tools Sonnet used: [${f.tools_sonnet_used.join(', ')}]`); + console.log(`Missing: [${f.coverage_details.missing.join(', ')}]`); + } + } + + // Sample successes + const successes = results.filter(r => r.sonnet_tools_covered && r.tools_sonnet_used.length > 0); + if (successes.length > 0) { + console.log('\n' + '='.repeat(80)); + console.log('SAMPLE SUCCESSES (tools correctly covered)'); + console.log('='.repeat(80)); + + for (const s of successes.slice(0, 5)) { + console.log('\n' + '-'.repeat(60)); + console.log(`Message: "${s.user_message}"`); + console.log(`New router sets: [${s.new_router_sets.join(', ')}]`); + console.log(`Reason: ${s.new_router_reason}`); + console.log(`Tools Sonnet used: [${s.tools_sonnet_used.join(', ')}]`); + } + } +} + +/** + * Main entry point + */ +async function main() { + console.log('Fetching test messages from database...'); + + if (!process.env.DATABASE_URL) { + console.error('ERROR: DATABASE_URL environment variable is required'); + console.log('\nUsage:'); + console.log(' DATABASE_URL="postgres://..." npx ts-node --esm scripts/test-tool-sets.ts'); + console.log('\nOr use fly proxy:'); + console.log(' fly proxy 15432:5432 -a agentic-advertising-db'); + console.log(' DATABASE_URL="postgres://user:pass@localhost:15432/db" npx ts-node ...'); + process.exit(1); + } + + try { + const messages = await fetchTestMessages(50); + console.log(`Found ${messages.length} messages with router decisions\n`); + + if (messages.length === 0) { + console.log('No messages found. Check your database connection.'); + process.exit(1); + } + + console.log('Running new router on each message...'); + const results: AnalysisResult[] = []; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + process.stdout.write(`\r Processing ${i + 1}/${messages.length}...`); + + try { + const result = await analyzeMessage(msg); + results.push(result); + } catch (error) { + console.error(`\nError processing message ${msg.message_id}:`, error); + } + + // Small delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 100)); + } + + console.log('\n'); + printReport(results); + + } catch (error) { + console.error('Error:', error); + process.exit(1); + } finally { + await pool.end(); + } +} + +main(); diff --git a/server/src/addie/bolt-app.ts b/server/src/addie/bolt-app.ts index 5fc919a61a..38ed07e4ae 100644 --- a/server/src/addie/bolt-app.ts +++ b/server/src/addie/bolt-app.ts @@ -59,6 +59,10 @@ import { ESCALATION_TOOLS, createEscalationToolHandlers, } from './mcp/escalation-tools.js'; +import { + ADCP_TOOLS, + createAdcpToolHandlers, +} from './mcp/adcp-tools.js'; import { MEETING_TOOLS, createMeetingToolHandlers, @@ -78,6 +82,10 @@ import { DatabaseThreadContextStore } from './thread-context-store.js'; import { getThreadService, type ThreadContext } from './thread-service.js'; import { getThreadReplies, getSlackUser, getChannelInfo } from '../slack/client.js'; import { AddieRouter, type RoutingContext, type ExecutionPlan } from './router.js'; +import { + getToolsForSets, + buildUnavailableSetsHint, +} from './tool-sets.js'; import { getCachedInsights, prefetchInsights } from './insights-cache.js'; import { getGoalsForSystemPrompt } from './services/insight-extractor.js'; import { getHomeContent, renderHomeView, renderErrorView, invalidateHomeCache } from './home/index.js'; @@ -668,6 +676,14 @@ async function createUserScopedTools( } logger.debug('Addie Bolt: Escalation tools enabled'); + // Add AdCP protocol tools (standard MCP tools for interacting with agents) + const adcpHandlers = createAdcpToolHandlers(memberContext); + allTools.push(...ADCP_TOOLS); + for (const [name, handler] of adcpHandlers) { + allHandlers.set(name, handler); + } + logger.debug('Addie Bolt: AdCP protocol tools enabled'); + // Check if user is AAO admin (based on aao-admin working group membership) const userIsAdmin = slackUserId ? await isSlackUserAdmin(slackUserId) : false; @@ -731,6 +747,53 @@ async function createUserScopedTools( }; } +/** + * Filter tools to only include those in the selected tool sets. + * This reduces the context sent to Sonnet based on Haiku's routing decision. + * + * @param userTools - All tools available to the user + * @param selectedSets - Tool set names from the router's execution plan + * @param isAdmin - Whether the user is an admin (affects which sets are valid) + * @returns Filtered tools and a hint about unavailable sets + */ +function filterToolsBySet( + userTools: RequestTools, + selectedSets: string[], + isAdmin: boolean +): { filteredTools: RequestTools; unavailableHint: string } { + // Get all tool names that should be available based on selected sets + const allowedToolNames = new Set(getToolsForSets(selectedSets, isAdmin)); + + // Filter tools to only those allowed + const filteredToolDefs = userTools.tools.filter(tool => allowedToolNames.has(tool.name)); + + // Filter handlers to match + const filteredHandlers = new Map) => Promise>(); + for (const [name, handler] of userTools.handlers) { + if (allowedToolNames.has(name)) { + filteredHandlers.set(name, handler); + } + } + + // Build hint about unavailable tool sets + const unavailableHint = buildUnavailableSetsHint(selectedSets, isAdmin); + + logger.debug({ + selectedSets, + allowedCount: filteredToolDefs.length, + totalCount: userTools.tools.length, + filteredToolNames: filteredToolDefs.map(t => t.name), + }, 'Addie Bolt: Filtered tools by set'); + + return { + filteredTools: { + tools: filteredToolDefs, + handlers: filteredHandlers, + }, + unavailableHint, + }; +} + /** * Handle assistant_thread_started event * User opened Addie - show suggested prompts @@ -1643,7 +1706,7 @@ function buildRouterDecision(plan: ExecutionPlan): { action: string; reason: string; decision_method: 'quick_match' | 'llm'; - tools?: string[]; + tool_sets?: string[]; latency_ms?: number; tokens_input?: number; tokens_output?: number; @@ -1660,7 +1723,7 @@ function buildRouterDecision(plan: ExecutionPlan): { }; if (plan.action === 'respond') { - return { ...base, tools: plan.tools }; + return { ...base, tool_sets: plan.tool_sets }; } return base; @@ -2566,7 +2629,7 @@ async function handleChannelMessage({ } // action === 'respond' - logger.info({ channelId, userId, tools: plan.tools }, + logger.info({ channelId, userId, toolSets: plan.tool_sets }, 'Addie Bolt: Generating proposed response for channel message'); // Build message with member context @@ -2575,15 +2638,22 @@ async function handleChannelMessage({ messageText ); - // Generate a response with the specified tools (includes admin tools if user is admin, meeting tools with channel context) + // Get all user-scoped tools then filter by selected tool sets const { tools: userTools, isAdmin: userIsAdmin } = await createUserScopedTools(memberContext, userId, thread.thread_id, channelContext); + const { filteredTools, unavailableHint } = filterToolsBySet(userTools, plan.tool_sets, userIsAdmin); + + // Append unavailable sets hint to message context so Sonnet knows what's not loaded + const messageWithHint = unavailableHint + ? `${messageWithContext}\n\n${unavailableHint}` + : messageWithContext; + // Use precision model (Opus) for billing/financial queries const effectiveModel = plan.requires_precision ? ModelConfig.precision : AddieModelConfig.chat; const processOptions = { ...(userIsAdmin ? { maxIterations: ADMIN_MAX_ITERATIONS } : {}), ...(plan.requires_precision ? { modelOverride: ModelConfig.precision } : {}), }; - const response = await claudeClient.processMessage(messageWithContext, undefined, userTools, undefined, processOptions); + const response = await claudeClient.processMessage(messageWithHint, undefined, filteredTools, undefined, processOptions); if (!response.text || response.text.trim().length === 0) { logger.debug({ channelId }, 'Addie Bolt: No response generated'); @@ -2639,7 +2709,7 @@ async function handleChannelMessage({ user_id: userId, user_display_name: memberContext?.slack_user?.display_name || undefined, tools_used: response.tools_used, - router_tools: plan.tools, + router_tool_sets: plan.tool_sets, router_reason: plan.reason, router_decision_method: plan.decision_method, router_latency_ms: plan.latency_ms, diff --git a/server/src/addie/handler.ts b/server/src/addie/handler.ts index 955bf63590..681580dd53 100644 --- a/server/src/addie/handler.ts +++ b/server/src/addie/handler.ts @@ -54,6 +54,10 @@ import { ESCALATION_TOOLS, createEscalationToolHandlers, } from './mcp/escalation-tools.js'; +import { + ADCP_TOOLS, + createAdcpToolHandlers, +} from './mcp/adcp-tools.js'; import { AddieDatabase } from '../db/addie-db.js'; import { SUGGESTED_PROMPTS, STATUS_MESSAGES, buildDynamicSuggestedPrompts } from './prompts.js'; import { AddieModelConfig } from '../config/models.js'; @@ -260,6 +264,14 @@ async function createUserScopedTools( allHandlers.set(name, handler); } + // Add AdCP protocol tools (standard MCP tools for interacting with agents) + // These match the skill format and enable proper protocol interactions + const adcpHandlers = createAdcpToolHandlers(memberContext); + allTools.push(...ADCP_TOOLS); + for (const [name, handler] of adcpHandlers) { + allHandlers.set(name, handler); + } + // Check if user is AAO admin (based on aao-admin working group membership) const userIsAdmin = slackUserId ? await isSlackUserAdmin(slackUserId) : false; diff --git a/server/src/addie/mcp/adcp-tools.ts b/server/src/addie/mcp/adcp-tools.ts new file mode 100644 index 0000000000..add820548f --- /dev/null +++ b/server/src/addie/mcp/adcp-tools.ts @@ -0,0 +1,674 @@ +/** + * AdCP Protocol Tools + * + * Standard MCP tools that match the AdCP protocol specification. + * These expose the same functionality as call_adcp_agent but with + * proper schemas that match the protocol, enabling skills to work. + */ + +import { logger } from '../../logger.js'; +import type { AddieTool } from '../types.js'; +import type { MemberContext } from '../member-context.js'; +import { AgentContextDatabase } from '../../db/agent-context-db.js'; + +// Tool handler type (matches claude-client.ts internal type) +type ToolHandler = (input: Record) => Promise; + +// ============================================ +// MEDIA BUY TOOLS +// ============================================ + +export const ADCP_MEDIA_BUY_TOOLS: AddieTool[] = [ + { + name: 'get_products', + description: + 'Discover advertising products from a sales agent using natural language briefs. Returns available inventory with pricing, targeting, and creative format options.', + usage_hints: + 'use when the user wants to find ad inventory, discover products, search for advertising opportunities, or start a media buying workflow', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The sales agent URL (must be HTTPS)', + }, + brief: { + type: 'string', + description: + 'Natural language description of campaign requirements (e.g., "Looking for premium video inventory targeting tech professionals")', + }, + brand_manifest: { + type: 'object', + description: 'Brand context - either { url: "https://brand.com" } or inline manifest with name, colors, etc.', + properties: { + url: { type: 'string', description: 'Brand website URL for context extraction' }, + name: { type: 'string', description: 'Brand name (for inline manifest)' }, + }, + }, + filters: { + type: 'object', + description: 'Optional filters to narrow results', + properties: { + channels: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by channel types (video, display, audio, ctv, dooh, etc.)', + }, + budget_range: { + type: 'object', + properties: { + min: { type: 'number' }, + max: { type: 'number' }, + }, + }, + delivery_type: { + type: 'string', + enum: ['guaranteed', 'non-guaranteed'], + }, + format_types: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by format types', + }, + }, + }, + }, + required: ['agent_url', 'brief'], + }, + }, + { + name: 'create_media_buy', + description: + 'Create an advertising campaign from selected products. Returns media_buy_id and initial status.', + usage_hints: + 'use after get_products when the user wants to create a campaign, buy ads, or place an order', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The sales agent URL (must be HTTPS)', + }, + buyer_ref: { + type: 'string', + description: 'Your unique identifier for this campaign', + }, + brand_manifest: { + type: 'object', + description: 'Brand context - URL reference or inline manifest', + properties: { + url: { type: 'string' }, + name: { type: 'string' }, + }, + }, + packages: { + type: 'array', + description: 'Products to purchase', + items: { + type: 'object', + properties: { + buyer_ref: { type: 'string', description: 'Your identifier for this package' }, + product_id: { type: 'string', description: 'From get_products response' }, + pricing_option_id: { type: 'string', description: "From product's pricing_options" }, + budget: { type: 'number', description: 'Budget amount in dollars' }, + bid_price: { type: 'number', description: 'Required for auction pricing' }, + targeting_overlay: { type: 'object', description: 'Additional targeting constraints' }, + creative_ids: { + type: 'array', + items: { type: 'string' }, + description: 'References to existing creatives', + }, + }, + required: ['buyer_ref', 'product_id', 'pricing_option_id', 'budget'], + }, + }, + start_time: { + type: 'object', + description: 'When to start - { type: "asap" } or { type: "scheduled", datetime: "ISO-8601" }', + properties: { + type: { type: 'string', enum: ['asap', 'scheduled'] }, + datetime: { type: 'string' }, + }, + required: ['type'], + }, + end_time: { + type: 'string', + description: 'ISO 8601 datetime when campaign ends', + }, + }, + required: ['agent_url', 'buyer_ref', 'brand_manifest', 'packages', 'start_time', 'end_time'], + }, + }, + { + name: 'sync_creatives', + description: + 'Upload and manage creative assets for a campaign. Supports upsert semantics with optional assignment to packages.', + usage_hints: + 'use when the user wants to upload creatives, add creative assets, or assign creatives to campaign packages', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The sales agent URL (must be HTTPS)', + }, + creatives: { + type: 'array', + description: 'Creative assets to sync', + items: { + type: 'object', + properties: { + creative_id: { type: 'string', description: 'Your unique identifier' }, + name: { type: 'string', description: 'Human-readable name' }, + format_id: { + type: 'object', + description: 'Format specification reference', + properties: { + agent_url: { type: 'string' }, + id: { type: 'string' }, + }, + required: ['agent_url', 'id'], + }, + assets: { + type: 'object', + description: 'Asset content keyed by asset name (video, image, html, etc.)', + }, + }, + required: ['creative_id', 'format_id', 'assets'], + }, + }, + assignments: { + type: 'object', + description: 'Map creative_id to array of package IDs', + }, + dry_run: { + type: 'boolean', + description: 'Preview changes without applying', + }, + }, + required: ['agent_url', 'creatives'], + }, + }, + { + name: 'list_creative_formats', + description: + 'View supported creative specifications from a sales or creative agent. Returns format definitions with dimensions and asset requirements.', + usage_hints: + 'use when the user wants to see what creative formats are supported, understand creative specs, or check dimension requirements', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The agent URL (must be HTTPS)', + }, + format_types: { + type: 'array', + items: { type: 'string' }, + description: 'Filter to specific format categories (video, display, audio, etc.)', + }, + }, + required: ['agent_url'], + }, + }, + { + name: 'list_authorized_properties', + description: + "Get the list of publisher properties this sales agent can sell. Returns authorized domain names.", + usage_hints: + 'use when the user wants to see what publishers or properties an agent can sell', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The sales agent URL (must be HTTPS)', + }, + }, + required: ['agent_url'], + }, + }, + { + name: 'get_media_buy_delivery', + description: + 'Retrieve performance metrics for a campaign. Returns impressions, spend, clicks, and other delivery data.', + usage_hints: + 'use when the user wants to check campaign performance, see delivery stats, or monitor a media buy', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The sales agent URL (must be HTTPS)', + }, + media_buy_id: { + type: 'string', + description: 'The campaign identifier from create_media_buy', + }, + granularity: { + type: 'string', + enum: ['hourly', 'daily', 'weekly'], + description: 'Time granularity for timeseries data', + }, + date_range: { + type: 'object', + properties: { + start: { type: 'string', description: 'ISO date (YYYY-MM-DD)' }, + end: { type: 'string', description: 'ISO date (YYYY-MM-DD)' }, + }, + }, + }, + required: ['agent_url', 'media_buy_id'], + }, + }, +]; + +// ============================================ +// CREATIVE TOOLS +// ============================================ + +export const ADCP_CREATIVE_TOOLS: AddieTool[] = [ + { + name: 'build_creative', + description: + 'Generate a creative from a brief or transform an existing creative to a different format. Returns a complete creative manifest.', + usage_hints: + 'use when the user wants to generate ad creatives, transform creative sizes, or build creative assets from a brief', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The creative agent URL (must be HTTPS)', + }, + message: { + type: 'string', + description: 'Natural language instructions for generation or transformation', + }, + target_format_id: { + type: 'object', + description: 'The format to generate', + properties: { + agent_url: { type: 'string' }, + id: { type: 'string' }, + }, + required: ['agent_url', 'id'], + }, + creative_manifest: { + type: 'object', + description: 'Source manifest - minimal for generation, complete for transformation', + }, + }, + required: ['agent_url', 'target_format_id'], + }, + }, + { + name: 'preview_creative', + description: + 'Generate visual previews of creative manifests. Returns preview URLs or HTML.', + usage_hints: + 'use when the user wants to see how a creative will look, preview ad renderings, or validate creative output', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The creative agent URL (must be HTTPS)', + }, + request_type: { + type: 'string', + enum: ['single', 'batch'], + description: 'Single preview or batch of multiple creatives', + }, + format_id: { + type: 'object', + description: 'Format identifier (for single preview)', + properties: { + agent_url: { type: 'string' }, + id: { type: 'string' }, + }, + }, + creative_manifest: { + type: 'object', + description: 'The creative manifest to preview', + }, + requests: { + type: 'array', + description: 'For batch preview - array of { format_id, creative_manifest }', + }, + output_format: { + type: 'string', + enum: ['url', 'html'], + description: 'Output format (default: url)', + }, + }, + required: ['agent_url', 'request_type'], + }, + }, +]; + +// ============================================ +// SIGNALS TOOLS +// ============================================ + +export const ADCP_SIGNALS_TOOLS: AddieTool[] = [ + { + name: 'get_signals', + description: + 'Discover audience signals using natural language. Returns matching signals with coverage, pricing, and deployment status.', + usage_hints: + 'use when the user wants to find audience data, discover targeting segments, or search for signal providers', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The signal agent URL (must be HTTPS)', + }, + signal_spec: { + type: 'string', + description: 'Natural language description of desired signals (e.g., "High-income households interested in luxury goods")', + }, + deliver_to: { + type: 'object', + description: 'Where signals will be used', + properties: { + deployments: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['platform', 'agent'] }, + platform: { type: 'string', description: 'DSP name (e.g., "the-trade-desk")' }, + agent_url: { type: 'string', description: 'Sales agent URL' }, + account: { type: 'string', description: 'Optional account identifier' }, + }, + required: ['type'], + }, + }, + countries: { + type: 'array', + items: { type: 'string' }, + description: 'ISO country codes', + }, + }, + required: ['deployments'], + }, + filters: { + type: 'object', + properties: { + catalog_types: { type: 'array', items: { type: 'string' } }, + data_providers: { type: 'array', items: { type: 'string' } }, + max_cpm: { type: 'number' }, + min_coverage_percentage: { type: 'number' }, + }, + }, + max_results: { + type: 'number', + description: 'Limit number of results', + }, + }, + required: ['agent_url', 'signal_spec', 'deliver_to'], + }, + }, + { + name: 'activate_signal', + description: + 'Activate a signal for use on a specific platform or agent. Returns activation key for targeting.', + usage_hints: + 'use when the user wants to activate an audience segment, deploy a signal to a DSP, or enable targeting data', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The signal agent URL (must be HTTPS)', + }, + signal_agent_segment_id: { + type: 'string', + description: 'Signal identifier from get_signals response', + }, + deployments: { + type: 'array', + description: 'Target deployments', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['platform', 'agent'] }, + platform: { type: 'string' }, + agent_url: { type: 'string' }, + account: { type: 'string' }, + }, + required: ['type'], + }, + }, + }, + required: ['agent_url', 'signal_agent_segment_id', 'deployments'], + }, + }, +]; + +// ============================================ +// ALL ADCP TOOLS +// ============================================ + +export const ADCP_TOOLS: AddieTool[] = [ + ...ADCP_MEDIA_BUY_TOOLS, + ...ADCP_CREATIVE_TOOLS, + ...ADCP_SIGNALS_TOOLS, +]; + +// ============================================ +// TOOL HANDLERS +// ============================================ + +/** + * Create handlers for AdCP protocol tools. + * These wrap the AdCPClient to execute tasks with proper parameter mapping. + */ +export function createAdcpToolHandlers( + memberContext: MemberContext | null +): Map { + const handlers = new Map(); + const agentContextDb = new AgentContextDatabase(); + + // Helper to get auth token for an agent + async function getAuthToken(agentUrl: string): Promise { + const organizationId = memberContext?.organization?.workos_organization_id; + if (!organizationId) return undefined; + + try { + const token = await agentContextDb.getAuthTokenByOrgAndUrl(organizationId, agentUrl); + if (token) { + return token; + } + } catch (error) { + logger.debug({ error, agentUrl }, 'Failed to get auth token for agent'); + } + return undefined; + } + + // Helper to validate agent URL + function validateAgentUrl(agentUrl: string): string | null { + try { + const url = new URL(agentUrl); + + if (url.protocol !== 'https:') { + return 'Agent URL must use HTTPS protocol.'; + } + + const hostname = url.hostname.toLowerCase(); + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname.endsWith('.local') || + hostname.endsWith('.internal') || + hostname.startsWith('10.') || + hostname.startsWith('192.168.') || + hostname.match(/^172\.(1[6-9]|2\d|3[01])\./) || + hostname === '169.254.169.254' + ) { + return 'Agent URL cannot point to internal or private networks.'; + } + + return null; // Valid + } catch { + return 'Invalid agent URL format.'; + } + } + + // Helper to execute AdCP task + async function executeTask( + agentUrl: string, + task: string, + params: Record + ): Promise { + const validationError = validateAgentUrl(agentUrl); + if (validationError) { + return `**Error:** ${validationError}`; + } + + const authToken = await getAuthToken(agentUrl); + + logger.info({ agentUrl, task, hasAuth: !!authToken }, `AdCP: executing ${task}`); + + try { + const { AdCPClient } = await import('@adcp/client'); + const multiClient = new AdCPClient([ + { + id: 'target', + name: 'target', + agent_uri: agentUrl, + protocol: 'mcp', + ...(authToken && { auth_token: authToken }), + }, + ]); + const client = multiClient.agent('target'); + + const result = await client.executeTask(task, params); + + if (!result.success) { + return `**Task failed:** \`${task}\`\n\n**Error:**\n\`\`\`json\n${JSON.stringify(result.error, null, 2)}\n\`\`\``; + } + + let output = `**Task:** \`${task}\`\n**Status:** Success\n\n`; + output += `**Response:**\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\``; + + return output; + } catch (error) { + logger.error({ error, agentUrl, task }, `AdCP: ${task} failed`); + return `**Task failed:** \`${task}\`\n\n**Error:** ${error instanceof Error ? error.message : 'Unknown error'}`; + } + } + + // Media Buy handlers + handlers.set('get_products', async (input: Record) => { + const agentUrl = input.agent_url as string; + const params: Record = { + brief: input.brief, + }; + if (input.brand_manifest) params.brand_manifest = input.brand_manifest; + if (input.filters) params.filters = input.filters; + + return executeTask(agentUrl, 'get_products', params); + }); + + handlers.set('create_media_buy', async (input: Record) => { + const agentUrl = input.agent_url as string; + const params: Record = { + buyer_ref: input.buyer_ref, + brand_manifest: input.brand_manifest, + packages: input.packages, + start_time: input.start_time, + end_time: input.end_time, + }; + + return executeTask(agentUrl, 'create_media_buy', params); + }); + + handlers.set('sync_creatives', async (input: Record) => { + const agentUrl = input.agent_url as string; + const params: Record = { + creatives: input.creatives, + }; + if (input.assignments) params.assignments = input.assignments; + if (input.dry_run !== undefined) params.dry_run = input.dry_run; + + return executeTask(agentUrl, 'sync_creatives', params); + }); + + handlers.set('list_creative_formats', async (input: Record) => { + const agentUrl = input.agent_url as string; + const params: Record = {}; + if (input.format_types) params.format_types = input.format_types; + + return executeTask(agentUrl, 'list_creative_formats', params); + }); + + handlers.set('list_authorized_properties', async (input: Record) => { + const agentUrl = input.agent_url as string; + return executeTask(agentUrl, 'list_authorized_properties', {}); + }); + + handlers.set('get_media_buy_delivery', async (input: Record) => { + const agentUrl = input.agent_url as string; + const params: Record = { + media_buy_id: input.media_buy_id, + }; + if (input.granularity) params.granularity = input.granularity; + if (input.date_range) params.date_range = input.date_range; + + return executeTask(agentUrl, 'get_media_buy_delivery', params); + }); + + // Creative handlers + handlers.set('build_creative', async (input: Record) => { + const agentUrl = input.agent_url as string; + const params: Record = { + target_format_id: input.target_format_id, + }; + if (input.message) params.message = input.message; + if (input.creative_manifest) params.creative_manifest = input.creative_manifest; + + return executeTask(agentUrl, 'build_creative', params); + }); + + handlers.set('preview_creative', async (input: Record) => { + const agentUrl = input.agent_url as string; + const params: Record = { + request_type: input.request_type, + }; + if (input.format_id) params.format_id = input.format_id; + if (input.creative_manifest) params.creative_manifest = input.creative_manifest; + if (input.requests) params.requests = input.requests; + if (input.output_format) params.output_format = input.output_format; + + return executeTask(agentUrl, 'preview_creative', params); + }); + + // Signals handlers + handlers.set('get_signals', async (input: Record) => { + const agentUrl = input.agent_url as string; + const params: Record = { + signal_spec: input.signal_spec, + deliver_to: input.deliver_to, + }; + if (input.filters) params.filters = input.filters; + if (input.max_results) params.max_results = input.max_results; + + return executeTask(agentUrl, 'get_signals', params); + }); + + handlers.set('activate_signal', async (input: Record) => { + const agentUrl = input.agent_url as string; + const params: Record = { + signal_agent_segment_id: input.signal_agent_segment_id, + deployments: input.deployments, + }; + + return executeTask(agentUrl, 'activate_signal', params); + }); + + return handlers; +} diff --git a/server/src/addie/router.ts b/server/src/addie/router.ts index 5d7d30b137..24069eb39e 100644 --- a/server/src/addie/router.ts +++ b/server/src/addie/router.ts @@ -26,6 +26,10 @@ import { KNOWLEDGE_TOOLS } from './mcp/knowledge-search.js'; import { MEMBER_TOOLS } from './mcp/member-tools.js'; import { InsightsDatabase, type MemberInsight } from '../db/insights-db.js'; import { trackApiCall, ApiPurpose } from './services/api-tracker.js'; +import { + getToolSetDescriptionsForRouter, + requiresPrecision as checkPrecision, +} from './tool-sets.js'; /** * Execution plan types @@ -48,21 +52,9 @@ export type ExecutionPlan = ExecutionPlanBase & ( | { action: 'ignore'; reason: string } | { action: 'react'; emoji: string; reason: string } | { action: 'clarify'; question: string; reason: string } - | { action: 'respond'; tools: string[]; reason: string } + | { action: 'respond'; tool_sets: string[]; reason: string } ); -/** - * Billing-related tools that require precision mode - * When these tools are selected, we'll use Opus instead of Sonnet - */ -export const PRECISION_REQUIRED_TOOLS = [ - 'find_membership_products', - 'create_payment_link', - 'send_invoice', - 'send_payment_request', - 'grant_discount', -]; - /** * Context for routing decisions */ @@ -306,10 +298,8 @@ function buildRoutingPrompt(ctx: RoutingContext): string { const isMember = !!ctx.memberContext?.workos_user?.workos_user_id; const isLinked = isMember; - // Build tool descriptions section - this is key for proper tool selection - const toolsSection = Object.entries(TOOL_DESCRIPTIONS) - .map(([name, desc]) => `- **${name}**: ${desc}`) - .join('\n'); + // Build tool SET descriptions - router selects categories, not individual tools + const toolSetsSection = getToolSetDescriptionsForRouter(isAdmin); // Build react patterns const reactList = Object.entries(ROUTING_RULES.reactWith) @@ -324,17 +314,16 @@ function buildRoutingPrompt(ctx: RoutingContext): string { if (!isLinked) { conditionalRules += ` The user has NOT linked their Slack account to AgenticAdvertising.org. -- If they ask about membership features, suggest linking their account first -- Use tools: [get_my_profile] to check their status`; +- If they ask about membership features, include the "member" tool set`; } if (isAdmin) { conditionalRules += ` The user is an ADMIN. -- They may ask about system configuration or analytics +- They have access to the "admin" tool set for system operations - Be more direct and technical in responses`; } - return `You are Addie's router. Analyze this message and determine the execution plan. + return `You are Addie's router. Analyze this message and select the appropriate tool SETS. ## User Context - Source: ${ctx.source} @@ -344,18 +333,22 @@ The user is an ADMIN. ${conditionalRules} ${insightsSection} -## Available Tools (with when to use each) -${toolsSection} - -## Tool Selection Guidelines -IMPORTANT: Choose tools based on the user's INTENT, not just keywords: -- "How does X work?" / "What is X?" / "Explain X" → search_docs (learning/understanding) -- "Validate my adagents.json" / "Check example.com" / "Debug my setup" → validate_adagents (action/validation) -- "How do I use the SDK?" / "Salesagent setup" → search_repos (implementation help) -- "What did someone say about X?" → search_slack (community discussions) -- Questions about MCP, A2A, OpenRTB, AdCOM, TCF, GPP, UID2, Prebid, IAB specs → search_repos (we have these indexed!) -- "Is my agent working?" / "Test my endpoint" → check_agent_health (testing) -- Questions about topics NOT in our indexed repos → web_search (external info) +## Available Tool Sets +Select which CATEGORIES of tools will be needed. Each set contains multiple related tools. +${toolSetsSection} + +## Tool Set Selection Guidelines +IMPORTANT: Select tool SETS based on the user's INTENT: +- Questions about AdCP, protocols, implementation → ["knowledge"] +- Questions about member profile, working groups, account → ["member"] +- Looking for vendors, partners, introductions → ["directory"] +- Testing/validating AdCP agent implementations → ["agent_testing"] +- Actually executing AdCP operations (media buys, creatives, signals) → ["adcp_operations"] +- Content workflows, GitHub issues, proposals → ["content"] +- Billing, invoices, payment links → ["billing"] +- Scheduling meetings, calendar → ["meetings"] +- Multiple intents? Include multiple sets: ["knowledge", "agent_testing"] +- General questions needing no tools → [] ## Messages to React To (emoji only, no response) ${reactList} @@ -382,9 +375,9 @@ Respond with a JSON object for the execution plan. Choose ONE action: - When you need more information to help effectively - Use sparingly - only when truly ambiguous -4. {"action": "respond", "tools": ["tool1", "tool2"], "reason": "brief reason"} - - When you can help and know which tools to use - - Select tools from the Available Tools list based on user intent +4. {"action": "respond", "tool_sets": ["set1", "set2"], "reason": "brief reason"} + - When you can help - select the tool SET(S) that will be needed + - Valid sets: knowledge, member, directory, agent_testing, adcp_operations, content, billing, meetings${isAdmin ? ', admin' : ''} - Empty array [] means respond without tools (general knowledge) Respond with ONLY the JSON object, no other text.`; @@ -397,7 +390,7 @@ type ParsedPlan = | { action: 'ignore'; reason: string } | { action: 'react'; emoji: string; reason: string } | { action: 'clarify'; question: string; reason: string } - | { action: 'respond'; tools: string[]; reason: string }; + | { action: 'respond'; tool_sets: string[]; reason: string }; /** * Parse the router response into a partial ExecutionPlan @@ -431,11 +424,11 @@ function parseRouterResponse(response: string): ParsedPlan { }; } if (parsed.action === 'respond') { - // Accept tool names as-is - they come from ROUTING_RULES.expertise - const tools = Array.isArray(parsed.tools) ? parsed.tools : []; + // Accept tool set names as-is + const toolSets = Array.isArray(parsed.tool_sets) ? parsed.tool_sets : []; return { action: 'respond', - tools, + tool_sets: toolSets, reason: parsed.reason || 'Can help with this topic', }; } @@ -445,8 +438,8 @@ function parseRouterResponse(response: string): ParsedPlan { return { action: 'ignore', reason: 'Unknown action type' }; } catch (error) { logger.error({ error, response }, 'Router: Failed to parse response'); - // On parse error, default to respond with no tools (safe fallback) - return { action: 'respond', tools: [], reason: 'Parse error - defaulting to general response' }; + // On parse error, default to respond with knowledge tools (safe fallback) + return { action: 'respond', tool_sets: ['knowledge'], reason: 'Parse error - defaulting to knowledge tools' }; } } @@ -487,12 +480,10 @@ export class AddieRouter { const parsedPlan = parseRouterResponse(text); const latencyMs = Date.now() - startTime; - // Check if any selected tools require precision mode (billing, financial) - let requiresPrecision = false; + // Check if any selected tool sets require precision mode (billing, financial) + let requiresPrecisionMode = false; if (parsedPlan.action === 'respond') { - requiresPrecision = parsedPlan.tools.some(tool => - PRECISION_REQUIRED_TOOLS.includes(tool) - ); + requiresPrecisionMode = checkPrecision(parsedPlan.tool_sets); } const plan: ExecutionPlan = { @@ -502,17 +493,18 @@ export class AddieRouter { tokens_input: response.usage?.input_tokens, tokens_output: response.usage?.output_tokens, model: ModelConfig.fast, - requires_precision: requiresPrecision, + requires_precision: requiresPrecisionMode, }; logger.debug({ source: ctx.source, action: plan.action, reason: plan.reason, + toolSets: parsedPlan.action === 'respond' ? parsedPlan.tool_sets : undefined, durationMs: latencyMs, inputTokens: response.usage?.input_tokens, outputTokens: response.usage?.output_tokens, - requiresPrecision, + requiresPrecision: requiresPrecisionMode, }, 'Router: Execution plan generated'); // Track for performance metrics (fire-and-forget, errors handled internally) @@ -527,11 +519,11 @@ export class AddieRouter { return plan; } catch (error) { logger.error({ error }, 'Router: Failed to generate execution plan'); - // On error, default to respond (safe fallback - don't miss important messages) + // On error, default to respond with knowledge tools (safe fallback - don't miss important messages) return { action: 'respond', - tools: [], - reason: 'Router error - defaulting to general response', + tool_sets: ['knowledge'], + reason: 'Router error - defaulting to knowledge tools', decision_method: 'llm', latency_ms: Date.now() - startTime, }; diff --git a/server/src/addie/tool-sets.ts b/server/src/addie/tool-sets.ts new file mode 100644 index 0000000000..9a8b60418d --- /dev/null +++ b/server/src/addie/tool-sets.ts @@ -0,0 +1,313 @@ +/** + * Tool Sets for Addie Router + * + * Defines categories of tools that can be selected by the Haiku router. + * This allows Sonnet to receive a focused set of tools based on user intent, + * reducing context size and improving response quality. + * + * Design principles: + * - Router selects CATEGORIES (easier to get right) not individual tools + * - Sonnet picks specific tools within categories (it knows best) + * - Some tools are always available (escape hatches) + * - Sonnet is told what sets are NOT available so it can redirect if needed + */ + +/** + * Tool set definitions + * Each set has a name, description (for router), and list of tool names + */ +export interface ToolSet { + name: string; + description: string; + tools: string[]; + /** If true, requires admin role */ + adminOnly?: boolean; + /** If true, requires precision model (Opus) */ + requiresPrecision?: boolean; +} + +/** + * Tools that are ALWAYS available regardless of routing + * These are escape hatches that should never be filtered out + */ +export const ALWAYS_AVAILABLE_TOOLS = [ + 'escalate_to_admin', // Can always ask for human help + 'get_account_link', // Check user's linked status + 'capture_learning', // Save insights from conversations + 'web_search', // Built-in Claude tool, always available +]; + +/** + * Tool set definitions + */ +export const TOOL_SETS: Record = { + knowledge: { + name: 'knowledge', + description: 'Search documentation, code repos, Slack history, and curated resources for protocol questions, implementation help, and community discussions', + tools: [ + 'search_docs', + 'get_doc', + 'search_repos', + 'search_slack', + 'get_channel_activity', + 'search_resources', + 'get_recent_news', + 'fetch_url', + 'read_slack_file', + ], + }, + + member: { + name: 'member', + description: 'Manage member profile, working groups, committees, content proposals, and account settings', + tools: [ + 'get_my_profile', + 'update_my_profile', + 'list_working_groups', + 'get_working_group', + 'join_working_group', + 'get_my_working_groups', + 'express_council_interest', + 'withdraw_council_interest', + 'get_my_council_interests', + 'list_perspectives', + 'create_working_group_post', + 'propose_content', + 'get_my_content', + 'bookmark_resource', + ], + }, + + directory: { + name: 'directory', + description: 'Find member organizations, request introductions, search for vendors/partners, and explore the member directory', + tools: [ + 'search_members', + 'request_introduction', + 'get_my_search_analytics', + 'list_members', + 'get_member', + 'list_agents', + 'get_agent', + 'list_publishers', + 'lookup_domain', + ], + }, + + agent_testing: { + name: 'agent_testing', + description: 'Validate and test AdCP agent implementations - check adagents.json, probe endpoints, verify publisher authorization, run compliance tests', + tools: [ + 'validate_adagents', + 'probe_adcp_agent', + 'check_publisher_authorization', + 'test_adcp_agent', + 'validate_agent', + ], + }, + + adcp_operations: { + name: 'adcp_operations', + description: 'Execute AdCP protocol operations - discover products, create media buys, manage creatives, work with signals, and interact with sales/creative/signal agents', + tools: [ + // Media Buy tools + 'get_products', + 'create_media_buy', + 'sync_creatives', + 'list_creative_formats', + 'list_authorized_properties', + 'get_media_buy_delivery', + // Creative tools + 'build_creative', + 'preview_creative', + // Signals tools + 'get_signals', + 'activate_signal', + // Agent management + 'call_adcp_agent', + 'save_agent', + 'list_saved_agents', + 'remove_saved_agent', + 'setup_test_agent', + ], + }, + + content: { + name: 'content', + description: 'Manage content workflows - draft GitHub issues, propose news sources, handle content approvals, manage committee documents', + tools: [ + 'draft_github_issue', + 'propose_news_source', + 'list_pending_content', + 'approve_content', + 'reject_content', + 'add_committee_document', + 'list_committee_documents', + 'update_committee_document', + 'delete_committee_document', + ], + }, + + billing: { + name: 'billing', + description: 'Handle billing and payment operations - create payment links, send invoices, manage discounts and promotions', + tools: [ + 'find_membership_products', + 'create_payment_link', + 'send_invoice', + 'send_payment_request', + 'grant_discount', + 'remove_discount', + 'list_discounts', + 'create_promotion_code', + ], + requiresPrecision: true, + }, + + meetings: { + name: 'meetings', + description: 'Schedule meetings and manage calendar - check availability, create calendar events', + tools: [ + 'schedule_meeting', + 'check_availability', + ], + }, + + admin: { + name: 'admin', + description: 'Administrative operations - manage prospects, organizations, feeds, flagged conversations, insights (admin only)', + tools: [ + 'list_pending_invoices', + 'get_account', + 'add_prospect', + 'update_prospect', + 'enrich_company', + 'list_prospects', + 'prospect_search_lusha', + 'search_industry_feeds', + 'add_industry_feed', + 'get_feed_stats', + 'list_feed_proposals', + 'approve_feed_proposal', + 'reject_feed_proposal', + 'add_media_contact', + 'list_flagged_conversations', + 'review_flagged_conversation', + 'create_chapter', + 'list_chapters', + 'create_industry_gathering', + 'list_industry_gatherings', + 'add_committee_leader', + 'remove_committee_leader', + 'list_committee_leaders', + 'merge_organizations', + 'find_duplicate_orgs', + 'check_domain_health', + 'manage_organization_domains', + 'my_engaged_prospects', + 'my_followups_needed', + 'unassigned_prospects', + 'claim_prospect', + 'suggest_prospects', + 'set_reminder', + 'my_upcoming_tasks', + 'log_conversation', + 'get_insight_summary', + 'get_member_search_analytics', + 'list_organizations_by_users', + 'list_slack_users_by_org', + 'tag_insight', + 'list_pending_insights', + 'run_synthesis', + ], + adminOnly: true, + }, +}; + +/** + * Get all tool names in a set + */ +export function getToolsInSet(setName: string): string[] { + const set = TOOL_SETS[setName]; + return set ? set.tools : []; +} + +/** + * Get all tool names for multiple sets, including always-available tools + */ +export function getToolsForSets(setNames: string[], isAdmin: boolean = false): string[] { + const tools = new Set(ALWAYS_AVAILABLE_TOOLS); + + for (const setName of setNames) { + const toolSet = TOOL_SETS[setName]; + if (toolSet) { + // Skip admin-only sets if user is not admin + if (toolSet.adminOnly && !isAdmin) { + continue; + } + for (const tool of toolSet.tools) { + tools.add(tool); + } + } + } + + return Array.from(tools); +} + +/** + * Get tool set names that were NOT selected (for hinting to Sonnet) + */ +export function getUnavailableSets(selectedSets: string[], isAdmin: boolean = false): string[] { + return Object.keys(TOOL_SETS).filter(setName => { + // Don't mention admin set to non-admins + if (TOOL_SETS[setName].adminOnly && !isAdmin) { + return false; + } + return !selectedSets.includes(setName); + }); +} + +/** + * Build a hint message about unavailable tool sets + * This helps Sonnet know it can redirect the user if needed + */ +export function buildUnavailableSetsHint(selectedSets: string[], isAdmin: boolean = false): string { + const unavailable = getUnavailableSets(selectedSets, isAdmin); + + if (unavailable.length === 0) { + return ''; + } + + const hints = unavailable.map(setName => { + const set = TOOL_SETS[setName]; + return `- **${setName}**: ${set.description}`; + }); + + return ` +## Tool Sets Not Currently Loaded + +Based on the user's request, the following tool categories were not loaded. If the user's request actually needs these capabilities, suggest they clarify their intent: + +${hints.join('\n')} +`; +} + +/** + * Check if any selected sets require precision mode + */ +export function requiresPrecision(selectedSets: string[]): boolean { + return selectedSets.some(setName => { + const set = TOOL_SETS[setName]; + return set?.requiresPrecision === true; + }); +} + +/** + * Get tool set descriptions for the router prompt + */ +export function getToolSetDescriptionsForRouter(isAdmin: boolean = false): string { + return Object.entries(TOOL_SETS) + .filter(([_, set]) => !set.adminOnly || isAdmin) + .map(([name, set]) => `- **${name}**: ${set.description}`) + .join('\n'); +} diff --git a/server/src/db/migrations/183_router_tool_sets.sql b/server/src/db/migrations/183_router_tool_sets.sql new file mode 100644 index 0000000000..32de97eda9 --- /dev/null +++ b/server/src/db/migrations/183_router_tool_sets.sql @@ -0,0 +1,49 @@ +-- ===================================================== +-- ROUTER TOOL SETS +-- ===================================================== +-- Updates router_decision schema from 'tools' (individual tool names) +-- to 'tool_sets' (category names like 'knowledge', 'member', etc.) +-- The view now reads both formats for backward compatibility. + +-- Drop and recreate the execution analysis view +DROP VIEW IF EXISTS addie_execution_analysis; + +CREATE VIEW addie_execution_analysis AS +SELECT + m.message_id, + m.thread_id, + t.channel, + m.created_at, + m.role, + m.latency_ms, + m.timing_system_prompt_ms, + m.timing_total_llm_ms, + m.timing_total_tool_ms, + m.processing_iterations, + m.tokens_input, + m.tokens_output, + m.tokens_cache_creation, + m.tokens_cache_read, + m.tools_used, + jsonb_array_length(COALESCE(m.tool_calls, '[]'::jsonb)) as tool_call_count, + m.rating, + m.rating_notes, + m.feedback_tags, + m.flagged, + m.active_rule_ids, + t.active_rules_snapshot, + -- Router decision fields + m.router_decision->>'action' as router_action, + m.router_decision->>'reason' as router_reason, + m.router_decision->>'decision_method' as router_decision_method, + (m.router_decision->>'latency_ms')::integer as router_latency_ms, + (m.router_decision->>'tokens_input')::integer as router_tokens_input, + (m.router_decision->>'tokens_output')::integer as router_tokens_output, + m.router_decision->>'model' as router_model, + -- Support both old 'tools' and new 'tool_sets' formats + COALESCE(m.router_decision->'tool_sets', m.router_decision->'tools') as router_tool_sets +FROM addie_thread_messages m +JOIN addie_threads t ON m.thread_id = t.thread_id +WHERE m.role = 'assistant'; + +COMMENT ON VIEW addie_execution_analysis IS 'Assistant response analysis including router decisions (tool_sets)'; diff --git a/server/src/routes/addie-admin.ts b/server/src/routes/addie-admin.ts index f355bbf4b8..b711709476 100644 --- a/server/src/routes/addie-admin.ts +++ b/server/src/routes/addie-admin.ts @@ -2196,7 +2196,7 @@ Be specific and actionable. Focus on patterns that could help improve Addie's be tokens_input: plan.tokens_input, tokens_output: plan.tokens_output, model: plan.model, - ...(plan.action === 'respond' ? { tools: plan.tools } : {}), + ...(plan.action === 'respond' ? { tool_sets: plan.tool_sets } : {}), }; // Create a test thread and log the message with router decision diff --git a/skills/adcp-creative/SKILL.md b/skills/adcp-creative/SKILL.md index aff9b228aa..44c37ae749 100644 --- a/skills/adcp-creative/SKILL.md +++ b/skills/adcp-creative/SKILL.md @@ -5,7 +5,7 @@ description: Execute AdCP Creative Protocol operations with creative agents - bu # AdCP Creative Protocol -This skill enables you to execute the AdCP Creative Protocol with creative agents. Use the `call_adcp_agent` tool to send requests to creative agents. +This skill enables you to execute the AdCP Creative Protocol with creative agents. Use the standard MCP tools (`list_creative_formats`, `build_creative`, `preview_creative`) exposed by the connected agent. ## Overview diff --git a/skills/adcp-media-buy/SKILL.md b/skills/adcp-media-buy/SKILL.md index 80e3072609..b9d5ab3b20 100644 --- a/skills/adcp-media-buy/SKILL.md +++ b/skills/adcp-media-buy/SKILL.md @@ -5,7 +5,7 @@ description: Execute AdCP Media Buy Protocol operations with sales agents - disc # AdCP Media Buy Protocol -This skill enables you to execute the AdCP Media Buy Protocol with sales agents. Use the `call_adcp_agent` tool to send requests to sales agents. +This skill enables you to execute the AdCP Media Buy Protocol with sales agents. Use the standard MCP tools (`get_products`, `create_media_buy`, `sync_creatives`, etc.) exposed by the connected agent. ## Overview diff --git a/skills/adcp-signals/SKILL.md b/skills/adcp-signals/SKILL.md index d35159dfd2..2a102058b3 100644 --- a/skills/adcp-signals/SKILL.md +++ b/skills/adcp-signals/SKILL.md @@ -5,7 +5,7 @@ description: Execute AdCP Signals Protocol operations with signal agents - disco # AdCP Signals Protocol -This skill enables you to execute the AdCP Signals Protocol with signal agents. Use the `call_adcp_agent` tool to send requests to signal agents. +This skill enables you to execute the AdCP Signals Protocol with signal agents. Use the standard MCP tools (`get_signals`, `activate_signal`) exposed by the connected agent. ## Overview From 99f7f60f5a58eb8869695d5893eb0f317cee42b6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 09:29:39 -0500 Subject: [PATCH 51/58] fix: Paginate through all organization members in domain user backfill (#826) The auto-add domain users feature was only checking the first page of organization memberships from WorkOS (max 100). Organizations with more members would incorrectly show users as "available to add" when they were already members. Fixed by adding pagination loops that fetch all membership pages using listMetadata.after cursor, matching the pattern used elsewhere in the codebase. Co-authored-by: Claude Opus 4.5 --- .changeset/fix-auto-add-pagination.md | 5 ++++ server/src/routes/admin/domains.ts | 39 +++++++++++++++++++-------- 2 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-auto-add-pagination.md diff --git a/.changeset/fix-auto-add-pagination.md b/.changeset/fix-auto-add-pagination.md new file mode 100644 index 0000000000..8dd27cd9b2 --- /dev/null +++ b/.changeset/fix-auto-add-pagination.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +Fix pagination in auto-add domain users feature to fetch all organization members diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index fb534ac95d..189c2f4712 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -2179,12 +2179,21 @@ export function setupDomainRoutes( for (const row of result.rows) { const orgId = row.workos_organization_id; - // Get existing members for this org + // Get existing members for this org (paginate through all) try { - const memberships = await config.workos!.userManagement.listOrganizationMemberships({ - organizationId: orgId, - }); - const existingMemberUserIds = new Set(memberships.data.map(m => m.userId)); + const existingMemberUserIds = new Set(); + let after: string | undefined; + do { + const memberships = await config.workos!.userManagement.listOrganizationMemberships({ + organizationId: orgId, + limit: 100, + after, + }); + for (const m of memberships.data) { + existingMemberUserIds.add(m.userId); + } + after = memberships.listMetadata?.after; + } while (after); // Filter to users who aren't already members const usersToAdd = (row.users as Array<{ email: string; name: string | null; workos_user_id: string }>) @@ -2300,13 +2309,21 @@ export function setupDomainRoutes( let orgSkipped = 0; let orgErrors = 0; - // Get existing members for this org - let existingMemberUserIds: Set; + // Get existing members for this org (paginate through all) + const existingMemberUserIds = new Set(); try { - const memberships = await config.workos!.userManagement.listOrganizationMemberships({ - organizationId: orgId, - }); - existingMemberUserIds = new Set(memberships.data.map(m => m.userId)); + let after: string | undefined; + do { + const memberships = await config.workos!.userManagement.listOrganizationMemberships({ + organizationId: orgId, + limit: 100, + after, + }); + for (const m of memberships.data) { + existingMemberUserIds.add(m.userId); + } + after = memberships.listMetadata?.after; + } while (after); } catch (err) { logger.warn({ err, orgId }, 'Failed to get memberships for org, skipping'); continue; From 67d68d705a2d3ad662d2899a3ac40f35940f2f3f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 09:37:53 -0500 Subject: [PATCH 52/58] feat: Add search functionality to admin Addie threads (#827) Add ability to search/filter threads by: - Message content (text search) - User display name - Tool name used in conversation Backend changes: - Extend ThreadListFilters with search_text, tool_name, user_search - Update listThreads() to handle new filters with optional JOIN - Add getAvailableTools() method for tool dropdown - Add /api/admin/addie/threads/tools endpoint - Add input length validation on search params Frontend changes: - Add search row with text, user, and tool inputs - Add loading state to prevent concurrent requests - Wire up search params to API call Co-authored-by: Claude Opus 4.5 --- .changeset/solid-rooms-scream.md | 2 + server/public/admin-addie.html | 102 +++++++++++++++++++++++------ server/src/addie/thread-service.ts | 79 +++++++++++++++++----- server/src/routes/addie-admin.ts | 26 +++++++- 4 files changed, 174 insertions(+), 35 deletions(-) create mode 100644 .changeset/solid-rooms-scream.md diff --git a/.changeset/solid-rooms-scream.md b/.changeset/solid-rooms-scream.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/solid-rooms-scream.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/admin-addie.html b/server/public/admin-addie.html index cf5c55ce6e..f23cf1ca76 100644 --- a/server/public/admin-addie.html +++ b/server/public/admin-addie.html @@ -1587,25 +1587,39 @@

Conversation Threads

Browse conversations across all channels (Web, Slack, A2A)

-
- - - - - - - -
+
+ +
+ + + + + +
+ +
+ + + + + +
@@ -2437,8 +2451,40 @@

${escapeHtml(doc.title)}

} } + // Load available tools for the filter dropdown + async function loadThreadsTools() { + try { + const response = await fetch('/api/admin/addie/threads/tools'); + const data = await response.json(); + const select = document.getElementById('threads-tool-filter'); + select.innerHTML = '' + + data.tools.map(t => ``).join(''); + } catch (error) { + console.error('Error loading tools:', error); + } + } + + // Clear search filters + function clearThreadsSearch() { + document.getElementById('threads-search-text').value = ''; + document.getElementById('threads-search-user').value = ''; + document.getElementById('threads-tool-filter').value = ''; + loadThreads(); + } + // Load unified threads + let threadsLoading = false; async function loadThreads() { + // Prevent concurrent requests + if (threadsLoading) return; + threadsLoading = true; + + const searchBtn = document.getElementById('threads-search-btn'); + if (searchBtn) { + searchBtn.disabled = true; + searchBtn.textContent = 'Searching...'; + } + const channel = document.getElementById('channel-filter').value; const flaggedOnly = document.getElementById('threads-flagged-only').checked; const unreviewedOnly = document.getElementById('threads-unreviewed-only').checked; @@ -2446,6 +2492,11 @@

${escapeHtml(doc.title)}

const multiTurnOnly = document.getElementById('threads-multi-turn').checked; const timeframe = document.getElementById('stats-timeframe').value; + // Search filters + const searchText = document.getElementById('threads-search-text').value.trim(); + const userSearch = document.getElementById('threads-search-user').value.trim(); + const toolFilter = document.getElementById('threads-tool-filter').value; + const params = new URLSearchParams(); params.append('limit', '50'); if (channel) params.append('channel', channel); @@ -2454,6 +2505,11 @@

${escapeHtml(doc.title)}

if (hasUserFeedback) params.append('has_user_feedback', 'true'); if (multiTurnOnly) params.append('min_messages', '2'); + // Add search params + if (searchText) params.append('search', searchText); + if (userSearch) params.append('user_search', userSearch); + if (toolFilter) params.append('tool', toolFilter); + // Add timeframe filter to threads query if (timeframe !== 'all') { const now = new Date(); @@ -2575,6 +2631,13 @@

${escapeHtml(doc.title)}

console.error('Error loading threads:', error); document.getElementById('threads-list').innerHTML = '

Error loading threads

'; + } finally { + threadsLoading = false; + const searchBtn = document.getElementById('threads-search-btn'); + if (searchBtn) { + searchBtn.disabled = false; + searchBtn.textContent = 'Search'; + } } } @@ -4804,6 +4867,7 @@

{ try { const threadService = getThreadService(); - const { channel, flagged_only, unreviewed_only, has_user_feedback, min_messages, user_id, since, limit, offset } = req.query; + const { + channel, flagged_only, unreviewed_only, has_user_feedback, + min_messages, user_id, since, limit, offset, + search, tool, user_search + } = req.query; const threads = await threadService.listThreads({ channel: channel as ThreadChannel | undefined, @@ -385,6 +389,10 @@ export function createAddieAdminRouter(): { pageRouter: Router; apiRouter: Route since: since ? new Date(since as string) : undefined, limit: limit ? parseInt(limit as string, 10) : 50, offset: offset ? parseInt(offset as string, 10) : undefined, + // Search filters (with length limits to prevent performance issues) + search_text: typeof search === 'string' && search.length <= 500 ? search : undefined, + tool_name: typeof tool === 'string' && tool.length <= 100 ? tool : undefined, + user_search: typeof user_search === 'string' && user_search.length <= 200 ? user_search : undefined, }); res.json({ @@ -448,6 +456,22 @@ export function createAddieAdminRouter(): { pageRouter: Router; apiRouter: Route } }); + // GET /api/admin/addie/threads/tools - Get list of available tool names for filtering + // NOTE: Must be defined BEFORE /threads/:id to avoid matching "tools" as an ID + apiRouter.get("/threads/tools", requireAuth, requireAdmin, async (req, res) => { + try { + const threadService = getThreadService(); + const tools = await threadService.getAvailableTools(); + res.json({ tools }); + } catch (error) { + logger.error({ err: error }, "Error fetching available tools"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to fetch available tools", + }); + } + }); + // GET /api/admin/addie/threads/:id - Get a single thread with messages apiRouter.get("/threads/:id", requireAuth, requireAdmin, async (req, res) => { try { From dd1df2460c1b0f40b678612c4a50c67144398ffa Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 10:02:19 -0500 Subject: [PATCH 53/58] fix: Verify unverified domains when linking to org (#828) When clicking "Link to Org" for an organization in the "Organizations Without Verified Domains" section, the API was returning success but the org remained in the list. This happened because the domain existed in organization_domains with verified=false, and the endpoint returned early without checking the verified status. Now the endpoint checks if the existing domain is verified before returning early. If not verified, it continues to the verification logic which updates both WorkOS and the local DB. Co-authored-by: Claude Opus 4.5 --- .changeset/moody-beers-love.md | 2 ++ server/src/routes/admin/domains.ts | 18 +++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 .changeset/moody-beers-love.md diff --git a/.changeset/moody-beers-love.md b/.changeset/moody-beers-love.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/moody-beers-love.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index 189c2f4712..13b7340960 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -1190,7 +1190,7 @@ export function setupDomainRoutes( // Check if domain is already claimed by another org locally const existingResult = await pool.query( - `SELECT od.workos_organization_id, o.name as org_name + `SELECT od.workos_organization_id, od.verified, o.name as org_name FROM organization_domains od JOIN organizations o ON o.workos_organization_id = od.workos_organization_id WHERE od.domain = $1`, @@ -1206,12 +1206,16 @@ export function setupDomainRoutes( existing_organization_id: existingOrg.workos_organization_id, }); } - // Domain already belongs to this org - return res.json({ - success: true, - message: "Domain already associated with this organization", - domain: normalizedDomain, - }); + // Domain already belongs to this org - but need to verify it if not already + if (existingOrg.verified) { + return res.json({ + success: true, + message: "Domain already associated with this organization", + domain: normalizedDomain, + }); + } + // Domain exists but not verified - continue to verify it + logger.info({ orgId, domain: normalizedDomain }, "Domain exists but not verified, proceeding to verify"); } // Add to WorkOS first - this is the source of truth From 2a8106e46870b1d113e040e473453b3a19091796 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 10:11:55 -0500 Subject: [PATCH 54/58] fix: Handle pending invitations in auto-add domain users feature (#829) The auto-add domain users backfill was showing users as "can be added" when they actually had pending invitations that hadn't been accepted yet. Root cause: WorkOS's listOrganizationMemberships only returns active memberships, not pending invitations. When trying to add these users, WorkOS returns error code "cannot_reactivate_pending_organization_membership". Changes: - Status endpoint now checks pending invitations and excludes those users - Backfill endpoint handles both "organization_membership_already_exists" and "cannot_reactivate_pending_organization_membership" as skip conditions - Fixed error code checking in organizations.ts endpoints (was checking error message strings instead of error codes) Co-authored-by: Claude Opus 4.5 --- .changeset/fix-auto-add-members.md | 4 ++++ server/src/routes/admin/domains.ts | 29 ++++++++++++++++++++++++----- server/src/routes/organizations.ts | 22 +++++++++++----------- 3 files changed, 39 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-auto-add-members.md diff --git a/.changeset/fix-auto-add-members.md b/.changeset/fix-auto-add-members.md new file mode 100644 index 0000000000..7aec3e34db --- /dev/null +++ b/.changeset/fix-auto-add-members.md @@ -0,0 +1,4 @@ +--- +--- + +Fix auto-add domain users feature to properly handle pending invitations diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index 13b7340960..1351fd5303 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -2199,9 +2199,27 @@ export function setupDomainRoutes( after = memberships.listMetadata?.after; } while (after); - // Filter to users who aren't already members + // Also get pending invitations (can't add users who have pending invites) + const pendingInvitationEmails = new Set(); + after = undefined; + do { + const invitations = await config.workos!.userManagement.listInvitations({ + organizationId: orgId, + limit: 100, + after, + }); + for (const inv of invitations.data) { + if (inv.state === 'pending') { + pendingInvitationEmails.add(inv.email.toLowerCase()); + } + } + after = invitations.listMetadata?.after; + } while (after); + + // Filter to users who aren't already members and don't have pending invitations const usersToAdd = (row.users as Array<{ email: string; name: string | null; workos_user_id: string }>) - .filter(u => !existingMemberUserIds.has(u.workos_user_id)); + .filter(u => !existingMemberUserIds.has(u.workos_user_id) && + !pendingInvitationEmails.has(u.email.toLowerCase())); if (usersToAdd.length > 0) { orgUsersToAdd.push({ @@ -2355,9 +2373,10 @@ export function setupDomainRoutes( userId: user.workos_user_id, addedBy: adminUser.id, }, 'Domain user auto-added to organization via backfill'); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error'; - if (errorMessage.includes('already a member')) { + } catch (err: any) { + if (err?.code === 'organization_membership_already_exists' || + err?.code === 'cannot_reactivate_pending_organization_membership') { + // User is already a member or has a pending invitation orgSkipped++; } else { logger.warn({ err, orgId, email: user.email }, 'Failed to add domain user'); diff --git a/server/src/routes/organizations.ts b/server/src/routes/organizations.ts index e2d6b074f4..d17163a4cb 100644 --- a/server/src/routes/organizations.ts +++ b/server/src/routes/organizations.ts @@ -423,24 +423,24 @@ export function createOrganizationsRouter(): Router { success: true, message: `Invitation sent to ${request.user_email}`, }); - } catch (error) { + } catch (error: any) { logger.error({ err: error }, 'Approve join request error:'); // Check for specific WorkOS errors - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (errorMessage.includes('already a member')) { + if (error?.code === 'organization_membership_already_exists') { return res.status(400).json({ error: 'User already a member', message: 'This user is already a member of the organization', }); } - if (errorMessage.includes('pending invitation')) { + if (error?.code === 'invitation_already_exists') { return res.status(400).json({ error: 'Invitation exists', message: 'There is already a pending invitation for this user', }); } + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: 'Failed to approve join request', message: errorMessage, @@ -937,17 +937,17 @@ export function createOrganizationsRouter(): Router { role: membership.role?.slug || roleToAssign, }, }); - } catch (error) { + } catch (error: any) { logger.error({ err: error }, 'Add domain user error'); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (errorMessage.includes('already a member')) { + if (error?.code === 'organization_membership_already_exists') { return res.status(400).json({ error: 'Already a member', message: 'This user is already a member of the organization.', }); } + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: 'Failed to add domain user', message: errorMessage, @@ -2002,24 +2002,24 @@ export function createOrganizationsRouter(): Router { accept_invitation_url: invitation.acceptInvitationUrl, }, }); - } catch (error) { + } catch (error: any) { logger.error({ err: error }, 'Send invitation error'); // Check for specific WorkOS errors - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (errorMessage.includes('already a member')) { + if (error?.code === 'organization_membership_already_exists') { return res.status(400).json({ error: 'User already a member', message: 'This user is already a member of the organization', }); } - if (errorMessage.includes('pending invitation')) { + if (error?.code === 'invitation_already_exists') { return res.status(400).json({ error: 'Invitation already exists', message: 'An invitation has already been sent to this email address', }); } + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: 'Failed to send invitation', message: errorMessage, From 6708ad4c6bffdcba6690cf0ca8d4024642693e57 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 10:17:16 -0500 Subject: [PATCH 55/58] feat: Add debug logging to AdCP tools and clarify probe messaging (#830) - Add `debug` parameter to all 10 AdCP tool schemas for protocol-level visibility - Include debug_logs in tool output when debug mode enabled - Remove redundant `call_adcp_agent` tool (individual tools provide better validation) - Fix `probe_adcp_agent` messaging to clarify connectivity vs protocol compliance Co-authored-by: Claude Opus 4.5 --- .changeset/honest-maps-decide.md | 10 ++ server/src/addie/mcp/adcp-tools.ts | 116 ++++++++++++++++++----- server/src/addie/mcp/member-tools.ts | 136 +-------------------------- server/src/addie/tool-sets.ts | 1 - 4 files changed, 106 insertions(+), 157 deletions(-) create mode 100644 .changeset/honest-maps-decide.md diff --git a/.changeset/honest-maps-decide.md b/.changeset/honest-maps-decide.md new file mode 100644 index 0000000000..03781f4130 --- /dev/null +++ b/.changeset/honest-maps-decide.md @@ -0,0 +1,10 @@ +--- +"adcontextprotocol": patch +--- + +Add debug logging support to Addie's AdCP tools and clarify probe vs test behavior. + +- Add `debug` parameter to all 10 AdCP tool schemas (get_products, create_media_buy, etc.) +- Include debug_logs in tool output when debug mode is enabled +- Remove redundant `call_adcp_agent` tool (individual tools provide better schema validation) +- Fix `probe_adcp_agent` messaging to clarify it only checks connectivity, not protocol compliance diff --git a/server/src/addie/mcp/adcp-tools.ts b/server/src/addie/mcp/adcp-tools.ts index add820548f..7c39263885 100644 --- a/server/src/addie/mcp/adcp-tools.ts +++ b/server/src/addie/mcp/adcp-tools.ts @@ -2,8 +2,8 @@ * AdCP Protocol Tools * * Standard MCP tools that match the AdCP protocol specification. - * These expose the same functionality as call_adcp_agent but with - * proper schemas that match the protocol, enabling skills to work. + * Each tool has a typed schema that helps Claude understand the parameters. + * Use debug=true to see protocol-level details (requests, responses, schema validation). */ import { logger } from '../../logger.js'; @@ -72,6 +72,10 @@ export const ADCP_MEDIA_BUY_TOOLS: AddieTool[] = [ }, }, }, + debug: { + type: 'boolean', + description: 'Enable debug logging to see protocol-level details (requests, responses, schema validation)', + }, }, required: ['agent_url', 'brief'], }, @@ -135,6 +139,10 @@ export const ADCP_MEDIA_BUY_TOOLS: AddieTool[] = [ type: 'string', description: 'ISO 8601 datetime when campaign ends', }, + debug: { + type: 'boolean', + description: 'Enable debug logging to see protocol-level details', + }, }, required: ['agent_url', 'buyer_ref', 'brand_manifest', 'packages', 'start_time', 'end_time'], }, @@ -185,6 +193,10 @@ export const ADCP_MEDIA_BUY_TOOLS: AddieTool[] = [ type: 'boolean', description: 'Preview changes without applying', }, + debug: { + type: 'boolean', + description: 'Enable debug logging to see protocol-level details', + }, }, required: ['agent_url', 'creatives'], }, @@ -207,6 +219,10 @@ export const ADCP_MEDIA_BUY_TOOLS: AddieTool[] = [ items: { type: 'string' }, description: 'Filter to specific format categories (video, display, audio, etc.)', }, + debug: { + type: 'boolean', + description: 'Enable debug logging to see protocol-level details', + }, }, required: ['agent_url'], }, @@ -224,6 +240,10 @@ export const ADCP_MEDIA_BUY_TOOLS: AddieTool[] = [ type: 'string', description: 'The sales agent URL (must be HTTPS)', }, + debug: { + type: 'boolean', + description: 'Enable debug logging to see protocol-level details', + }, }, required: ['agent_url'], }, @@ -257,6 +277,10 @@ export const ADCP_MEDIA_BUY_TOOLS: AddieTool[] = [ end: { type: 'string', description: 'ISO date (YYYY-MM-DD)' }, }, }, + debug: { + type: 'boolean', + description: 'Enable debug logging to see protocol-level details', + }, }, required: ['agent_url', 'media_buy_id'], }, @@ -298,6 +322,10 @@ export const ADCP_CREATIVE_TOOLS: AddieTool[] = [ type: 'object', description: 'Source manifest - minimal for generation, complete for transformation', }, + debug: { + type: 'boolean', + description: 'Enable debug logging to see protocol-level details', + }, }, required: ['agent_url', 'target_format_id'], }, @@ -341,6 +369,10 @@ export const ADCP_CREATIVE_TOOLS: AddieTool[] = [ enum: ['url', 'html'], description: 'Output format (default: url)', }, + debug: { + type: 'boolean', + description: 'Enable debug logging to see protocol-level details', + }, }, required: ['agent_url', 'request_type'], }, @@ -407,6 +439,10 @@ export const ADCP_SIGNALS_TOOLS: AddieTool[] = [ type: 'number', description: 'Limit number of results', }, + debug: { + type: 'boolean', + description: 'Enable debug logging to see protocol-level details', + }, }, required: ['agent_url', 'signal_spec', 'deliver_to'], }, @@ -442,6 +478,10 @@ export const ADCP_SIGNALS_TOOLS: AddieTool[] = [ required: ['type'], }, }, + debug: { + type: 'boolean', + description: 'Enable debug logging to see protocol-level details', + }, }, required: ['agent_url', 'signal_agent_segment_id', 'deployments'], }, @@ -522,7 +562,8 @@ export function createAdcpToolHandlers( async function executeTask( agentUrl: string, task: string, - params: Record + params: Record, + debug: boolean = false ): Promise { const validationError = validateAgentUrl(agentUrl); if (validationError) { @@ -531,30 +572,45 @@ export function createAdcpToolHandlers( const authToken = await getAuthToken(agentUrl); - logger.info({ agentUrl, task, hasAuth: !!authToken }, `AdCP: executing ${task}`); + logger.info({ agentUrl, task, hasAuth: !!authToken, debug }, `AdCP: executing ${task}`); try { const { AdCPClient } = await import('@adcp/client'); - const multiClient = new AdCPClient([ - { - id: 'target', - name: 'target', - agent_uri: agentUrl, - protocol: 'mcp', - ...(authToken && { auth_token: authToken }), - }, - ]); + const multiClient = new AdCPClient( + [ + { + id: 'target', + name: 'target', + agent_uri: agentUrl, + protocol: 'mcp', + ...(authToken && { auth_token: authToken }), + }, + ], + { debug } + ); const client = multiClient.agent('target'); - const result = await client.executeTask(task, params); + const result = await client.executeTask(task, params, undefined, { debug }); if (!result.success) { - return `**Task failed:** \`${task}\`\n\n**Error:**\n\`\`\`json\n${JSON.stringify(result.error, null, 2)}\n\`\`\``; + let output = `**Task failed:** \`${task}\`\n\n**Error:**\n\`\`\`json\n${JSON.stringify(result.error, null, 2)}\n\`\`\``; + + // Include debug logs on failure (always useful for debugging) + if (result.debug_logs && result.debug_logs.length > 0) { + output += `\n\n**Debug Logs:**\n\`\`\`json\n${JSON.stringify(result.debug_logs, null, 2)}\n\`\`\``; + } + + return output; } let output = `**Task:** \`${task}\`\n**Status:** Success\n\n`; output += `**Response:**\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\``; + // Include debug logs if debug mode is enabled + if (debug && result.debug_logs && result.debug_logs.length > 0) { + output += `\n\n**Debug Logs:**\n\`\`\`json\n${JSON.stringify(result.debug_logs, null, 2)}\n\`\`\``; + } + return output; } catch (error) { logger.error({ error, agentUrl, task }, `AdCP: ${task} failed`); @@ -565,17 +621,19 @@ export function createAdcpToolHandlers( // Media Buy handlers handlers.set('get_products', async (input: Record) => { const agentUrl = input.agent_url as string; + const debug = input.debug as boolean | undefined; const params: Record = { brief: input.brief, }; if (input.brand_manifest) params.brand_manifest = input.brand_manifest; if (input.filters) params.filters = input.filters; - return executeTask(agentUrl, 'get_products', params); + return executeTask(agentUrl, 'get_products', params, debug); }); handlers.set('create_media_buy', async (input: Record) => { const agentUrl = input.agent_url as string; + const debug = input.debug as boolean | undefined; const params: Record = { buyer_ref: input.buyer_ref, brand_manifest: input.brand_manifest, @@ -584,58 +642,64 @@ export function createAdcpToolHandlers( end_time: input.end_time, }; - return executeTask(agentUrl, 'create_media_buy', params); + return executeTask(agentUrl, 'create_media_buy', params, debug); }); handlers.set('sync_creatives', async (input: Record) => { const agentUrl = input.agent_url as string; + const debug = input.debug as boolean | undefined; const params: Record = { creatives: input.creatives, }; if (input.assignments) params.assignments = input.assignments; if (input.dry_run !== undefined) params.dry_run = input.dry_run; - return executeTask(agentUrl, 'sync_creatives', params); + return executeTask(agentUrl, 'sync_creatives', params, debug); }); handlers.set('list_creative_formats', async (input: Record) => { const agentUrl = input.agent_url as string; + const debug = input.debug as boolean | undefined; const params: Record = {}; if (input.format_types) params.format_types = input.format_types; - return executeTask(agentUrl, 'list_creative_formats', params); + return executeTask(agentUrl, 'list_creative_formats', params, debug); }); handlers.set('list_authorized_properties', async (input: Record) => { const agentUrl = input.agent_url as string; - return executeTask(agentUrl, 'list_authorized_properties', {}); + const debug = input.debug as boolean | undefined; + return executeTask(agentUrl, 'list_authorized_properties', {}, debug); }); handlers.set('get_media_buy_delivery', async (input: Record) => { const agentUrl = input.agent_url as string; + const debug = input.debug as boolean | undefined; const params: Record = { media_buy_id: input.media_buy_id, }; if (input.granularity) params.granularity = input.granularity; if (input.date_range) params.date_range = input.date_range; - return executeTask(agentUrl, 'get_media_buy_delivery', params); + return executeTask(agentUrl, 'get_media_buy_delivery', params, debug); }); // Creative handlers handlers.set('build_creative', async (input: Record) => { const agentUrl = input.agent_url as string; + const debug = input.debug as boolean | undefined; const params: Record = { target_format_id: input.target_format_id, }; if (input.message) params.message = input.message; if (input.creative_manifest) params.creative_manifest = input.creative_manifest; - return executeTask(agentUrl, 'build_creative', params); + return executeTask(agentUrl, 'build_creative', params, debug); }); handlers.set('preview_creative', async (input: Record) => { const agentUrl = input.agent_url as string; + const debug = input.debug as boolean | undefined; const params: Record = { request_type: input.request_type, }; @@ -644,12 +708,13 @@ export function createAdcpToolHandlers( if (input.requests) params.requests = input.requests; if (input.output_format) params.output_format = input.output_format; - return executeTask(agentUrl, 'preview_creative', params); + return executeTask(agentUrl, 'preview_creative', params, debug); }); // Signals handlers handlers.set('get_signals', async (input: Record) => { const agentUrl = input.agent_url as string; + const debug = input.debug as boolean | undefined; const params: Record = { signal_spec: input.signal_spec, deliver_to: input.deliver_to, @@ -657,17 +722,18 @@ export function createAdcpToolHandlers( if (input.filters) params.filters = input.filters; if (input.max_results) params.max_results = input.max_results; - return executeTask(agentUrl, 'get_signals', params); + return executeTask(agentUrl, 'get_signals', params, debug); }); handlers.set('activate_signal', async (input: Record) => { const agentUrl = input.agent_url as string; + const debug = input.debug as boolean | undefined; const params: Record = { signal_agent_segment_id: input.signal_agent_segment_id, deployments: input.deployments, }; - return executeTask(agentUrl, 'activate_signal', params); + return executeTask(agentUrl, 'activate_signal', params, debug); }); return handlers; diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index e357506d37..92d7f45030 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -503,8 +503,8 @@ export const MEMBER_TOOLS: AddieTool[] = [ { name: 'probe_adcp_agent', description: - 'Check if an AdCP agent is online and discover its capabilities. Always validates connectivity first, then retrieves available tools and operations. Use this as the primary tool for investigating agents - it combines health checking with capability discovery.', - usage_hints: 'use for "is this agent working?", "what can this agent do?", "check my agent", "probe agent", "test connectivity"', + 'Check if an AdCP agent is online and list its advertised capabilities. This only verifies connectivity (the agent responds to HTTP requests) - it does NOT verify the agent implements the protocol correctly. Use test_adcp_agent to verify actual protocol compliance.', + usage_hints: 'use for "is this agent online?", "check connectivity", "what tools does this agent advertise?". For compliance testing, use test_adcp_agent instead.', input_schema: { type: 'object', properties: { @@ -551,51 +551,6 @@ export const MEMBER_TOOLS: AddieTool[] = [ required: ['agent_url'], }, }, - { - name: 'call_adcp_agent', - description: - 'Execute an AdCP protocol task on an agent. This is a low-level proxy that forwards requests directly to the agent. Use adcp-media-buy, adcp-signals, or adcp-creative skills for detailed parameter schemas.', - usage_hints: 'use for "call get_products", "execute create_media_buy", "run sync_creatives", "get_signals", "build_creative", "interact with the agent directly", "use the full AdCP spec". For testing workflows, prefer test_adcp_agent instead.', - input_schema: { - type: 'object', - properties: { - agent_url: { - type: 'string', - description: 'The agent URL (must be HTTPS, e.g., "https://sales.example.com")', - }, - task: { - type: 'string', - enum: [ - // Media Buy tasks - 'get_products', - 'list_authorized_properties', - 'list_creative_formats', - 'create_media_buy', - 'update_media_buy', - 'sync_creatives', - 'list_creatives', - 'get_media_buy_delivery', - // Signals tasks - 'get_signals', - 'activate_signal', - // Creative tasks - 'build_creative', - 'preview_creative', - ], - description: 'The AdCP task to execute', - }, - params: { - type: 'object', - description: 'Task parameters - structure depends on the task. See protocol skills for schemas.', - }, - auth_token: { - type: 'string', - description: 'Optional auth token. If not provided, will use saved token for this agent.', - }, - }, - required: ['agent_url', 'task'], - }, - }, // ============================================ // AGENT CONTEXT MANAGEMENT // ============================================ @@ -1968,11 +1923,11 @@ export function createMemberToolHandlers( // Summary response += `\n---\n`; if (isHealthy && (agent?.capabilities?.tools?.length ?? 0) > 0) { - response += `✅ Agent is healthy and ready to use. Try \`test_adcp_agent\` to run a full workflow test.`; + response += `✅ Agent is **online** and responding. Run \`test_adcp_agent\` to verify protocol compliance.`; } else if (isHealthy) { - response += `✅ Agent is responding but not in the registry. You can still use \`call_adcp_agent\` to execute tasks directly.`; + response += `✅ Agent is **online** but not in the registry. Try calling it with \`get_products\` or run \`test_adcp_agent\` to verify it works correctly.`; } else { - response += `❌ Agent is not responding. Check the URL and ensure the agent is running.`; + response += `❌ Agent is **not responding**. Check the URL and ensure the agent is running.`; } return response; @@ -2173,87 +2128,6 @@ export function createMemberToolHandlers( } }); - // ============================================ - // ADCP AGENT PROXY - // ============================================ - handlers.set('call_adcp_agent', async (input) => { - const agentUrl = input.agent_url as string; - const task = input.task as string; - const params = (input.params as Record) || {}; - let authToken = input.auth_token as string | undefined; - - // Validate URL to prevent SSRF attacks - try { - const url = new URL(agentUrl); - - // Must be HTTPS - if (url.protocol !== 'https:') { - return '**Error:** Agent URL must use HTTPS protocol.'; - } - - // Block internal/private networks - const hostname = url.hostname.toLowerCase(); - if ( - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '::1' || - hostname.endsWith('.local') || - hostname.endsWith('.internal') || - hostname.startsWith('10.') || - hostname.startsWith('192.168.') || - hostname.match(/^172\.(1[6-9]|2\d|3[01])\./) || - hostname === '169.254.169.254' // AWS metadata - ) { - return '**Error:** Agent URL cannot point to internal or private networks.'; - } - } catch { - return '**Error:** Invalid agent URL format.'; - } - - // Look up saved auth token if not provided - if (!authToken && memberContext?.organization?.workos_organization_id) { - const savedContext = await agentContextDb.getByOrgAndUrl( - memberContext.organization.workos_organization_id, - agentUrl - ); - if (savedContext?.id) { - authToken = (await agentContextDb.getAuthToken(savedContext.id)) ?? undefined; - } - } - - logger.info({ agentUrl, task, hasAuth: !!authToken }, 'Addie: call_adcp_agent'); - - try { - const { AdCPClient } = await import('@adcp/client'); - const multiClient = new AdCPClient([ - { - id: 'target', - name: 'target', - agent_uri: agentUrl, - protocol: 'mcp', - ...(authToken && { auth_token: authToken }), - }, - ]); - const client = multiClient.agent('target'); - - const result = await client.executeTask(task, params); - - if (!result.success) { - // Return errors in a structured way - return `**Task failed:** \`${task}\`\n\n**Error:**\n\`\`\`json\n${JSON.stringify(result.error, null, 2)}\n\`\`\``; - } - - // Format successful response - let output = `**Task:** \`${task}\`\n**Status:** Success\n\n`; - output += `**Response:**\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\``; - - return output; - } catch (error) { - logger.error({ error, agentUrl, task }, 'Addie: call_adcp_agent failed'); - return `**Task failed:** \`${task}\`\n\n**Error:** ${error instanceof Error ? error.message : 'Unknown error'}`; - } - }); - // ============================================ // GITHUB ISSUE DRAFTING // ============================================ diff --git a/server/src/addie/tool-sets.ts b/server/src/addie/tool-sets.ts index 9a8b60418d..25a8cf45da 100644 --- a/server/src/addie/tool-sets.ts +++ b/server/src/addie/tool-sets.ts @@ -124,7 +124,6 @@ export const TOOL_SETS: Record = { 'get_signals', 'activate_signal', // Agent management - 'call_adcp_agent', 'save_agent', 'list_saved_agents', 'remove_saved_agent', From ec0e4fe981368f34066c39a9bde8658602da6186 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 10:30:57 -0500 Subject: [PATCH 56/58] fix: Correct API response parsing in Addie member tools (#831) Multiple MCP tool handlers were incorrectly parsing API responses, expecting flat arrays/objects when APIs return wrapped responses: - list_working_groups: Extract working_groups from wrapped response - get_working_group: Extract working_group from { working_group, is_member } - get_my_working_groups: Extract working_groups from wrapped response - get_my_profile: Extract profile from { profile, organization_id, organization_name } These fixes resolve errors that were appearing on the Addie performance page for list_working_groups and related tools. Co-authored-by: Claude Opus 4.5 --- .changeset/eighty-dingos-lay.md | 11 ++++++ server/src/addie/mcp/member-tools.ts | 54 ++++++++++++++-------------- 2 files changed, 39 insertions(+), 26 deletions(-) create mode 100644 .changeset/eighty-dingos-lay.md diff --git a/.changeset/eighty-dingos-lay.md b/.changeset/eighty-dingos-lay.md new file mode 100644 index 0000000000..f671d57204 --- /dev/null +++ b/.changeset/eighty-dingos-lay.md @@ -0,0 +1,11 @@ +--- +"adcontextprotocol": patch +--- + +Fix API response parsing in Addie member tools + +Multiple MCP tool handlers were incorrectly parsing API responses, expecting flat arrays/objects when APIs return wrapped responses. Fixed: +- `list_working_groups`: Extract `working_groups` from `{ working_groups: [...] }` +- `get_working_group`: Extract `working_group` from `{ working_group: {...}, is_member }` +- `get_my_working_groups`: Extract `working_groups` from wrapped response +- `get_my_profile`: Extract `profile` from `{ profile, organization_id, organization_name }` diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 92d7f45030..57da0423bb 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -865,7 +865,7 @@ export function createMemberToolHandlers( return `Failed to fetch working groups: ${result.error}`; } - const groups = result.data as Array<{ + const data = result.data as { working_groups: Array<{ slug: string; name: string; description: string; @@ -873,9 +873,10 @@ export function createMemberToolHandlers( member_count: number; committee_type: string; region?: string; - }>; + }> }; + const groups = data.working_groups; - if (groups.length === 0) { + if (!groups || groups.length === 0) { const typeLabel = typeFilter && typeFilter !== 'all' ? ` (type: ${typeFilter})` : ''; return `No active committees found${typeLabel}.`; } @@ -912,15 +913,15 @@ export function createMemberToolHandlers( return `Failed to fetch working group: ${result.error}`; } - const group = result.data as { + const data = result.data as { working_group: { name: string; slug: string; description: string; is_private: boolean; member_count: number; - leaders: Array<{ name: string; headline?: string }>; - recent_posts?: Array<{ title: string; author: string; published_at: string }>; - }; + leaders?: Array<{ name?: string; user_id: string }>; + }; is_member: boolean }; + const group = data.working_group; let response = `## ${group.name}\n\n`; response += `**Slug:** ${group.slug}\n`; @@ -931,18 +932,11 @@ export function createMemberToolHandlers( if (group.leaders && group.leaders.length > 0) { response += `### Leaders\n`; group.leaders.forEach((leader) => { - response += `- **${leader.name}**${leader.headline ? ` - ${leader.headline}` : ''}\n`; + response += `- ${leader.name || 'Unknown'}\n`; }); response += `\n`; } - if (group.recent_posts && group.recent_posts.length > 0) { - response += `### Recent Posts\n`; - group.recent_posts.forEach((post) => { - response += `- "${post.title}" by ${post.author}\n`; - }); - } - return response; }); @@ -978,20 +972,22 @@ export function createMemberToolHandlers( return `Failed to fetch your working groups: ${result.error}`; } - const memberships = result.data as Array<{ - working_group: { name: string; slug: string }; - role: string; - joined_at: string; - }>; + const data = result.data as { working_groups: Array<{ + name: string; + slug: string; + committee_type: string; + is_private: boolean; + }> }; + const groups = data.working_groups; - if (memberships.length === 0) { + if (!groups || groups.length === 0) { return "You're not a member of any working groups yet. Use list_working_groups to find groups to join!"; } let response = `## Your Working Group Memberships\n\n`; - memberships.forEach((m) => { - const role = m.role === 'leader' ? '👑 Leader' : '👤 Member'; - response += `- **${m.working_group.name}** (${m.working_group.slug}) - ${role}\n`; + groups.forEach((group) => { + const typeLabel = group.committee_type !== 'working_group' ? ` [${group.committee_type.replace('_', ' ')}]` : ''; + response += `- **${group.name}**${typeLabel} (${group.slug})\n`; }); return response; @@ -1101,7 +1097,7 @@ export function createMemberToolHandlers( return `Failed to fetch your profile: ${result.error}`; } - const profile = result.data as { + const data = result.data as { profile: { name: string; slug: string; headline?: string; @@ -1111,7 +1107,13 @@ export function createMemberToolHandlers( linkedin?: string; location?: string; is_visible: boolean; - }; + } | null; organization_name?: string }; + + if (!data.profile) { + return "You don't have a member profile yet. Visit https://agenticadvertising.org/member-profile to create one!"; + } + + const profile = data.profile; let response = `## Your Member Profile\n\n`; response += `**Name:** ${profile.name}\n`; From 240b50c083c918801f744883b0fb6734f437b78d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Jan 2026 10:35:45 -0500 Subject: [PATCH 57/58] feat: Add Addie code version tracking and shorter performance timeframes (#832) - Add CODE_VERSION constant to track significant code logic changes - Include code_version in config hash so code changes create new versions - Add migration 184 to store code_version in addie_config_versions table - Show code version in admin dashboard version banner and history - Add 3-hour and 12-hour options to performance page time filters - Change performance metrics API to use hours internally for precision - Add input validation for days parameter Co-authored-by: Claude Opus 4.5 --- .changeset/addie-code-version.md | 5 +++ CLAUDE.md | 13 +++++++ server/public/admin-addie.html | 29 +++++++++++---- server/src/addie/config-version.ts | 33 +++++++++++++---- server/src/addie/thread-service.ts | 35 ++++++++++--------- server/src/db/addie-db.ts | 3 ++ .../db/migrations/184_addie_code_version.sql | 12 +++++++ server/src/routes/addie-admin.ts | 16 +++++++-- 8 files changed, 114 insertions(+), 32 deletions(-) create mode 100644 .changeset/addie-code-version.md create mode 100644 server/src/db/migrations/184_addie_code_version.sql diff --git a/.changeset/addie-code-version.md b/.changeset/addie-code-version.md new file mode 100644 index 0000000000..6d641bb2b2 --- /dev/null +++ b/.changeset/addie-code-version.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +Add Addie code version tracking and shorter performance timeframes diff --git a/CLAUDE.md b/CLAUDE.md index e97734e883..2d5ade832e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,19 @@ Types: `patch` (fixes), `minor` (new features), `major` (breaking), `--empty` (n - **MINOR**: Add optional fields, new enum values, new tasks - **MAJOR**: Remove/rename fields, change types, remove enum values +### Addie Code Version +When making significant changes to Addie's core logic, bump `CODE_VERSION` in `server/src/addie/config-version.ts`. + +**When to bump:** +- Claude client behavior (`claude-client.ts`) +- Tool implementations (`mcp/*.ts`) +- Message processing logic (`thread-service.ts`, `bolt-app.ts`) +- Router logic beyond `ROUTING_RULES` (`router.ts`) + +**Format:** `YYYY.MM.N` (e.g., `2025.01.1`, `2025.01.2`, `2025.02.1`) + +This creates a new Addie config version, allowing performance comparison before/after code changes. + ## Deployment Production deploys to **Fly.io** (not Vercel). Migrations run automatically on startup. diff --git a/server/public/admin-addie.html b/server/public/admin-addie.html index f23cf1ca76..24d1bf9c11 100644 --- a/server/public/admin-addie.html +++ b/server/public/admin-addie.html @@ -1240,9 +1240,11 @@

Addie Dashboard

- Config Version: + Addie Version: - - (- rules) + + (code -, - rules) +
@@ -1464,15 +1466,16 @@

Review Synthesis Run