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).
| Tag | Label | Description | Example | Requires Account |
|---|---|---|---|---|
{guest_first_name} | First Name | Guest's first name | Sarah | No |
{guest_full_name} | Full Name | Guest's complete name | Sarah Johnson | No |
{guest_email} | Guest's email address | [email protected] | No | |
{guest_phone} | Phone | Guest's phone number (if provided) | +1 (555) 123-4567 | No |
{guest_bio} | Bio | Guest's biography | Sarah is a bestselling author... | Yes |
{guest_website} | Website | Guest's website URL | https://example.com/sarah | Yes |
{guest_twitter} | Guest's Twitter handle (@ prefixed) | @sarahjohnson | Yes | |
{guest_avatar} | Avatar URL | URL to guest's profile photo | https://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.
| Tag | Label | Description | Example |
|---|---|---|---|
{episode_title} | Title | Episode title | How to Build a Startup |
{episode_number} | Number | Episode number | 42 |
{episode_description} | Description | Episode description | In this episode... |
{episode_status} | Status | Current status | scheduled |
{season_number} | Season | Season number (if applicable) | 2 |
Availability: Available when episode context is present.
Podcast Tags
Information about the podcast itself.
| Tag | Label | Description | Example |
|---|---|---|---|
{podcast_name} | Name | Podcast title | The Tech Show |
{podcast_host_name} | Host Name | Podcast host name | Sarah Johnson |
{podcast_description} | Description | Podcast description | A weekly show about creativity... |
{podcast_website} | Website | Podcast website URL | https://example.com |
Availability: Always available (populated from podcast data).
Booking Tags
Information about a specific booking.
| Tag | Label | Description | Example |
|---|---|---|---|
{booking_link_name} | Link Name | Name of the booking link used | 30-Min Interview |
{booking_notes} | Notes | Booking notes | Please prepare... |
Availability: Available when booking context is present.
Date/Time Tags
Formatted dates and times for recordings and publishing.
| Tag | Label | Description | Example |
|---|---|---|---|
{recording_date} | Recording Date | Formatted recording date | Friday, January 10, 2025 |
{recording_time} | Recording Time | Formatted recording time | 2:00 PM |
{recording_datetime} | Full DateTime | ISO datetime | 2025-01-10T14:00:00Z |
{recording_timezone} | Timezone | Timezone name | Eastern Time (ET) |
{publish_date} | Publish Date | Scheduled publish date | Monday, January 20, 2025 |
{publish_time} | Publish Time | Scheduled publish time | 8:00 AM |
{publish_datetime} | Publish DateTime | Full publish date and time | February 1, 2026 at 8:00 AM |
{days_until_publish} | Days Until Publish | Days until episode publishes | 7 |
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:
booking.timezone— used for allrecording_*tags whenever a booking is in scopepodcasts.default_timezone— used for episode-only dates (publish_*, andrecording_*when no booking is present)'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.
Link Tags
URLs for meetings and portals.
| Tag | Label | Description | Example |
|---|---|---|---|
{meeting_url} | Meeting URL | The recording link, whatever the platform | https://riverside.fm/studio/abc |
{meeting_platform} | Recording Platform | Catalog LABEL for the session's platform, never the stored enum value | Riverside |
{guest_portal_link} | Portal Link | Guest portal access URL | https://app.podcasterplus.com/guest/abc123 |
{calendar_link} | Calendar Link | Link to add event to calendar | https://calendar.google.com/event?... |
{episode_url} | Episode URL | Direct link to published episode | https://www.podcasterplus.com/show/creative-hour/episode-42 |
{booking_page_url} | Booking Page URL | Link to the booking page | https://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 Type | Guest | Episode | Booking | Dates | Links |
|---|---|---|---|---|---|
booking.confirmed | Yes | Partial | Yes | Yes | Yes |
booking.declined | Yes | Partial | Yes | Yes | No |
booking.canceled | Yes | Partial | Yes | Yes | No |
booking.rescheduled | Yes | Partial | Yes | Yes | Yes |
episode.draft_created | No | Yes | No | Yes | No |
episode.published | No | Yes | No | Yes | No |
episode.scheduled | No | Yes | No | Yes | No |
guest.responded | Yes | Yes | Partial | Partial | Yes |
time.before_recording | Yes | Yes | Yes | Yes | Yes |
time.after_recording | Yes | Yes | Yes | Yes | Yes |
time.before_publish | Partial | Yes | Partial | Yes | No |
time.after_publish | Partial | Yes | Partial | Yes | No |
time.after_booking | Yes | Partial | Yes | Yes | Yes |
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:
- 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 byepisode_id, so an episode that came from a booking resolves this way even on an episode trigger. - The episode, otherwise.
episodes.recording_scheduled_at, rendered in the podcast'sdefault_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
| Tag | Profile (user_profiles) | Guest (episode_guests) | Booking (bookings) | Fallback |
|---|---|---|---|---|
guest_full_name | display_name (wins) | name | guest_name | "" |
guest_first_name | First word of display_name (wins) | First word of name | First word of guest_name | "" |
guest_email | - | email | guest_email | "" |
guest_phone | - | - | guest_phone | "" |
guest_bio | bio | - | - | "" |
guest_website | website_url | - | - | "" |
guest_twitter | twitter_handle (@ prefixed) | - | - | "" |
guest_avatar | avatar_url | - | - | "" |
How user_id is Resolved
The executor discovers user_id through the episode_guests table:
- If
guest_idis provided or resolved from booking → queryepisode_guests→ captureuser_id - If reverse-lookup by
episode_id→ queryepisode_guests WHERE status = 'active'→ captureuser_id - If
user_idis found → queryuser_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
| Location | Purpose | File |
|---|---|---|
| App-side | Preview generation, validation | src/lib/automation/tag-parser.ts buildMagicTagContext() |
| Worker-side | Actual execution with DB queries | workers/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.
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.
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 availablereplaceMagicTags(content: string, context: MagicTagContext, options?): string
Replaces magic tags with actual values.
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.
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.
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.
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):
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).
import { getSampleContext } from '$lib/automation/tag-parser';
const samples = getSampleContext();
// Returns pre-filled context with realistic sample valuesstripHtml(html: string): string
Converts HTML to plain text (for email text versions).
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.
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.
<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():
<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
- Add definition in
src/lib/automation/magic-tags.ts:
// 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
}- Populate in app-side context builder in
src/lib/automation/tag-parser.ts:
// 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;
}- Populate in worker-side context builder in
workers/automation-executor/src/index.ts:
// 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- Add tests in
src/lib/automation/__tests__/magic-tags.test.ts:
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
Use fallbacks for optional data:
Hello {guest_first_name},If
guest_first_nameis missing, it becomes empty. Consider:Hello {guest_first_name|there},(Note: Fallback syntax not yet implemented; use
replaceMagicTagswithfallbackoption)Test with preview: Always use the preview feature to verify tags render correctly.
Document required context: When creating automations, ensure the trigger provides needed context.
Security
HTML escaping: When displaying user-provided values in HTML emails, use
escapeHtml: true:typescriptreplaceMagicTags(content, context, { escapeHtml: true });JSON safety: When embedding in JSON (e.g., webhook payloads), use
prepareForJson():typescriptconst payload = `{"name": "${prepareForJson(context.guest_full_name)}"}`;Validation: Always validate templates before saving:
typescriptconst { isValid, errors } = validateMagicTags(template.body_html); if (!isValid) { toast.error(`Invalid tags: ${errors.join(', ')}`); }