Skip to content

System Prompt Builder — C# source

Assemble a system prompt from ordered blocks — role, context, constraints, output format — with a live token count, soft-limit warnings, and a shareable URL. 100% client-side.

This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.

// System Prompt Builder — assemble an ordered list of prompt blocks into a
// markdown-structured system prompt, with pure list operations, presets,
// warnings, and a compact URL codec for shareable state.
//
// Language: C# (.NET 8, zero dependencies)
// Port of src/lib/systemPromptBuilder.ts (the canonical TypeScript
// implementation). Field names stay camelCase to match the TS surface.
// Tool page: https://dev.cosmolabs.org/tools/system-prompt-builder

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;

namespace CosmoDev.SystemPromptBuilder;

public sealed record PromptBlock(
    string Id,
    string Title,
    string Content,
    bool Enabled = true);

public sealed record PromptPreset(string Id, string Title, string Description, string Content);

public sealed record PromptReport(string Assembled, long Tokens, IReadOnlyList<string> Warnings);

/// <summary>Blocks whose assembled size starts crowding the context on most models.</summary>
public static class SystemPromptBuilder
{
    public const long SYSTEM_PROMPT_SOFT_LIMIT_TOKENS = 2000;

    /// <summary>Ordered starter templates — the recommended skeleton.</summary>
    public static readonly IReadOnlyList<PromptPreset> SYSTEM_PROMPT_PRESETS = new PromptPreset[]
    {
        new("role", "Role", "Who the model is and what it optimizes for.",
            "You are a senior software engineer. You give correct, concise answers and say so plainly when you are unsure."),
        new("context", "Context", "The situation the model is working in.",
            "The user is a developer working in a TypeScript codebase. Prefer runnable examples over prose when both work."),
        new("constraints", "Constraints", "Hard rules the model must not break.",
            "- Never invent library APIs; use only the ones in the provided code.\n- Keep answers under 300 words unless asked for more."),
        new("output-format", "Output format", "The exact shape of the answer.",
            "Respond with: 1) a one-line summary, 2) a fenced code block, 3) any caveats as bullet points."),
        new("examples", "Examples", "Few-shot demonstrations of the desired behavior.",
            "Input: reverse \"abc\"\nOutput: \"cba\""),
        new("tone", "Tone", "Voice and register.",
            "Direct and friendly. No filler openers, no apologies."),
        new("refusal", "Refusal policy", "How to handle out-of-scope requests.",
            "If a request is outside your scope, say so in one sentence and suggest the closest thing you can do."),
        new("safety", "Safety", "Guardrails for sensitive content.",
            "Refuse requests that could cause harm, and never echo secrets, keys, or credentials back in full."),
    };

    /// <summary>Render enabled, non-empty blocks (in order) as one markdown prompt.</summary>
    public static string AssemblePrompt(
        IReadOnlyList<PromptBlock> blocks, bool headers = true)
    {
        return string.Join("\n\n", blocks
            .Where(b => b.Enabled && b.Content.Trim().Length > 0)
            .Select(b => headers
                ? $"## {(string.IsNullOrWhiteSpace(b.Title) ? "Untitled" : b.Title.Trim())}\n{b.Content.Trim()}"
                : b.Content.Trim()))
            .Trim();
    }

    /// <summary>Append a block (caller supplies the id so the lib stays pure).</summary>
    public static List<PromptBlock> AddBlock(
        List<PromptBlock> blocks, string id, string title, string content = "",
        bool enabled = true)
    {
        var next = new List<PromptBlock>(blocks) { new PromptBlock(id, title, content, enabled) };
        return next;
    }

    /// <summary>Patch one block by id; unknown ids leave the list unchanged.</summary>
    public static List<PromptBlock> UpdateBlock(
        List<PromptBlock> blocks, string id,
        string? title = null, string? content = null, bool? enabled = null)
    {
        return blocks
            .Select(b => b.Id == id
                ? b with
                {
                    Title = title ?? b.Title,
                    Content = content ?? b.Content,
                    Enabled = enabled ?? b.Enabled,
                }
                : b)
            .ToList();
    }

    /// <summary>Flip one block's enabled flag by id.</summary>
    public static List<PromptBlock> ToggleBlock(List<PromptBlock> blocks, string id)
    {
        return blocks.Select(b => b.Id == id ? b with { Enabled = !b.Enabled } : b).ToList();
    }

    /// <summary>Remove one block by id.</summary>
    public static List<PromptBlock> RemoveBlock(List<PromptBlock> blocks, string id)
    {
        return blocks.Where(b => b.Id != id).ToList();
    }

    /// <summary>Move a block (clamped; no-op when indexes are out of range or equal).</summary>
    public static List<PromptBlock> MoveBlock(List<PromptBlock> blocks, int from, int to)
    {
        if (from < 0 || from >= blocks.Count || to < 0 || to >= blocks.Count || from == to)
        {
            return new List<PromptBlock>(blocks);
        }
        var next = new List<PromptBlock>(blocks);
        var moved = next[from];
        next.RemoveAt(from);
        next.Insert(to, moved);
        return next;
    }

    /// <summary>Assemble + count + lint in one pass — the island's live report.</summary>
    public static PromptReport BuildReport(
        IReadOnlyList<PromptBlock> blocks, string contentType = "prose")
    {
        var assembled = AssemblePrompt(blocks);
        long tokens = assembled.Length > 0 ? EstimateTokens(assembled, contentType) : 0;
        var warnings = new List<string>();
        if (tokens > SYSTEM_PROMPT_SOFT_LIMIT_TOKENS)
        {
            warnings.Add(
                $"Assembled prompt is ~{tokens:N0} tokens — beyond " +
                $"{SYSTEM_PROMPT_SOFT_LIMIT_TOKENS:N0} it starts crowding the context " +
                "window on most models.");
        }
        if (blocks.Count > 0
            && !blocks.Any(b => b.Enabled && b.Title.Trim().ToLowerInvariant() == "role"))
        {
            warnings.Add(
                "No enabled \"Role\" block — stating who the model is tends to anchor " +
                "every following instruction.");
        }
        if (blocks.Count > 0 && assembled.Length == 0)
        {
            warnings.Add("Every block is disabled or empty — the assembled prompt is empty.");
        }
        return new PromptReport(assembled, tokens, warnings);
    }

    // ---- shareable state codec (URL-safe, compact) -----------------------
    // Triples of [enabled(0/1), title, content] keep URLs far smaller than the
    // full object shape; ids are regenerated on decode (they are UI-local).

    private const int MAX_ENCODED_LENGTH = 4000;

    private static string ToBase64Url(string s)
    {
        return Convert.ToBase64String(Encoding.UTF8.GetBytes(s))
            .TrimEnd('=')
            .Replace('+', '-')
            .Replace('/', '_');
    }

    private static string FromBase64Url(string s)
    {
        var b64 = s.Replace('-', '+').Replace('_', '/');
        return Encoding.UTF8.GetString(Convert.FromBase64String(
            b64 + new string('=', (4 - s.Length % 4) % 4)));
    }

    /// <summary>Encode blocks to a compact base64url string; "" when blocks are empty.</summary>
    public static string EncodeBlocks(IReadOnlyList<PromptBlock> blocks)
    {
        if (blocks.Count == 0) return "";
        var compact = blocks
            .Select(b => new object[] { b.Enabled ? 1 : 0, b.Title, b.Content })
            .ToArray();
        return ToBase64Url(JsonSerializer.Serialize(compact));
    }

    /// <summary>True when the encoded form would make an uncomfortably long URL.</summary>
    public static bool EncodedTooLong(string encoded)
    {
        return encoded.Length > MAX_ENCODED_LENGTH;
    }

    /// <summary>
    /// Decode <see cref="EncodeBlocks"/> output; regenerates ids (b1, b2, …).
    /// Returns null on malformed input — never throws.
    /// </summary>
    public static List<PromptBlock>? DecodeBlocks(string encoded)
    {
        if (encoded.Length == 0) return new List<PromptBlock>();
        try
        {
            using var doc = JsonDocument.Parse(FromBase64Url(encoded));
            if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
            var blocks = new List<PromptBlock>();
            int i = 0;
            foreach (var entry in doc.RootElement.EnumerateArray())
            {
                if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() != 3)
                {
                    return null;
                }
                var enabled = entry[0];
                var title = entry[1];
                var content = entry[2];
                if (enabled.ValueKind != JsonValueKind.Number
                    || title.ValueKind != JsonValueKind.String
                    || content.ValueKind != JsonValueKind.String)
                {
                    return null;
                }
                i++;
                blocks.Add(new PromptBlock(
                    $"b{i}", title.GetString() ?? "", content.GetString() ?? "",
                    enabled.GetInt32() == 1));
            }
            return blocks;
        }
        catch
        {
            return null;
        }
    }

    /// <summary>
    /// The prose path of the tokenEstimator, inlined: every non-empty line
    /// costs max(1, round(length / 4)) tokens; empty text is 0.
    /// </summary>
    private static long EstimateTokens(string text, string contentType = "prose")
    {
        if (text.Length == 0) return 0;
        long tokens = 0;
        foreach (var line in text.Split('\n'))
        {
            if (line.Length > 0)
            {
                tokens += Math.Max(1, (long)Math.Round(line.Length / 4.0,
                    MidpointRounding.AwayFromZero));
            }
        }
        return tokens;
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →