diff --git a/src/TeamsISO.App/Services/OutputNameTemplate.cs b/src/TeamsISO.App/Services/OutputNameTemplate.cs
new file mode 100644
index 0000000..491ea6d
--- /dev/null
+++ b/src/TeamsISO.App/Services/OutputNameTemplate.cs
@@ -0,0 +1,107 @@
+using System.IO;
+using System.Text;
+
+namespace TeamsISO.App.Services;
+
+///
+/// User-editable template for the NDI source name a participant's ISO is
+/// published as. Default "TEAMSISO_{guid}" matches the original
+/// hard-coded DefaultOutputName in IsoController; operators
+/// can switch to "TEAMSISO_{name}" for human-readable output names
+/// (recommended for downstream switchers that key on name patterns), or
+/// "TEAMSISO_{machine}_{name}" when multiple TeamsISO machines feed
+/// the same NDI network.
+///
+/// Tokens expanded in :
+/// {name} participant display name, sanitized (alphanumeric + underscore)
+/// {guid} first 8 hex chars of the participant's Id, uppercase
+/// {machine} sanitized PC hostname (Environment.MachineName)
+/// {timestamp} current local time as yyyyMMdd_HHmmss
+///
+/// Persisted to %LOCALAPPDATA%\TeamsISO\output-name-template.txt.
+///
+public static class OutputNameTemplate
+{
+ public const string DefaultTemplate = "TEAMSISO_{guid}";
+
+ private static string TemplatePath =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "TeamsISO", "output-name-template.txt");
+
+ ///
+ /// Get the operator's current template, or the shipped default when no
+ /// override has been saved (or the override file is missing/unreadable).
+ ///
+ public static string Get()
+ {
+ try
+ {
+ if (File.Exists(TemplatePath))
+ {
+ var raw = File.ReadAllText(TemplatePath).Trim();
+ if (!string.IsNullOrEmpty(raw)) return raw;
+ }
+ }
+ catch
+ {
+ // Disk read failure → fall through to default. The next Set() call
+ // will overwrite cleanly.
+ }
+ return DefaultTemplate;
+ }
+
+ public static void Set(string template)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(TemplatePath);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ File.WriteAllText(TemplatePath, template ?? string.Empty);
+ }
+ catch
+ {
+ // Best-effort persistence; the in-memory value still sticks for
+ // this session.
+ }
+ }
+
+ ///
+ /// Expand tokens in for a specific participant.
+ /// Result is sanitized into NDI-safe characters: alphanumeric, underscore,
+ /// hyphen, period. NDI spec allows more, but a conservative set keeps
+ /// downstream switchers happy.
+ ///
+ public static string Render(string template, Guid participantId, string displayName)
+ {
+ var safeName = SanitizeForNdi(displayName);
+ var guid = participantId.ToString("N")[..8].ToUpperInvariant();
+ var machine = SanitizeForNdi(Environment.MachineName);
+ var timestamp = DateTimeOffset.Now.ToString("yyyyMMdd_HHmmss");
+
+ var result = template
+ .Replace("{name}", safeName)
+ .Replace("{guid}", guid)
+ .Replace("{machine}", machine)
+ .Replace("{timestamp}", timestamp);
+
+ // Final sanitize on the rendered result — protects against a template
+ // that includes literal characters NDI doesn't accept.
+ return SanitizeForNdi(result);
+ }
+
+ private static string SanitizeForNdi(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return string.Empty;
+ var sb = new StringBuilder(s.Length);
+ foreach (var c in s)
+ {
+ if (char.IsLetterOrDigit(c) || c is '_' or '-' or '.')
+ sb.Append(c);
+ else if (char.IsWhiteSpace(c))
+ sb.Append('_');
+ // else: skip
+ }
+ return sb.ToString();
+ }
+}