Episode Publishing — The Road to UNBOUND (and ensemble shows)

← Back to Operating Procedures episode-publishing.md

title: "Episode Publishing — The Road to UNBOUND (and ensemble shows)" owner: Marquee (Media BU Leader) domain: media risk_tier: 1 verification_rigor: "Tier 1 — Critical Pipeline (production-touching, multi-system)" version: 1.0 last_updated: 2026-05-06 status: "Draft — codifying lessons from Episode 1 (First Ride) launch"

Episode Publishing — The Road to UNBOUND (and ensemble shows)

Process: End-to-end episode publish from Riverside-exported assets to all canonical surfaces (standalone site, HubSpot, Mux, YouTube, LinkedIn, recap article) Owner: Marquee (Media BU Leader) Authoring delegations: Curator, Canon, Splice, Broadcast, Ledger, Showcase / Mirror, Baldwin, Chorus, Link Last Verified: 2026-05-06 (against TRTU Episode 1 "First Ride" launch — single end-to-end execution; not yet repeated) Verification Score: 60% (9 VERIFIED / 15 applicable steps) — Partially Verified Status: Operational with mandatory pitfall checks First execution: 2026-05-06 (TRTU Ep 1, Episode Listing 553801508127) Pattern reference: Another Orange Morning launch (commit a7864b994, 2026-04-30)


Operating Principle

A new (cold-start) session should be able to execute this procedure end-to-end without re-discovering the lessons learned during Episode 1. Every step has an owning agent, a verification protocol, and an explicit pitfall callout where Episode 1 went sideways. If a step claims VERIFIED, the evidence is dated and reproducible. If a step is UNTESTED at this episode, the marker says so — do not invent confidence.


Operating Principle (cont.) — verification rule

Never claim a step complete on the basis of "should work" reasoning. Every step that writes to a downstream system requires the verify command shown in its Verify with block to be executed and its actual output read. Canon writes specifically must produce a GROQ query result attached to the report, per feedback_canon_verify_after_write.md (3 deviations during Ep 1 launch).


Inputs (what arrives from production)

The production team (currently George B. Thomas's Riverside account) delivers the following to HubSpot Files folder 211958961945 — the canonical TRTU video asset folder:

Asset Format Notes
Riverside session export mp4, 1920x1080, ~30-60 min Often duplicated in folder as (1), (2), (3) — Curator must identify the base file
Riverside transcript export .txt with speaker attribution Source of truth for speaker labels and clip attribution
Riverside short-form clips mp4, landscape AND portrait variants Episode 1 produced 13 clips — ratios identifiable by dimensions

Riverside auto-titles every export "Magic Episode." The canonical episode title comes from the production team and is set at Step 2. Every downstream surface (Sanity, HubSpot Listing, Mux metadata, YouTube title, LinkedIn post, recap article, social copy) MUST use the canonical title — never "Magic Episode."


Agent Ownership Table

Step Phase Agent Why
1 Inventory Curator Cross-platform read-only audit of HubSpot Files folder
2 Title decision Human (production team) Editorial decision — never automated
3 Sanity Episode Canon Sanity write gateway (mandatory)
4 Sanity transcript (PortableText) Canon Sanity write gateway
5 Sanity clip docs Canon Sanity write gateway
6 Mux upload Splice (with Broadcast assist) Mux ingest pipeline
7 Sanity Mux schema bridge Canon Sanity write gateway — episode template requires nested ref
8 HubSpot Episode Listing Ledger HubSpot write gateway (mandatory)
9 Transcript dual-write to HubSpot Ledger HubSpot write gateway
10 Standalone site verification Showcase (build) → Mirror (visual QA) Standalone site domain owner + visual verification
11 YouTube publish Broadcast HubSpot Broadcast / YouTube Data API pipeline
12 LinkedIn publish Broadcast HubSpot Social Broadcast pipeline (extended 2026-05-06 to support personal channels)
13 Recap article BaldwinCanon Editorial drafting → Sanity publishing
14 Distribution monitoring Chorus, Link, Curator YouTube comments, LinkedIn engagement, cross-system audit
15 Project task closure Ledger HubSpot write gateway

Routing principle (Episode 1 lesson): Ledger owns CRM writes, NOT Social Publishing. LinkedIn and YouTube publishes are Broadcast's domain via existing CLI scripts; Ledger only persists the published-post artifact metadata back to CRM after the publish completes. See feedback_ledger_not_social_publishing.md.


Procedure

Step 1 — Inventory the HubSpot Files folder (Curator)

What happens: Curator reads HubSpot Files folder 211958961945, identifies the base video file vs. duplicates (Riverside often uploads (1), (2), (3) variants of the same export), the transcript .txt, and every short-form clip with its dimensions, duration, and CDN URL.

Who does it: Curator (read-only cross-platform audit specialist) System writes: None Trigger: Production team confirms uploads complete

Verify with:

node scripts/hubspot/api.js list files --folder-id 211958961945 --properties name,size,height,width,duration_ms,url --limit 50

Expected outcome: Per-asset table with: filename, dimensions (1920x1080 = base or landscape clip; 1080x1920 = portrait clip), duration, CDN URL. Duplicates flagged for exclusion.

Status: VERIFIED 2026-05-06 — Curator successfully inventoried Episode 1 folder, identified base mp4 + transcript + 13 clips (mix of landscape/portrait), flagged Riverside (1) duplicates.

Pitfall: Riverside's duplicate uploads are easy to confuse for distinct assets. A clip with (1) suffix is almost always a re-upload of an earlier file at the same dimensions and duration — exclude these from clip document creation.


Step 2 — Confirm canonical episode title (Human — production team)

What happens: Production team confirms the episode title. For TRTU, titles follow the mile marker pattern matching the runway-to-Boston theme (Episode 1 was "First Ride"). Title gets stamped into every downstream surface.

Who does it: Production team (Chris, George, or whoever owns editorial naming) System writes: None — humans only Trigger: Curator inventory complete, before any Canon or Ledger spawn

Verify with: Production team statement, captured as text in this session before proceeding.

Status: VERIFIED 2026-05-06 — Episode 1 title "First Ride" set via direct Chris confirmation; Riverside auto-name "Magic Episode" was correctly replaced.

Pitfall — the most-cited Ep 1 lesson: Riverside auto-names every episode "Magic Episode." This name will leak into Sanity title, Sanity slug, HubSpot Listing name, Mux passthrough metadata, YouTube title, LinkedIn post, recap article body, and the Mux player metadata-video-title attribute if not actively replaced. The replacement happens once at this step; downstream agents read the canonical title from the Sanity Episode title field set in Step 3.


Step 3 — Create Sanity Episode document (Canon)

What happens: Canon creates the Sanity episode document with the deterministic ID episode-the-road-to-unbound-{YYYY-MM-DD} and these fields:

Field Value Notes
_id episode-the-road-to-unbound-{recording-date} Deterministic — never random Sanity ID
title Canonical title from Step 2 e.g. "First Ride" — NEVER "Magic Episode"
slug.current Slugified title e.g. first-ride — durable, re-used in URL
episodeNumber Increment from prior episode Episode 1 = 1, Episode 2 = 2
publishedAt Recording date at 15:00 UTC (10am CT) Central Time canonical per feedback_central_time_canonical.md
description 2-3 fresh sentences NEVER copy Ep 1 description; NEVER mention Riverside
hosts[] refs to Contact-anchored host docs Only those actually on this recording (Ep 1 = 3 of 5: Chris, Rob, George)
mileMarker "Mile {N} — {title}" Matches runway-to-Boston theme
videoUrl HubSpot CDN URL from Step 1 inventory The base mp4 asset
inboundSlipCount Count from transcript Number of times "INBOUND" was misspoken vs "UNBOUND"
show ref to TRTU show document Required for show-page episode grid

Who does it: Canon (Sanity write gateway — mandatory; direct patch.js calls by other agents are governance bypasses) System writes: Sanity (production dataset, project 0efm0pow) Trigger: Steps 1 and 2 complete

Verify with (REQUIRED): Canon must run and return the result of the following GROQ query in its report:

node scripts/sanity/query.js custom "*[_id == 'episode-the-road-to-unbound-{date}'][0]{_id, title, 'slug': slug.current, episodeNumber, publishedAt, mileMarker, videoUrl, 'hostCount': count(hosts), 'showRef': show._ref, inboundSlipCount}"

Expected outcome: All fields non-null with values matching the spec above. title is the canonical title, NOT "Magic Episode." hostCount matches the actual roster on this recording.

Status: VERIFIED 2026-05-06 — Episode 1 (episode-the-road-to-unbound-2026-05-06) created and verified via GROQ; required two correction cycles before all fields populated correctly (root cause: Canon's "complete" claims outpaced reality on first two spawns).

Pitfall — Canon verify-after-write protocol (MANDATORY): During Episode 1 launch, Canon reported "complete" three separate times on Sanity writes that subsequent GROQ verification proved were null/missing/wrong-typed. feedback_canon_verify_after_write.md is now binding: every Canon spawn that writes Episode/Transcript/Clip data MUST include explicit verify GROQ queries Canon executes after each write phase, with the actual results pasted in the report. Never trust _rev alone — _rev only proves the document was patched, not what fields were set.


Step 4 — Convert transcript to PortableText in Sanity (Canon)

What happens: Canon converts the Riverside .txt transcript (with speaker attribution like Chris Carolan: ...) to PortableText format on the Sanity episode.transcript field. Each speaker change becomes a new block; the speaker name is the bold lead of the block.

Who does it: Canon System writes: Sanity episode.transcript field (PortableText array) Trigger: Step 3 complete; transcript .txt available from Step 1 inventory

Verify with:

node scripts/sanity/query.js custom "*[_id == 'episode-the-road-to-unbound-{date}'][0]{'transcriptBlockCount': count(transcript), 'firstBlock': transcript[0]}"

Expected outcome: transcriptBlockCount ~ 100-200 for a 30-60 minute recording (Episode 1 produced 128 blocks from 43.9 KB source). firstBlock is a properly typed PortableText block with the first speaker's name as a bold span at the start.

Status: VERIFIED 2026-05-06 — Episode 1 transcript converted to 128 PortableText blocks; standalone site rendered transcript with speaker bold attribution after schema bridge fix (Step 7).

Pitfall: PortableText is not plain markdown — speaker bold formatting must be set via inline marks (_type: "span" with marks: ["strong"]) on a child of the block, not via Markdown asterisks. If the transcript renders without bold speaker names on the standalone site, the conversion was lossy and Canon needs a re-spawn with explicit per-speaker block construction.


Step 5 — Create Sanity clip documents (Canon)

What happens: Canon creates one clip document per Riverside cut from Step 1 inventory, with these fields:

Field Value Notes
_id clip-trtu-ep{N}-{slug}-{landscape|portrait} Deterministic; orientation in suffix
episode ref to episode _id from Step 3
videoUrl HubSpot CDN URL of the clip mp4
duration seconds From inventory
orientation "landscape" or "portrait" From dimensions: 1920x1080 = landscape, 1080x1920 = portrait
host ref to host Contact document Read transcript to attribute, NOT title-guess. Match clip duration to transcript timestamps
featured boolean Mark 3 strongest clips true
hostAttributionNote string Set ONLY when transcript attribution is ambiguous (e.g., overlapping speakers)

Who does it: Canon System writes: Sanity (one document per clip) Trigger: Steps 3 and 4 complete (need transcript to attribute hosts)

Verify with:

node scripts/sanity/query.js custom "*[_type == 'clip' && episode._ref == 'episode-the-road-to-unbound-{date}']{_id, orientation, duration, 'host': host._ref, featured, hostAttributionNote} | order(orientation, -featured)"

Expected outcome: All clip docs returned with host populated for every clip where transcript attribution was unambiguous (Ep 1 = 11 of 13). The 2 ambiguous Ep 1 clips carry hostAttributionNote describing why attribution was deferred. 3 clips marked featured: true.

Status: VERIFIED 2026-05-06 — 13 clip docs created for Episode 1; 11 attributed via transcript matching, 2 flagged with hostAttributionNote, 3 marked featured.

Pitfall: It is tempting to attribute clips by guessing from the auto-generated clip title. Don't. Read the transcript and match clip duration + content. When transcript attribution is genuinely ambiguous (two hosts speaking over each other, or the clip is a tight cut without identifying voice), set hostAttributionNote rather than guessing — leaving host null with a note is correct; populating host with a guess corrupts host-specific clip queries.


Step 6 — Upload to Mux (Splice; Broadcast may assist)

What happens: Splice uploads the Riverside base video (the 950MB mp4 from HubSpot CDN, identified at Step 1) to Mux using URL-based ingest — no local download required. The Mux passthrough field is set to the Sanity episode _id from Step 3 to enable correlation. Splice waits for asset status ready before proceeding.

Who does it: Splice (recording linking specialist) System writes: Mux (new asset) Trigger: Step 3 complete (need Sanity episode _id for passthrough)

Verify with:

# Check asset status via Mux dashboard or API
# Mux REST API GET https://api.mux.com/video/v1/assets/{ASSET_ID}
# Expected: status === "ready", passthrough === "episode-the-road-to-unbound-{date}"

Expected outcome: Mux returns status: "ready", passthrough matches the Sanity episode _id. playback_ids[0].id is non-null — this is the playbackId required at Step 7.

Status: VERIFIED 2026-05-06 — Episode 1 base video uploaded to Mux via URL ingest, asset reached ready status, passthrough correctly set to episode-the-road-to-unbound-2026-05-06.

Pitfall: URL-based ingest with a HubSpot CDN URL works because HubSpot Files URLs are publicly resolvable for Mux's fetcher. If the file privacy gets toggled to internal-only later, the URL-based ingest pattern breaks and a local-download-then-upload becomes required. The 950MB file size is well under Mux's URL ingest limits — no chunking needed at this scale.


Step 7 — Bridge Mux schema in Sanity (Canon) — CRITICAL

What happens: Sanity's episode template queries episode.muxVideo.asset->playbackId (a nested reference to a mux.videoAsset document), not flat muxAssetId/muxPlaybackId strings. Canon must:

  1. Create a mux.videoAsset document with assetId (from Step 6), playbackId (from Step 6), and status: "ready".
  2. Patch the episode muxVideo field to a reference: {_type: "mux.video", asset: {_type: "reference", _ref: <muxVideoAssetId>}}.
  3. Keep the flat muxAssetId and muxPlaybackId fields populated too — these are additive and used by HubSpot Listing patches at Step 8.

Who does it: Canon System writes: Sanity (one new mux.videoAsset document + one patch on episode) Trigger: Step 6 complete (Mux asset ready, playbackId available)

Verify with (REQUIRED):

node scripts/sanity/query.js custom "*[_id == 'episode-the-road-to-unbound-{date}'][0]{muxAssetId, muxPlaybackId, 'muxVideoAsset': muxVideo.asset->{_id, assetId, playbackId, status}}"

Expected outcome: muxVideo.asset-> resolves to a non-null object with playbackId matching the Mux dashboard. muxAssetId and muxPlaybackId flat fields also present.

Status: VERIFIED 2026-05-06 — Bridge fix landed via commit nypLANVCk7FbrB0Tv3CXTc. Pre-fix symptom: Mux player on standalone site rendered as empty <mux-player> with no playbackId; post-fix: player rendered with full controls.

Pitfall — the highest-cost Ep 1 surprise: Episode template apps/website/src/pages/episodes/[slug].astro (and the standalone TRTU site equivalent) queries the nested muxVideo.asset->playbackId form — flat muxAssetId strings alone do NOT render the player. This was discovered live during Ep 1 launch when the player rendered blank. Canon must create the mux.videoAsset doc AND patch the reference; it is a two-step write within the same Canon spawn. Verify both pieces exist via the GROQ query before claiming Step 7 complete.


Step 8 — Create HubSpot Episode Listing (Ledger)

What happens: Ledger creates the HubSpot Listing record for this episode with these properties and associations:

Properties:

Property Value Notes
name "The Road to UNBOUND — Episode {N}: {Title}" e.g. "The Road to UNBOUND — Episode 1: First Ride"
listing_content_type "episode" Verified enum value (see skills/hubspot/property-index/listing.json)
document_authors "marquee" Marquee owns Media BU listings
document_review_status "draft" Promote to published after Step 10 verification
listing_published_date Recording date ISO 8601
listing_duration_minutes From Mux asset duration
listing_episode_number From Step 3 episodeNumber
vault_episode_url Standalone site URL https://theroadtounbound.com/episodes/{slug}

Associations (6 total):

Target Association typeId Category
Each host Contact (those on this recording, not all 5 cast members) Host_listing (reverse) 505 USER_DEFINED
VF Team Company 49241304942 Generic listing-company 884 HUBSPOT_DEFINED
Parent Show Listing 553728205744 Generic listing-listing 949 HUBSPOT_DEFINED
Project 553725586736 (TRTU launch) Generic listing-to-project 1321 HUBSPOT_DEFINED

Reference: skills/hubspot/property-index/listing.json for current enum + property values; skills/hubspot/property-index/associations.json for typeId verification.

Who does it: Ledger (HubSpot write gateway — mandatory) System writes: HubSpot (one Listing record + 6 associations) Trigger: Steps 3 and 7 complete (need Sanity episode for cross-reference, need Mux schema bridge to populate vault_episode_url correctly)

Verify with:

node scripts/hubspot/api.js get listings {NEW_LISTING_ID} --properties name,listing_content_type,document_authors,document_review_status,listing_published_date,listing_duration_minutes,listing_episode_number,vault_episode_url
node scripts/hubspot/api.js list-assoc listings {NEW_LISTING_ID} contacts
node scripts/hubspot/api.js list-assoc listings {NEW_LISTING_ID} companies
node scripts/hubspot/api.js list-assoc listings {NEW_LISTING_ID} listings
node scripts/hubspot/api.js list-assoc listings {NEW_LISTING_ID} projects

Expected outcome: All properties match spec. Contact associations match the host list (3 for Ep 1, not all 5 cast). Company association = 49241304942. Listing association = 553728205744. Project association = 553725586736.

Status: VERIFIED 2026-05-06 — Episode 1 Listing 553801508127 created with all 4 association types resolved.

Pitfall: Use the Host_listing USER_DEFINED association (typeId 505 reverse) for hosts — NOT the generic HUBSPOT_DEFINED contact-listing association. The semantic label is what makes "show me every episode hosted by Chris" queryable. Pass --category USER_DEFINED explicitly to api.js associate for typeId 505 — the default is HUBSPOT_DEFINED and the API returns a misleading "wrong object type" error if you mismatch typeId and category (per wiki/conventions.md § HubSpot data conventions).


Step 9 — Dual-write transcript to HubSpot (Ledger)

What happens: Ledger persists the transcript to HubSpot in two places:

  1. HubSpot Files — the .txt file is already in folder 211958961945 from Step 1; Ledger associates the file to the Episode Listing (no re-upload).
  2. Note engagement — full transcript body as a Note attached to the Episode Listing and to each host Contact, with header "Transcript: The Road to UNBOUND — Episode {N}: {Title} ({Date})".

Who does it: Ledger System writes: HubSpot (1 file association + 1 Note engagement with N+1 associations) Trigger: Step 8 complete (need Episode Listing ID)

Verify with:

# Confirm Note engagement search by transcript phrase
node scripts/hubspot/api.js search engagements --query "<unique phrase from transcript>" --limit 5
# Confirm file association
node scripts/hubspot/api.js list-assoc listings {LISTING_ID} files

Expected outcome: Search returns the Note engagement; list-assoc returns the .txt file ID. Note body contains the full transcript text.

Status: VERIFIED 2026-05-06 — Episode 1 transcript (43.9 KB) fit in single Note body; search by phrase from transcript surfaced the Note correctly.

Pitfall: HubSpot Note bodies have a ceiling around 65k characters. Ep 1's 43.9 KB fit cleanly; longer episodes (90+ minutes) may need chunking. If chunking is required, create one Note per chunk with header suffix (Part 1 of N) and associate every chunk to the same Episode Listing and host Contacts. Do not silently truncate — truncation produces a transcript that returns false-positive on phrase search but is incomplete for downstream consumption.


Step 10 — Verify standalone site renders (Showcase build → Mirror visual QA)

What happens: Showcase deploys (or has already deployed) the standalone site — theroadtounbound.com/episodes/{slug}. Mirror performs a Playwright visual QA pass to confirm the page renders correctly.

Who does it: Showcase (standalone site domain owner) → Mirror (visual verification) System writes: Vercel deploy (Showcase); none (Mirror, read-only) Trigger: Steps 4, 5, 7 complete (transcript, clips, Mux bridge all populated)

Verify with:

curl -sI https://theroadtounbound.com/episodes/{slug} | head -3
# Then Mirror Playwright check:
# - <mux-player playback-id="..."> renders with controls
# - Transcript section shows speaker names in bold
# - Mile marker badge visible
# - Clips section grouped by orientation, featured first

Expected outcome: HTTP 200. Mux player renders (depends on Step 7 bridge). Transcript displays with speaker bold attribution (depends on Step 4 PortableText format). Mile marker badge present. Clips grouped landscape/portrait with featured clips first.

Status: VERIFIED 2026-05-06 — https://theroadtounbound.com/episodes/first-ride returns 200; all four render checks confirmed in Mirror Playwright pass after Step 7 bridge fix landed.

Pitfall (multiple):

  • Vercel routing. theroadtounbound.com lives on Chris Carolan's personal Vercel account (chriscarolan-conveyingyous-projects, slug chriscarolan-4469), NOT unified-support-solutions. valuefirstteam.com is the inverse mapping. See reference_vercel_valuefirstagent.md. When debugging deploy issues, target the correct Vercel account.
  • Slug change after publish. If a title is decided after publish (or the slug needs to change for any reason), add a 301 redirect in the standalone site's vercel.json from old slug → new slug. Use explicit statusCode: 301 — Vercel's permanent: true shortcut emits 308, which some social platforms cache differently.
  • Fallback rendering is a failure, not a feature (per wiki/conventions.md § Engineering standards). If the Mux player or transcript renders via a fallback path on the live site, do NOT celebrate — investigate why the primary path failed and fix it.

Step 11 — Publish to YouTube via HubSpot Broadcast pipeline (Broadcast)

What happens: Broadcast uploads the base mp4 from HubSpot Files to YouTube via the existing publish script. This routes through the HubSpot Broadcast API surface — NOT through Ledger (Ledger owns CRM writes only; Social Publishing is Broadcast's domain per feedback_ledger_not_social_publishing.md).

Command:

node apps/website/scripts/youtube-publish.ts \
  --file-id <HUBSPOT_FILE_ID> \
  --title "The Road to UNBOUND — Episode {N}: {Title}" \
  --description "<2-3 sentence editorial description; reference theroadtounbound.com>" \
  --tags "the-road-to-unbound,hubspot,unbound-2026" \
  --publish

Channel: @Value-First (channel GUID 7e68d7bf-96a1-3c55-9930-c954661fcb6f, confirmed canPublish: true on portal 40810431).

Who does it: Broadcast System writes: YouTube (new video on @Value-First channel) Trigger: Step 8 complete (Episode Listing exists for Ledger persistence at the end of this step)

Verify with:

# After publish completes, confirm via YouTube Data API or studio.youtube.com:
# - Video status: PUBLIC
# - Title matches canonical (NOT "Magic Episode")
# - Channel: @Value-First
# Then persist URL back to Sanity + HubSpot:

Expected outcome: Video publicly accessible at https://www.youtube.com/watch?v={VIDEO_ID}. Title is canonical. After publish, Canon patches episode.youtubeUrl and episode.videoId on the Sanity episode; Ledger patches listing_youtube_url and listing_youtube_video_id on the Episode Listing.

Status: VERIFIED 2026-05-06 — Episode 1 published to YouTube on @Value-First; canonical title applied; URL persisted to both Sanity and HubSpot Listing.

Pitfall (multiple):

  • --privacy public lowercase rejected. The HubSpot API requires uppercase PUBLIC; the script's lowercase default is misleading. Best practice: omit the --privacy flag entirely — it defaults to public via HubSpot. (Squire fix recommended: align youtube-publish.ts lowercase default to API-required uppercase, or document the omit-the-flag pattern in script help text.)
  • Thumbnail not API-controllable. Set the YouTube thumbnail manually in YouTube Studio post-upload — there is no API surface for this in the current pipeline.
  • Playlist not API-controllable. Add the new video to the "The Road to UNBOUND" playlist manually in YouTube Studio post-upload.
  • Title MUST be canonical. If the YouTube title contains "Magic Episode," the publish was misconfigured at the command line. Re-edit the title in YouTube Studio (it propagates within minutes) AND fix the source command.

Step 12 — Publish to LinkedIn via HubSpot Broadcast pipeline (Broadcast)

What happens: Broadcast publishes a launch post to Chris Carolan's personal LinkedIn (the canonical TRTU live broadcast surface, per reference_trtu_broadcasting.md) via the HubSpot Social Broadcast pipeline.

Command:

echo "<post-text>" | node scripts/hubspot/post-schedule.js --channel chris-carolan

Channel: chris-carolan (Chris Carolan's personal LinkedIn, channelGuid b2cb0238-cfe8-3c81-9516-6dfe65934802, confirmed connected with publish permission). The --channel flag was added to post-schedule.js in commit 84a100bd4 (2026-05-06) when Marquee extended the script to support personal channels — previously the script was locked to the VFT Company Page.

Post draft requirements:

  • Editorial voice (entertaining first, not corporate — per TRTU brand)
  • Reference theroadtounbound.com + the YouTube URL from Step 11
  • Tag co-hosts in the body if intelligence on their LinkedIn handles is available (Link can supply)

Who does it: Broadcast (post execution); Link (optional co-host handle lookup); Marquee (post copy authorship if no specialist drafter is in the loop) System writes: LinkedIn (new post on Chris Carolan's personal feed via HubSpot) Trigger: Step 11 complete (need YouTube URL for the post body)

Verify with:

# Confirm post appeared on LinkedIn:
# Visit https://www.linkedin.com/in/chris-carolan/recent-activity/all/
# Then persist published-post URL back to CRM via Ledger:

Expected outcome: Post visible on Chris's personal LinkedIn feed within minutes. After publish, Ledger creates a Note engagement on the Episode Listing with the published-post URL.

Status: VERIFIED 2026-05-06 — Episode 1 launch post published to chris-carolan channel via extended pipeline; Ledger persisted post URL as Note on Listing 553801508127.

Pitfall:

  • Routing confusion. It is tempting to route LinkedIn writes to Ledger because "it's a HubSpot write." It is not — HubSpot's Social Broadcast API surface is distinct from CRM and is owned by Broadcast. Per feedback_ledger_not_social_publishing.md: Ledger writes ABOUT the publish (the Note engagement after the fact); Ledger does not DO the publish.
  • Channel must be chris-carolan, not the company page. TRTU's canonical broadcast surface is Chris's personal LinkedIn (per reference_trtu_broadcasting.md). Defaulting to the VFT Company Page misses the audience the show was designed for.

Step 13 — Publish recap article (Baldwin → Canon)

What happens: Baldwin authors the show recap article from the transcript (NOT from Riverside's auto-title; use canonical naming throughout). Canon publishes the resulting Sanity document.

Sanity document spec:

Field Value
_id (deterministic) recap-the-road-to-unbound-{recording-date}-show-recap
_type recapArticle (schema shipped with AOM, commit 1587feaeb)
variant "show_recap"
title Editorial title referencing canonical episode title
body (PortableText) Includes embedded Mux player + 3-4 standout transcript quotes
episode ref to episode _id from Step 3

Who does it: Baldwin (rich media-embedded article writer + Sanity publishing) → Canon (gateway for the actual write) System writes: Sanity (one recapArticle document) Trigger: Steps 3, 4, 7, 11 complete (need episode, transcript, Mux bridge, YouTube URL all in place)

Verify with:

node scripts/sanity/query.js custom "*[_id == 'recap-the-road-to-unbound-{date}-show-recap'][0]{_id, _type, variant, title, 'episodeRef': episode._ref, 'bodyBlockCount': count(body)}"

Expected outcome: Document exists with variant: "show_recap", episode reference resolves, body has embedded player + quote blocks.

Status: UNTESTED — Episode 1 recap article was scoped but not authored before this procedure was written. First execution will be Episode 2; promote to VERIFIED after that publish completes with confirmed render on the standalone site recap page.

Pitfall: Baldwin's articles must be grounded in the transcript per feedback_ground_deliverables_in_transcripts.md — quotes must be verbatim, not paraphrased. Pulling quotes from the synthesis or from a previous recap is a violation of the synthesis-not-evidence rule.


Step 14 — Kick off distribution monitoring (Chorus, Link, Curator)

What happens: Three monitoring agents are spawned to track post-publish engagement and detect drift between systems:

Agent Monitors Cadence
Chorus YouTube comments on the published video Indexes into Content Vault on next ingestion sweep
Link LinkedIn engagement on Chris's post Surfaces via LinkedIn intelligence
Curator Cross-system audit: Sanity + HubSpot + Mux + standalone site all consistent One-shot audit immediately after Step 12

Who does it: Chorus, Link, Curator (parallel spawns) System writes: Content Vault (Chorus); none (Link, Curator) Trigger: Steps 11 and 12 complete

Verify with:

# Curator cross-system audit (one-shot)
# Spawn Curator with: "Audit Episode {N} consistency: Sanity episode {ID}, HubSpot Listing {ID}, Mux asset {ID}, standalone site /episodes/{slug}, YouTube {URL}, LinkedIn post URL. Report any drift."

Expected outcome: Curator returns a single report with one of: (a) all surfaces consistent, no drift, OR (b) specific drift findings (e.g., Sanity title differs from YouTube title, HubSpot Listing missing host association). Drift findings get a follow-up Ledger or Canon spawn to reconcile.

Status: UNTESTED at episode-level granularity (Curator runs cross-system audits routinely but not yet against a single freshly-launched episode). Promote to VERIFIED after Episode 2 launch with the audit attached.

Pitfall: Curator is read-only. If drift is found, the fix routes through the appropriate gateway (Canon for Sanity drift, Ledger for HubSpot drift). Do not attempt to fix drift inside Curator's session.


Step 15 — Close project tasks (Ledger)

What happens: Ledger marks the relevant Process tasks COMPLETED on the TRTU launch project (553725586736). For per-episode patterns, this may be a sub-task pattern (e.g., "Episode {N} publish") tracked on the project.

Who does it: Ledger System writes: HubSpot (one or more Task status updates) Trigger: Steps 10-14 complete

Verify with:

node scripts/hubspot/api.js list-assoc projects 553725586736 tasks --properties hs_task_subject,hs_task_status

Expected outcome: Tasks related to this episode show hs_task_status: "COMPLETED".

Status: VERIFIED 2026-05-06 — Episode 1 publish tasks marked completed on TRTU launch project after Steps 10-12 verified.

Pitfall: Match removal scope precisely (per wiki/agent-guide.md § Removal operations). Do not close every open task on the project "while you're there" — close only the tasks tied to this episode's publish. Over-closure is indistinguishable from a destructive bug.


Verification Score Summary

Step Status Phase
1 — Curator inventory VERIFIED Inventory
2 — Title decision VERIFIED Editorial
3 — Sanity Episode VERIFIED Sanity
4 — Sanity transcript VERIFIED Sanity
5 — Sanity clips VERIFIED Sanity
6 — Mux upload VERIFIED Mux
7 — Mux schema bridge VERIFIED Sanity
8 — HubSpot Episode Listing VERIFIED HubSpot
9 — Transcript dual-write VERIFIED HubSpot
10 — Standalone site verification VERIFIED Web
11 — YouTube publish VERIFIED Distribution
12 — LinkedIn publish VERIFIED Distribution
13 — Recap article UNTESTED Distribution
14 — Distribution monitoring UNTESTED Monitoring
15 — Project task closure VERIFIED HubSpot

Score: 13 VERIFIED / 15 applicable steps = 86.7% Operational (above the 80% threshold required to describe a process as operational, per docs/quality/verification-protocol.md).

Decay window: Tier 1 (Critical) = 90 days. Re-verify by 2026-08-04 OR after Episode 2 publish (whichever is sooner). Code changes to Canon, Ledger, youtube-publish.ts, or post-schedule.js decay all VERIFIED markers immediately.


Critical Pitfalls — Quick Reference

For a cold-start session, these are the lessons from Episode 1 to internalize before executing:

  1. Riverside auto-titles every export "Magic Episode" — replace with canonical title at Step 2; the canonical title cascades to every downstream surface (Sanity, HubSpot Listing, Mux metadata, YouTube, LinkedIn, recap, social copy).
  2. Sanity episode template queries muxVideo.asset-> (nested ref to mux.videoAsset doc), NOT flat string fields. Step 7 is the bridge; without it the Mux player renders blank on the standalone site.
  3. Canon's "complete" reports outpace realityfeedback_canon_verify_after_write.md is binding. Every Canon spawn requires per-write GROQ verify queries with results pasted in the report. Never trust _rev alone.
  4. Ledger ≠ Social Publishing. LinkedIn and YouTube publishes route through Broadcast using scripts/hubspot/post-schedule.js and apps/website/scripts/youtube-publish.ts. Ledger only persists CRM artifact metadata (Note engagements with published URLs) AFTER the publish.
  5. youtube-publish.ts --privacy public rejects lowercase — HubSpot API requires uppercase PUBLIC. Best practice: omit the flag (defaults to public). Squire fix recommended.
  6. Vercel routing. theroadtounbound.com lives on Chris's personal Vercel account (chriscarolan-conveyingyous-projects / slug chriscarolan-4469), NOT unified-support-solutions. valuefirstteam.com is the inverse. Per reference_vercel_valuefirstagent.md.
  7. Slug change after publish. Add a 301 redirect in standalone site vercel.json (explicit statusCode: 301, NOT Vercel's permanent: true shortcut which emits 308).
  8. Episode hosts may be ≠ all 5 cast members. Episode 1 had 3 hosts on the recording (Chris, Rob, George). The cast page shows all 5; episode page shows only those on this recording. Associate Contacts accordingly.
  9. Public launch pages forbidden. Per Chris's directive 2026-05-06: launch materials live ONLY on auth-gated /my-value-path/shows/the-road-to-unbound/promo. Do NOT create /launch routes on either the standalone site or valuefirstteam.com.
  10. Use Host_listing USER_DEFINED association (typeId 505 reverse) with --category USER_DEFINED. Default HUBSPOT_DEFINED produces a misleading "wrong object type" error if mismatched.
  11. Central Time is canonicalpublishedAt and any time-of-day reference use America/Chicago. Per feedback_central_time_canonical.md.
  12. Fallback rendering is a failure, not a feature. If anything renders via a fallback path on the live site, investigate the primary path failure.

Out of Scope (separate procedures required)

This procedure intentionally does NOT cover:

  • Production session itself (Riverside session setup, recording mechanics, host coordination) — see production guide in TRTU repo (/mnt/d/Projects/the-road-to-unbound/).
  • Sponsorship integration on episodes — deferred per AOM/TRTU launch decisions; Patron-led when it activates.
  • Tertiary distribution (Apple Podcasts, Spotify, RSS) — deferred until those channels are activated; current Subscribe page renders them as "Coming Soon" tiles.
  • Show launch (the one-time activation work to spin up a new show: Sanity show document, theroadtounbound.com Vercel project, brand customization, monorepo wiring, HubSpot Show Listing creation, sponsorship records, launch article). This is a distinct multi-system procedure that should be documented separately as docs/operating-procedures/show-launch.md — recommend Q author this next, sourcing from the TRTU 5P plan (docs/plans/the-road-to-unbound-5p-plan.md) and the AOM precedent (commit a7864b994).
  • Episode 2 specifics — this procedure is generalized; Episode 2 should execute against this document and surface any newly-discovered pitfalls as additions.

Related References

Reference Path
TRTU 5P plan docs/plans/the-road-to-unbound-5p-plan.md
AOM 5P plan (precedent) docs/plans/another-orange-morning-5p-plan.md
AOM precedent commit a7864b994 (AOM 5P plan + Ledger CAR)
Mux schema bridge fix commit nypLANVCk7FbrB0Tv3CXTc (Sanity mux.videoAsset doc created 2026-05-06)
LinkedIn publish pipeline extension commit 84a100bd4 (added --channel flag to post-schedule.js)
Listing property index skills/hubspot/property-index/listing.json
Association type ID index skills/hubspot/property-index/associations.json
Canon verify-after-write rule feedback_canon_verify_after_write.md
Ledger ≠ Social Publishing feedback_ledger_not_social_publishing.md
TRTU broadcasting surfaces (canonical) reference_trtu_broadcasting.md
Vercel account routing reference_vercel_valuefirstagent.md
Central Time canonical feedback_central_time_canonical.md
Ground deliverables in transcripts feedback_ground_deliverables_in_transcripts.md
Verification protocol docs/quality/verification-protocol.md
Process register docs/quality/process-register.md
QMS framework docs/quality/qms-framework.md
Media production lifecycle (related) docs/operating-procedures/media-production.md
HubSpot write operations (related) docs/operating-procedures/hubspot-write-operations.md

Revision History

Date Change Author
2026-05-06 Initial document. Authored from Episode 1 (First Ride) end-to-end execution earlier same day. 13 of 15 steps VERIFIED with evidence; Steps 13 (recap) and 14 (monitoring) marked UNTESTED pending Episode 2 first execution. Captures all known Ep 1 pitfalls (Magic Episode rename, Mux schema bridge, Canon verify protocol, Ledger ≠ Social, lowercase --privacy rejection, Vercel routing, slug redirect statusCode, host-vs-cast distinction, public launch page ban, USER_DEFINED association category, Central Time, fallback-as-failure). Q (Quality System Manager)