Skip to content

Magic Tags Reference

Magic tags are placeholders in email templates that get replaced with actual values at execution time. They follow the format {tag_name} and support guest, episode, podcast, booking, date, and link data.

Total Tags: 33 across 6 categories (4 require guest account)

Source Files:

  • src/lib/automation/magic-tags.ts - Tag definitions (33 tags)
  • src/lib/automation/tag-parser.ts - Parsing and replacement functions

Tag Categories

Guest Tags

Information about the podcast guest. Some tags require the guest to have a linked account (via user_profiles).

TagLabelDescriptionExampleRequires Account
{guest_first_name}First NameGuest's first nameSarahNo
{guest_full_name}Full NameGuest's complete nameSarah JohnsonNo
{guest_email}EmailGuest's email address[email protected]No
{guest_phone}PhoneGuest's phone number (if provided)+1 (555) 123-4567No
{guest_bio}BioGuest's biographySarah is a bestselling author...Yes
{guest_website}WebsiteGuest's website URLhttps://example.com/sarahYes
{guest_twitter}TwitterGuest's Twitter handle (@ prefixed)@sarahjohnsonYes
{guest_avatar}Avatar URLURL to guest's profile photohttps://media.podcasterplus.com/...Yes

Availability: Available when guest or booking context is present.

Profile Fallback: Tags marked "Requires Account" resolve from the user_profiles table when the guest has linked their account. If no account exists, these tags resolve to empty strings. See Profile Fallback for details.

Episode Tags

Information about the podcast episode.

TagLabelDescriptionExample
{episode_title}TitleEpisode titleHow to Build a Startup
{episode_number}NumberEpisode number42
{episode_description}DescriptionEpisode descriptionIn this episode...
{episode_status}StatusCurrent statusscheduled
{season_number}SeasonSeason number (if applicable)2

Availability: Available when episode context is present.

Podcast Tags

Information about the podcast itself.

TagLabelDescriptionExample
{podcast_name}NamePodcast titleThe Tech Show
{podcast_host_name}Host NamePodcast host nameSarah Johnson
{podcast_description}DescriptionPodcast descriptionA weekly show about creativity...
{podcast_website}WebsitePodcast website URLhttps://example.com

Availability: Always available (populated from podcast data).

Booking Tags

Information about a specific booking.

TagLabelDescriptionExample
{booking_link_name}Link NameName of the booking link used30-Min Interview
{booking_notes}NotesBooking notesPlease prepare...

Availability: Available when booking context is present.

Date/Time Tags

Formatted dates and times for recordings and publishing.

TagLabelDescriptionExample
{recording_date}Recording DateFormatted recording dateFriday, January 10, 2025
{recording_time}Recording TimeFormatted recording time2:00 PM
{recording_datetime}Full DateTimeISO datetime2025-01-10T14:00:00Z
{recording_timezone}TimezoneTimezone nameEastern Time (ET)
{publish_date}Publish DateScheduled publish dateMonday, January 20, 2025
{publish_time}Publish TimeScheduled publish time8:00 AM
{publish_datetime}Publish DateTimeFull publish date and timeFebruary 1, 2026 at 8:00 AM
{days_until_publish}Days Until PublishDays until episode publishes7

Availability: recording_* tags available with booking context; publish_* tags available with episode context.

Timezone handling: All formatted date/time tags are rendered via Intl.DateTimeFormat with the IANA timeZone option resolved in this order:

  1. booking.timezone — used for all recording_* tags whenever a booking is in scope
  2. podcasts.default_timezone — used for episode-only dates (publish_*, and recording_* when no booking is present)
  3. 'UTC' — final fallback

{recording_timezone} renders a short label (e.g. "ET") from the booking's IANA zone. See Timezone Management for the utility and the full fallback chain.

URLs for meetings and portals.

TagLabelDescriptionExample
{meeting_url}Meeting URLThe recording link, whatever the platformhttps://riverside.fm/studio/abc
{meeting_platform}Recording PlatformCatalog LABEL for the session's platform, never the stored enum valueRiverside
{guest_portal_link}Portal LinkGuest portal access URLhttps://app.podcasterplus.com/guest/abc123
{calendar_link}Calendar LinkLink to add event to calendarhttps://calendar.google.com/event?...
{episode_url}Episode URLDirect link to published episodehttps://www.podcasterplus.com/show/creative-hour/episode-42
{booking_page_url}Booking Page URLLink to the booking pagehttps://book.podcasterplus.com/creative-hour

Availability: meeting_url, meeting_platform and calendar_link with booking context; guest_portal_link with guest context; episode_url with episode context.

{calendar_link} carries &location={encodedUrl} when the session has a recording link, so a guest who adds the event from an automation email keeps the joining details.

LOCKSTEP (#200). meeting_platform resolves at seven sites and they must agree, all of them mapping the stored enum through meetingPlatformLabel from src/lib/constants/meeting-platforms.ts: the tag catalog (magic-tags.ts), MagicTagContext, tag-parser.ts, events.ts, scheduler.ts, the dev-sync preview in src/api/routes/automations/rules.ts, and both workers with their SQL joins. Both workers value-import that catalog, so a catalog change requires redeploying automation-executor and automation-scheduler even when git diff -- workers/ is empty.

meeting_platform is deliberately NOT in PUBLIC_FIELD_EXCLUDED_TAGS: a platform name is not sensitive. The meeting_url exclusion beside it stays.

Context Availability by Trigger

Different triggers provide different context data:

Trigger TypeGuestEpisodeBookingDatesLinks
booking.confirmedYesPartialYesYesYes
booking.declinedYesPartialYesYesNo
booking.canceledYesPartialYesYesNo
booking.rescheduledYesPartialYesYesYes
episode.draft_createdNoYesNoYesNo
episode.publishedNoYesNoYesNo
episode.scheduledNoYesNoYesNo
guest.respondedYesYesPartialPartialYes
time.before_recordingYesYesYesYesYes
time.after_recordingYesYesYesYesYes
time.before_publishPartialYesPartialYesNo
time.after_publishPartialYesPartialYesNo
time.after_bookingYesPartialYesYesYes

episode.draft_created fires on manual creation only

It is emitted from the /p/[slug]/e/new action (+page.server.ts), which is the only episode-creation path that emits it. Episodes minted by create_booking_with_attendees on a public booking, and episodes created by the import worker, do not fire it: those paths create rows in SQL and never reach the action.

That is worth knowing before writing a rule against it, and before assuming the trigger means "any episode was created". The trigger existed in the enum from the start but had no emitter at all until #350, so nothing depended on the wider reading.

Where a recording date comes from, and which one wins

Two sources, in this precedence order:

  1. The booking, when the run has one. bookings.scheduled_at, rendered in the booking's own timezone. The executor also reverse-looks-up a booking by episode_id, so an episode that came from a booking resolves this way even on an episode trigger.
  2. The episode, otherwise. episodes.recording_scheduled_at, rendered in the podcast's default_timezone, since an episode carries no timezone of its own.

The second source was added in #359. Before that these tags were built only inside the booking block, so an episode created by hand resolved none of them, even though the column is populated and the create form requires it.

A reschedule renders the new date. reschedule_booking_solo writes the new time to the booking and follows it onto episodes.recording_scheduled_at (20260716220000_booking_rpc_status_recheck.sql:958), so both sources agree afterwards. The precedence above matters only if they ever diverge, where the booking is the more specific record and wins.

Kept in lockstep across four builders: src/lib/automation/events.ts (the event snapshot), workers/automation-executor/src/index.ts (what actually renders the email), src/lib/automation/tag-parser.ts and workers/automation-scheduler/src/index.ts. The last two already read the episode column; the first two did not.

Time-based before_recording jobs are still armed from bookings only

Separate from the tags above. scheduleTimeBasedJobs is called from the booking flows and takes the recording date as a parameter, so a manually created episode never arms time.before_recording or time.after_recording, whatever its recording date says. #359 fixed tag rendering, not job scheduling.

Profile Fallback Chain

When the automation executor resolves magic tags, guest data can come from multiple sources. The system implements a priority-based fallback chain that ensures the most accurate data is used.

Data Sources (Priority Order)

Resolution Rules

TagProfile (user_profiles)Guest (episode_guests)Booking (bookings)Fallback
guest_full_namedisplay_name (wins)nameguest_name""
guest_first_nameFirst word of display_name (wins)First word of nameFirst word of guest_name""
guest_email-emailguest_email""
guest_phone--guest_phone""
guest_biobio--""
guest_websitewebsite_url--""
guest_twittertwitter_handle (@ prefixed)--""
guest_avataravatar_url--""

How user_id is Resolved

The executor discovers user_id through the episode_guests table:

  1. If guest_id is provided or resolved from booking → query episode_guests → capture user_id
  2. If reverse-lookup by episode_id → query episode_guests WHERE status = 'active' → capture user_id
  3. If user_id is found → query user_profiles WHERE id = user_id → populate profile tags

Practical Examples

Guest WITHOUT account (no user_profiles record):

{guest_full_name}  → "Sarah Johnson"       (from booking/episode_guests)
{guest_bio}        → ""                     (empty - no profile)
{guest_twitter}    → ""                     (empty - no profile)

Guest WITH account (has user_profiles record with display_name: "Dr. Sarah J."):

{guest_full_name}  → "Dr. Sarah J."        (profile display_name overrides)
{guest_first_name} → "Dr."                 (first word of profile display_name)
{guest_bio}        → "Award-winning author" (from profile)
{guest_twitter}    → "@drsarahj"           (from profile, @ prefix ensured)

Implementation Locations

LocationPurposeFile
App-sidePreview generation, validationsrc/lib/automation/tag-parser.ts buildMagicTagContext()
Worker-sideActual execution with DB queriesworkers/automation-executor/src/index.ts buildMagicTagContext()

Both implementations follow the same fallback priority, but the worker queries the database directly while the app-side function receives pre-fetched data objects.

Parser Functions

extractMagicTags(content: string): string[]

Extracts all magic tags from a string.

typescript
import { extractMagicTags } from '$lib/automation/tag-parser';

const tags = extractMagicTags('Hello {guest_first_name}, your episode {episode_title} is ready!');
// Returns: ['guest_first_name', 'episode_title']

validateMagicTags(content: string, availableContexts?: string[]): ValidationResult

Validates that all tags in the content are known and available.

typescript
import { validateMagicTags } from '$lib/automation/tag-parser';

const result = validateMagicTags('Hello {guest_first_name} {unknown_tag}');
// Returns:
// {
//     isValid: false,
//     validTags: ['guest_first_name'],
//     invalidTags: ['unknown_tag'],
//     errors: ['Unknown tag: {unknown_tag}']
// }

// With context restrictions
const result2 = validateMagicTags('{guest_first_name}', ['episode']);
// May warn that guest context may not be available

replaceMagicTags(content: string, context: MagicTagContext, options?): string

Replaces magic tags with actual values.

typescript
import { replaceMagicTags } from '$lib/automation/tag-parser';

const content = 'Hello {guest_first_name}!';
const context = { guest_first_name: 'John' };
const result = replaceMagicTags(content, context);
// Returns: 'Hello John!'

// With options
const result2 = replaceMagicTags(
	content,
	{},
	{
		fallback: '[Not set]', // Use for missing values
		preserveUnknown: true, // Keep unknown tags as-is
		escapeHtml: true // HTML-escape values
	}
);

generatePreview(content: string, customSamples?: Partial<MagicTagContext>): string

Generates a preview with sample data.

typescript
import { generatePreview } from '$lib/automation/tag-parser';

const preview = generatePreview('Hello {guest_first_name}!');
// Returns: 'Hello Alex!' (uses default sample data)

const preview2 = generatePreview('Hello {guest_first_name}!', {
	guest_first_name: 'Custom Name'
});
// Returns: 'Hello Custom Name!'

highlightMagicTags(content: string): string

Returns HTML with tags wrapped in styled spans for display.

typescript
import { highlightMagicTags } from '$lib/automation/tag-parser';

const highlighted = highlightMagicTags('Hello {guest_first_name}!');
// Returns: 'Hello <span class="magic-tag magic-tag-valid">{guest_first_name}</span>!'

const highlighted2 = highlightMagicTags('Hello {unknown_tag}!');
// Returns: 'Hello <span class="magic-tag magic-tag-invalid">{unknown_tag}</span>!'

buildMagicTagContext(data: ContextData): MagicTagContext

Builds a context object from source data. Supports optional profile data for the profile fallback chain.

typescript
import { buildMagicTagContext } from '$lib/automation/tag-parser';

const context = buildMagicTagContext({
	guest: {
		name: 'John Doe',
		email: '[email protected]',
		phone: '555-1234'
	},
	// Optional: profile data overrides guest name, adds bio/website/twitter/avatar
	profile: {
		display_name: 'Dr. John Doe',
		bio: 'Bestselling author and speaker',
		website_url: 'https://johndoe.com',
		twitter_handle: 'johndoe',
		avatar_url: 'https://media.podcasterplus.com/avatars/john.jpg'
	},
	episode: {
		title: 'Great Episode',
		episode_number: 42
	},
	podcast: {
		title: 'The Tech Show'
	},
	booking: {
		start_time: '2025-01-10T14:00:00Z',
		end_time: '2025-01-10T15:00:00Z',
		timezone: 'America/New_York',
		meeting_url: 'https://zoom.us/j/123'
	}
});

// Returns:
// {
//     guest_first_name: 'Dr.',            ← from profile.display_name (overrides guest.name)
//     guest_full_name: 'Dr. John Doe',    ← from profile.display_name (overrides guest.name)
//     guest_email: '[email protected]',
//     guest_phone: '555-1234',
//     guest_bio: 'Bestselling author and speaker',    ← profile-only
//     guest_website: 'https://johndoe.com',           ← profile-only
//     guest_twitter: '@johndoe',                      ← profile-only (@ prefix added)
//     guest_avatar: 'https://media...',               ← profile-only
//     episode_title: 'Great Episode',
//     episode_number: 42,
//     podcast_name: 'The Tech Show',
//     recording_date: 'Friday, January 10, 2025',
//     recording_time: '2:00 PM',
//     recording_timezone: 'Eastern Time (ET)',
//     meeting_url: 'https://zoom.us/j/123'
// }

Without profile data (guest has no linked account):

typescript
const context = buildMagicTagContext({
	guest: { name: 'John Doe', email: '[email protected]' },
	// profile: undefined  ← no account linked
	podcast: { title: 'The Tech Show' }
});

// guest_full_name: 'John Doe'  ← from guest.name
// guest_bio: undefined          ← not populated (no profile)

getSampleContext(): MagicTagContext

Returns sample data for all tags (used for previews).

typescript
import { getSampleContext } from '$lib/automation/tag-parser';

const samples = getSampleContext();
// Returns pre-filled context with realistic sample values

stripHtml(html: string): string

Converts HTML to plain text (for email text versions).

typescript
import { stripHtml } from '$lib/automation/tag-parser';

const plain = stripHtml('<p>Hello <strong>World</strong></p><br><ul><li>Item</li></ul>');
// Returns: 'Hello World\n\n- Item'

prepareForJson(content: string): string

Escapes content for safe JSON embedding.

typescript
import { prepareForJson } from '$lib/automation/tag-parser';

const safe = prepareForJson('He said "Hello"\nNew line');
// Returns: 'He said \\"Hello\\"\\nNew line'

UI Components

MagicTagInserter

A dropdown component for inserting tags into form fields.

svelte
<script>
	import MagicTagInserter from '$lib/components/automation/MagicTagInserter.svelte';

	let content = '';

	function handleInsert(tag: string) {
		content += tag;
	}
</script>

<MagicTagInserter onInsert={handleInsert} disabled={false} />

Tag Highlighting in Templates

When displaying template content, use highlightMagicTags():

svelte
<script>
	import { highlightMagicTags } from '$lib/automation/tag-parser';

	let template = 'Hello {guest_first_name}!';
	let highlighted = highlightMagicTags(template);
</script>

<div class="template-preview">
	{@html highlighted}
</div>

<style>
	:global(.magic-tag) {
		padding: 0 4px;
		border-radius: 4px;
		font-family: monospace;
	}
	:global(.magic-tag-valid) {
		background: #dcfce7;
		color: #166534;
	}
	:global(.magic-tag-invalid) {
		background: #fee2e2;
		color: #dc2626;
	}
</style>

Adding New Tags

  1. Add definition in src/lib/automation/magic-tags.ts:
typescript
// In MAGIC_TAGS array
{
    tag: 'my_new_tag',
    label: 'My New Tag',
    description: 'Description of what this tag contains',
    category: 'guest',  // or 'episode', 'podcast', 'booking', 'dates', 'links'
    example: 'Example value',
    requiresContext: ['guest'],    // optional: restrict availability
    requiresAccount: true          // optional: true if data comes from user_profiles
}
  1. Populate in app-side context builder in src/lib/automation/tag-parser.ts:
typescript
// In buildMagicTagContext function
// For profile-only tags (requiresAccount: true):
if (data.profile) {
	context.my_new_tag = data.profile.my_field;
}
// For standard guest tags:
if (data.guest) {
	context.my_new_tag = data.guest.my_field;
}
  1. Populate in worker-side context builder in workers/automation-executor/src/index.ts:
typescript
// In buildMagicTagContext function - add to the appropriate section
// For profile-only tags, add in the PROFILE DATA section:
if (profile) {
	context.my_new_tag = profile.my_field || '';
}
// Also add to the allTags array at the bottom for empty-string fallback
  1. Add tests in src/lib/automation/__tests__/magic-tags.test.ts:
typescript
it('should contain my new tag', () => {
	const tag = getMagicTagByName('my_new_tag');
	expect(tag).toBeDefined();
	expect(tag?.category).toBe('guest');
	expect(tag?.requiresAccount).toBe(true);
});

Important

When adding profile-based tags, you must update both the app-side (tag-parser.ts) and worker-side (workers/automation-executor/src/index.ts) context builders. The app-side is used for previews and the worker-side is used for actual execution.

Best Practices

Template Writing

  1. Use fallbacks for optional data:

    Hello {guest_first_name},

    If guest_first_name is missing, it becomes empty. Consider:

    Hello {guest_first_name|there},

    (Note: Fallback syntax not yet implemented; use replaceMagicTags with fallback option)

  2. Test with preview: Always use the preview feature to verify tags render correctly.

  3. Document required context: When creating automations, ensure the trigger provides needed context.

Security

  1. HTML escaping: When displaying user-provided values in HTML emails, use escapeHtml: true:

    typescript
    replaceMagicTags(content, context, { escapeHtml: true });
  2. JSON safety: When embedding in JSON (e.g., webhook payloads), use prepareForJson():

    typescript
    const payload = `{"name": "${prepareForJson(context.guest_full_name)}"}`;
  3. Validation: Always validate templates before saving:

    typescript
    const { isValid, errors } = validateMagicTags(template.body_html);
    if (!isValid) {
    	toast.error(`Invalid tags: ${errors.join(', ')}`);
    }

Internal documentation - Not for public distribution