YBH Pulse Content uses Sanity.io as its headless CMS for storing episodes, guest profiles, generated content, and social media assets.
Project setup
Create Sanity project
If you don’t have a Sanity project yet:npm install -g @sanity/cli
sanity init
Note your project ID and dataset name. Configure environment variables
Add to .env (client-side):VITE_SANITY_PROJECT_ID=your-project-id
VITE_SANITY_DATASET=production
Add to .dev.vars (server-side):SANITY_API_TOKEN=your-editor-token
Generate API token
- Go to https://sanity.io/manage
- Select your project
- Navigate to API > Tokens
- Create a token with Editor permissions
- Copy the token to
SANITY_API_TOKEN
Schema structure
Pulse Content uses four primary schemas in Sanity:
Episode content
Document type: episodeContent
Stores all generated content for each episode:
- Transcript (raw text)
- PRF document (HTML)
- Viral hooks (HTML)
- Social posts (LinkedIn, Instagram, Twitter)
- Generated assets (infographics, quote cards, thumbnails)
- Episode metadata (title, guest name, episode number, topics, summary)
- Sanity page metadata (for podcast website)
- Visual suggestions (AI-generated design ideas)
- Brand kit assets (curated assets for sharing)
- Video assets (square and vertical clips)
- Scheduled posts (Late API tracking)
- Approval status and timestamps
- Fact-check results and verified facts bank
Guest
Document type: guest
Guest directory with contact information:
- Name, position, company
- Email, mobile, address
- LinkedIn URL and follower count
- Circle community membership
- Status (booked, recorded, in production, complete)
- Episode references
- Share links
- Notes
- Transcript upload tracking
Guest profile
Document type: guestProfile
Cached LinkedIn data to avoid repeated API calls:
- Name and headline
- LinkedIn URL
- Professional summary
- Career history (positions, companies, dates, descriptions)
- Scraped timestamp (cached for 24 hours)
User
Document type: user
Team authentication:
- Email (must be
@popularit.net)
- Password hash
- Created and updated timestamps
API access patterns
Client-side (read-only)
All client-side requests go through /api/sanity/* proxy endpoints. The Sanity API token is never exposed to the browser.
import { fetchEpisodes, fetchEpisode } from '@/services/sanity'
// List all episodes
const episodes = await fetchEpisodes()
// Get single episode
const episode = await fetchEpisode('episode-id')
Server-side (read/write)
Server-side functions access Sanity directly using the editor token:
import { createClient } from '@sanity/client'
const client = createClient({
projectId: env.VITE_SANITY_PROJECT_ID,
dataset: env.VITE_SANITY_DATASET,
token: env.SANITY_API_TOKEN,
useCdn: false,
apiVersion: '2024-03-01',
})
// Create document
await client.create({
_type: 'episodeContent',
transcript: 'Episode transcript...',
status: 'draft',
})
// Update document
await client.patch('episode-id')
.set({ prf: 'PRF content...' })
.commit()
// Query documents
const episodes = await client.fetch(
`*[_type == "episodeContent"] | order(_createdAt desc)`
)
Data flow
Episode creation flow
Upload transcript
User uploads transcript file or pulls from Google Drive.
Parse metadata
System extracts episode number, guest name, and LinkedIn URL from transcript header:385-Chris Pacifico
Host: Phil Howard
Guest: Chris Pacifico
https://www.linkedin.com/in/chris-pacifico/
Create episode document
const episode = await createEpisode({
transcript,
guestId, // Optional
})
Auto-generate PRF
AI generates Podcast Repurposing Framework document from transcript.
Auto-generate hooks
AI generates viral hooks from PRF and transcript.
Generate on demand
- Episode metadata (title, descriptions, takeaways, show notes)
- LinkedIn posts
- Instagram posts
- Visual suggestions
- Career timeline
Guest profile caching
Check Sanity cache
Look for existing guestProfile by LinkedIn URL.
Check cache freshness
If cached profile exists and is less than 24 hours old, use it.
Scrape LinkedIn
If cache is stale or missing, fetch fresh data from RapidAPI.
Update cache
Upsert guestProfile with new data and timestamp.
Link to episode
Store guest reference in episodeContent.guestRef.
Content production workflow
Document linking
Episodes and guests are linked via references:
// Episode content references guest
{
_type: 'episodeContent',
guestRef: {
_type: 'reference',
_ref: 'guest-document-id',
},
}
// Guest references episodes
{
_type: 'guest',
episodeRefs: [
{ _type: 'reference', _ref: 'episode-1-id' },
{ _type: 'reference', _ref: 'episode-2-id' },
],
}
Status tracking
Episode status values:
draft - Initial state after upload
in_progress - Generating content
running - Active generation process
complete - All content generated
partial - Some content generated
failed - Generation failed
degraded - Generation completed with warnings
Approval workflow
Content requires approval before sharing:
// PRF approval
await updateEpisode(episodeId, {
prfApproved: true,
prfApprovedAt: new Date().toISOString(),
})
// Hooks approval
await updateEpisode(episodeId, {
hooksApproved: true,
hooksApprovedAt: new Date().toISOString(),
})
// LinkedIn posts approval
await updateEpisode(episodeId, {
linkedinApproved: true,
linkedinApprovedAt: new Date().toISOString(),
})
// Visual asset approval
const suggestions = episode.visualSuggestions?.map(s => ({
...s,
status: s._key === selectedKey ? 'approved' : s.status,
approvedAt: s._key === selectedKey ? new Date().toISOString() : s.approvedAt,
}))
await updateEpisode(episodeId, { visualSuggestions: suggestions })
Brand kit curation
Select assets
Team curates 4-10 assets for guest brand kit from approved visuals.
Add to brand kit
await updateEpisode(episodeId, {
brandKitAssets: [
{
type: 'infographic',
imageUrl: 'https://...',
title: 'Key Insight',
caption: 'Share this on LinkedIn',
order: 1,
addedAt: new Date().toISOString(),
},
],
})
Lock brand kit
When finalized:await updateEpisode(episodeId, {
brandKitLocked: true,
brandKitLockedAt: new Date().toISOString(),
})
Generate share link
Creates public URL: /share/{episodeNumber}-{token}
Sanity Studio
Manage content directly in Sanity Studio:
# Start Sanity Studio locally
npm run sanity:dev
# Deploy Sanity Studio
npm run sanity:deploy
Access at: https://your-project.sanity.studio/
Pulse Content is the primary interface for content production. Use Sanity Studio only for administrative tasks or manual data corrections.
Database maintenance
Backup episodes
sanity dataset export production backup.tar.gz
Import episodes
sanity dataset import backup.tar.gz production
Clean up old drafts
*[_type == "episodeContent" && status == "draft" && _createdAt < "2024-01-01"]
Common queries
Recent episodes
*[_type == "episodeContent"] | order(metadata.episodeNumber desc) [0...10] {
_id,
metadata,
status,
prfApproved,
hooksApproved,
linkedinApproved,
}
Episodes by guest
*[_type == "episodeContent" && references($guestId)] {
_id,
metadata,
guestRef->,
}
Approved assets ready for sharing
*[_type == "episodeContent" && prfApproved == true && hooksApproved == true && linkedinApproved == true] {
_id,
metadata,
brandKitAssets,
shareToken,
}
Use CDN for reads
Enable CDN for read-only queries:
const client = createClient({
projectId: env.VITE_SANITY_PROJECT_ID,
dataset: env.VITE_SANITY_DATASET,
useCdn: true, // Enable CDN for faster reads
apiVersion: '2024-03-01',
})
Disable CDN for writes
Always use useCdn: false for mutations:
const client = createClient({
projectId: env.VITE_SANITY_PROJECT_ID,
dataset: env.VITE_SANITY_DATASET,
token: env.SANITY_API_TOKEN,
useCdn: false, // Disable CDN for writes
apiVersion: '2024-03-01',
})
Batch updates
Use transactions for multiple related updates:
const transaction = client.transaction()
transaction
.patch(episodeId, { set: { prfApproved: true } })
.patch(episodeId, { set: { hooksApproved: true } })
.commit()
Troubleshooting
Connection errors
Verify project ID and dataset:
echo $VITE_SANITY_PROJECT_ID
echo $VITE_SANITY_DATASET
Permission denied
Ensure API token has Editor role:
- Go to https://sanity.io/manage
- Select project > API > Tokens
- Check token permissions
- Regenerate if needed
Data not updating
Check CDN cache:
- Use
useCdn: false for real-time data
- CDN cache can take up to 60 seconds to update
Schema validation errors
Redeploy schemas: