Guide

Slack architecture

prc-slack owns every Slack connection: production publish notices, watcher DMs, and PRC Nexus chat.

Intent gate

ConstructorIntentWhen it sends
Message::notice()NoticeAlways on production. Never on local. Otherwise when Settings notices_enabled is true.
Message::conversation()ConversationAny environment
Message::intake()IntakeAlways except local, which follows Settings notices_enabled.

Bot::send_notification() always builds a notice. Session replies always build a conversation. Bot::post_ask() builds an intake message for Help Center Asks. There is no public Message constructor, so chat cannot inherit the notice gate and notices cannot skip it.

Connection

Connection::resolve() is the only credential reader.

EnvironmentToken / signing secret / workspace
productionVIP constants only. Site option overrides are ignored.
non-productionNon-empty option override wins per field; constants are the fallback.

Constants:

ConstantUse
PRC_PLATFORM_SLACK_TOKENBot token (xoxb-…) for chat.postMessage, reactions.add, chat.getPermalink, and agents.sessions.*
PRC_PLATFORM_SLACK_SIGNING_SECRETEvents API HMAC
PRC_PLATFORM_SLACK_WORKSPACE_IDTeam allowlist (T…)
PRC_PLATFORM_ORIGIN_APP_IDOrigin app ID (app_…). JWT iss.
PRC_PLATFORM_ORIGIN_APP_KEY_IDSigning-key ID for JWT kid. Falls back to the app ID.
PRC_PLATFORM_ORIGIN_APP_PRIVATE_KEYPKCS#8 Ed25519 private key. Never store this in settings.
PRC_PLATFORM_ORIGIN_INSTALLATION_IDInstallation ID (i_…) used to mint oit_… tokens.

Connection never returns secrets as strings. Callers ask it to verify a signature or deliver a Message. Origin credentials live only in VIP constants.

Publish notifications

  • Consumes prc_platform_async_on_publish from prc-post-publish-pipeline.
  • Skips forked posts (_prc_fork_parent meta).
  • Routes decoded posts to #decoded; other public post types use #publish (channel_for_post()).
  • Message body includes post type label, title, permalink, Bit.ly shortlink when available, and bylines when prc-staff-bylines is active.
  • Sends when Intent::Notice is permitted for the current environment (production always, local never, other hosts when notices_enabled is on).

Platform Factory pipeline

Factory\Factory owns the Cursor Origin webhook route, Action Scheduler jobs, and Slack merge actions. It does not add event branches to Bot::send_notification().

Flow

Factory notification flow

5 relationships
The REST request verifies and queues work. Slack and Origin API calls run after the request.

The pipeline uses these types:

TypeResponsibility
Origin_DeliveryHolds a payload after Ed25519 verification and the five-minute replay check.
Pull_EventParses supported webhook payloads into pull request, author, reviewer, and approver values.
Git_RefRemoves one refs/heads/ prefix before branch comparison.
Factory_KindDefines the audience, message copy, and merge-button rule for each notification.
Factory_RosterResolves an Origin email or configured login mapping to a Slack member ID.
Merge_ActionEncodes the backport pull request and authorized Slack recipient in the button value.

The webhook handler claims webhook-id with wp_cache_add() and also requests a unique Action Scheduler job. Repeated deliveries converge on one queued job while the object-cache entry exists.

For a created pull request, the title suffix [backport] is enough to classify a backport. When Origin API credentials exist, Origin_Api fetches the pull request and adds its labels and description. The exact backport label also classifies the event. The description line Original author: @login selects the direct-message recipient; the pull request author is the fallback.

The merge button is limited to its direct-message recipient and is omitted while the pull request is a draft. A valid click replaces the button with a merging status and queues prc_slack_factory_merge. The job rejects drafts, tries Origin POST .../pulls/{n}/merge, and uses GitHub PUT .../pulls/{n}/merge for a mirrored or unsupported repository. An already merged pull request is a success. A failed job replaces the merge button with a link to the pull request.

Origin_Api signs a five-minute EdDSA app JWT with iss set to the app id and aud set to origin-apps, mints an installation token, and caches the token until shortly before expiry. The private key comes only from VIP configuration.

Register the Origin app

WordPress is the Cursor Origin app. Create the app in Origin, store the private key in VIP, then turn on factory notifications in WordPress.

Official reference: Origin API and Codebase settings. Manage apps at cursor.com/codebase/settings/apps.

  1. Create the Origin app

    Open codebase app settings. Create an internal app for Platform Factory. Copy the app ID (app_…).

  2. Generate a signing key

    Create a PKCS#8 Ed25519 pair locally. Upload only the public key. Keep the private key in a secrets manager. If Origin shows a separate signing-key ID, copy it; otherwise the app ID is the JWT kid.

  3. Set the webhook and events

    Copy Origin webhook URL from Settings → PRC Slack on the target WordPress site. Register that HTTPS URL on the app. Subscribe to pull_request.created, pull_request.reviewer.added, pull_request.reviewer.rerequested, pull_request.review.submitted, and pull_request.merged.

  4. Install the app

    Install the app on the Pew Research codebase. Approve repository:pull_requests:read and repository:contents:write for the platform repository. Copy the installation ID (i_…).

  5. Store VIP constants

    Create Platform Secrets items for the four PRC_PLATFORM_ORIGIN_* names. Tag the target VIP environments. Sync with npm run sync:vip-env-vars -- production --dry-run, then apply. Constants load on the next deploy.

  6. Configure Slack, then enable

    Invite the Slack bot to the private engineering channel. Store the C… channel ID, fill the roster, and keep bot logins. Confirm Settings shows Origin API credentials as Configured. Then turn on Enable Platform Factory notifications.

Signing key

Create the pair with OpenSSL:

openssl genpkey -algorithm ED25519 -out origin-app-private.pem
openssl pkey -in origin-app-private.pem -pubout -out origin-app-public.pem

Paste the public PEM (-----BEGIN PUBLIC KEY-----) when Origin asks for a signing key. WordPress signs app JWTs with the matching private key. Lifetime is about five minutes. Claims use iss = app ID and aud = origin-apps.

If a VIP environment variable cannot store raw newlines, store the PEM with literal \n sequences. PHP expands those before it reads the key.

Installation and scopes

Install from the app settings page, or send a workspace admin to:

https://cursor.com/codebase/apps/install?client_id=APP_ID&scope=repository:pull_requests:read%20repository:contents:write

repository:metadata:read is added automatically. repository:pull_requests:read lets WordPress load labels and the original-author line. repository:contents:write lets Origin merge a native pull request. Mirrored repositories still use the GitHub squash fallback and PRC_PLATFORM_GITHUB_TOKEN.

After approval, copy the installation ID (i_…). Partner-initiated installs put that ID in the sub claim of the installation_receipt JWT. An install from the settings UI also shows the ID on the installation record.

Select the platform repository. Do not grant every repository unless that is an explicit ops decision.

VIP constants

Reference

Origin VIP constants

WordPress reads these from vip_get_env_var(). Site options never store the private key.
4 fields
PRC_PLATFORM_ORIGIN_APP_ID string required
Origin app ID (app_…). Used as JWT iss.
PRC_PLATFORM_ORIGIN_APP_KEY_ID string
Signing-key ID for JWT kid. Leave empty to use the app ID.
PRC_PLATFORM_ORIGIN_APP_PRIVATE_KEY string required
PKCS#8 Ed25519 private PEM. Never commit this value.
PRC_PLATFORM_ORIGIN_INSTALLATION_ID string required
Installation ID (i_…) used to mint oit_ tokens.

Create one API Credential item per constant in the Platform Secrets vault. Title each item with the constant name. Tag production (and alpha, beta, or canary only when that host should call Origin). Preview, then apply:

npm run sync:vip-env-vars -- production --dry-run
npm run sync:vip-env-vars -- production

Remote env var changes take effect on the next deploy. After deploy, Settings → PRC Slack should show Origin API credentials: Configured.

Webhook signature checks use Origin's public JWKS, not this private key. The key is required to fetch pull request details and to merge from Slack.

Slack settings after credentials

  1. Invite the existing PRC Slack bot to the private engineering channel.
  2. Copy the channel ID (C…) into Engineering channel ID.
  3. Keep Trunk branch as trunk unless Origin uses another default branch.
  4. Keep bot logins such as github-actions[bot] and prc-github-actions[bot].
  5. Add one login = email or login = U… line per engineer who should receive DMs when Origin has no email.
  6. Turn on Enable Platform Factory notifications last.

On hosted staging, also turn on Enable publish notices. Factory messages use the same notice gate. Local PHP never posts them.

Verify

  1. Confirm Settings shows the production webhook URL and Configured credentials.
  2. Use Origin Ping Webhook (POST /v1/origin/app/webhook/pings) with an app JWT. WordPress should return HTTP 200 { "ok": true } and queue no factory work for ping.
  3. Open a test pull request. The author should receive a DM. A trunk merge should also post to the engineering channel.
  4. Open a non-draft backport. The original author should see a merge button. A failed merge becomes a GitHub pull request link.

If DMs are missing, check the roster and bot-login list. If Settings still says Not configured, the deploy has not loaded the four VIP constants.

Announcement meta

Meta keyValuePolicy
_prc_slack_announcement{ channel, ts }Last-write-wins on successful anchored publish

When Slack returns ok: true but omits a usable ts, delivery still counts as success (Posted_Message with is_anchored() === false) and the announcement is not stored. Retrying would duplicate a delivered message.

Posted_Message::anchor() builds the same type for Nexus thread replies.

First-day stats follow-up

Twenty-four hours after an anchored publish notice, Action Scheduler runs prc_slack_first_day_stats. The job replies in the same Slack thread with Parse.ly views, visitors, average engaged time, and the top 10 referrers for the post's first 24 hours (post_date_gmt through plus one day).

Skip:

  • Email CPTs (prc_email_campaign, prc_email_txn) — campaigns get a Mailchimp-sent thread reply, then first-day Mailchimp stats 24 hours later (owned by email-builder)
  • Forks and chart
  • Environments where notices are not permitted — the job is only scheduled after a successful notice, and the handler uses the same Intent::Notice gate
  • Unanchored Slack delivery — no ts, so no thread
  • Missing Parse.ly credentials — log and stop (no retry loop)

Source: includes/class-first-day-stats.php. Ops can run wp prc-slack first-day-stats <post_id> on production without waiting 24 hours.

Meta keyValuePolicy
_prc_slack_first_day_stats_tsAnnouncement ts already reportedIdempotent per announcement

Helpers for other plugins

Defined in includes/utils.php:

FunctionUse
PRC\Platform\Slack\send_notification( $args )Ad-hoc channel post; optional reply_to; optional to_user
PRC\Platform\Slack\notify_in_post_thread( $post_id, $args )Reply under the post's recorded announcement
PRC\Platform\Slack\channel_for_post( $post )Resolve default channel

Return type of send_notification() is Posted_Message|WP_Error|null. Treat a Posted_Message (including unanchored) as success. to_user is additive; existing callers keep passing channel.

PRC Nexus

One WordPress agent slug: prc-nexus. Other plugins register a PRC\Platform\Slack\Nexus\System on prc_nexus_systems. The registry compiles:

  1. wp_register_agent( 'prc-nexus' ) config (prompt, tools, action policy)
  2. Slack app manifest fragments (agent_view, suggested prompts, events, scopes)
  3. Per-turn tool_policy for the Slack-mapped WordPress user (prc_slack_user_id)

Turns run as prc_wp_bot. Ability permission_callbacks cannot see the editor. Narrowing tools on the turn is the authorization point. Users without a Slack member mapping get an empty tool list.

Registered systems:

  • Trending News (prc-agents-trending-news) — workflow and analysis abilities. No Slack client.
  • Social Builder (prc-social-builder) — prc-social-builder/generate-social-copy only. Does not create social-package posts. The Social Builder AI feature must be enabled so the ability exists.

Inbound sessions

Events API payloads parse into a closed set: Turn_Requested, Session_Stopped, Session_Renamed, Surface_Opened, Ignored, plus Url_Verification.

A turn job (prc_slack_nexus_turn) sets agents.sessions.setStatus to processing, then active. Slack does not return the session to active on its own. The first turn with an empty stored title also calls agents.sessions.rename with the first line of the editor's message.

Turns prefer Action Scheduler. When as_enqueue_async_action() does not return a numeric id greater than 0, Nexus_Channel falls through to WP_Agent_Channel::receive() (WP-Cron or a synchronous handle()). That keeps Slack retries from looking like duplicates while still producing a reply. Turn_Gate::release() still runs if receive() throws.

A failed agents/chat turn still returns a well-formed array with an empty reply. Nexus_Channel reads metadata.agents_api.run_outcome and posts that failure to the thread instead of a generic “could not generate a response”.

Stop (agent_session_stopped) bumps the session generation, cancels agents/cancel-chat-run, unschedules a queued Action Scheduler job, and discards a late reply.

Committed manifests: plugins/prc-slack/manifest.staging.yml (alpha.pewresearch.org/pewresearch-org) and manifest.production.yml (www.pewresearch.org). Release workflows update the live Slack apps from those files (see RELEASING.md). wp prc-slack manifest prints the compiled JSON with home_url(). A unit test asserts the YAML still lists agent_view, agent_session_stopped, and those request URLs.

Stub REST route

Bot::register_endpoint() defines GET /wp-json/prc-api/v3/slackbot/interact for future interactive callbacks. Source marks it @TODO: Wire this into the loader via rest_api_init. The route is not active by default. Do not depend on it.

Dependencies

DependencyTypeNotes
prc-post-publish-pipelineRequired pluginProvides prc_platform_async_on_publish
agents-apiSoftNexus chat; notices still send without it
prc-staff-bylinesOptionalAppends byline strings when active

Was this helpful?