Skip to content

System Prompt Builder — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Pure logic for the System Prompt Builder tool (slug: system-prompt-builder).
// Assembles 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. Token counting reuses tokenEstimator at the
// island layer; the soft-limit constant lives here as the domain rule.
import type { AutoType } from './tokenEstimator';
import { estimateTokens } from './tokenEstimator';

export interface PromptBlock {
  id: string;
  title: string;
  content: string;
  enabled: boolean;
}

export interface PromptPreset {
  id: string;
  title: string;
  description: string;
  content: string;
}

/** Blocks whose assembled size starts crowding the context on most models. */
export const SYSTEM_PROMPT_SOFT_LIMIT_TOKENS = 2000;

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

/** Render enabled, non-empty blocks (in order) as one markdown-structured prompt. */
export function assemblePrompt(
  blocks: readonly PromptBlock[],
  opts: { headers?: boolean } = {},
): string {
  const { headers = true } = opts;
  return blocks
    .filter((b) => b.enabled && b.content.trim().length > 0)
    .map((b) => (headers ? `## ${b.title.trim() || 'Untitled'}\n${b.content.trim()}` : b.content.trim()))
    .join('\n\n')
    .trim();
}

/** Append a block (caller supplies the id so the lib stays pure). */
export function addBlock(
  blocks: readonly PromptBlock[],
  id: string,
  title: string,
  content = '',
  enabled = true,
): PromptBlock[] {
  return [...blocks, { id, title, content, enabled }];
}

/** Patch one block by id; unknown ids leave the list unchanged. */
export function updateBlock(
  blocks: readonly PromptBlock[],
  id: string,
  patch: Partial<Omit<PromptBlock, 'id'>>,
): PromptBlock[] {
  return blocks.map((b) => (b.id === id ? { ...b, ...patch } : b));
}

/** Flip one block's enabled flag by id. */
export function toggleBlock(blocks: readonly PromptBlock[], id: string): PromptBlock[] {
  return blocks.map((b) => (b.id === id ? { ...b, enabled: !b.enabled } : b));
}

/** Remove one block by id. */
export function removeBlock(blocks: readonly PromptBlock[], id: string): PromptBlock[] {
  return blocks.filter((b) => b.id !== id);
}

/** Move a block (clamped; no-op when indexes are out of range or equal). */
export function moveBlock(blocks: readonly PromptBlock[], from: number, to: number): PromptBlock[] {
  if (from < 0 || from >= blocks.length || to < 0 || to >= blocks.length || from === to) {
    return [...blocks];
  }
  const next = [...blocks];
  const [moved] = next.splice(from, 1);
  next.splice(to, 0, moved);
  return next;
}

export interface PromptReport {
  assembled: string;
  tokens: number;
  warnings: string[];
}

/** Assemble + count + lint in one pass — the island's live report. */
export function buildReport(
  blocks: readonly PromptBlock[],
  contentType: AutoType = 'prose',
): PromptReport {
  const assembled = assemblePrompt(blocks);
  const tokens = assembled ? estimateTokens(assembled, { type: contentType }).tokens : 0;
  const warnings: string[] = [];
  if (tokens > SYSTEM_PROMPT_SOFT_LIMIT_TOKENS) {
    warnings.push(
      `Assembled prompt is ~${tokens.toLocaleString('en-US')} tokens — beyond ${SYSTEM_PROMPT_SOFT_LIMIT_TOKENS.toLocaleString('en-US')} it starts crowding the context window on most models.`,
    );
  }
  if (blocks.length > 0 && !blocks.some((b) => b.enabled && b.title.trim().toLowerCase() === 'role')) {
    warnings.push('No enabled "Role" block — stating who the model is tends to anchor every following instruction.');
  }
  if (blocks.length > 0 && assembled === '') {
    warnings.push('Every block is disabled or empty — the assembled prompt is empty.');
  }
  return { 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).

const MAX_ENCODED_LENGTH = 4000;

function toBase64Url(s: string): string {
  const bytes = new TextEncoder().encode(s);
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function fromBase64Url(s: string): string {
  const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4);
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

/** Encode blocks to a compact base64url string; '' when blocks are empty. */
export function encodeBlocks(blocks: readonly PromptBlock[]): string {
  if (blocks.length === 0) return '';
  const compact = blocks.map((b) => [b.enabled ? 1 : 0, b.title, b.content] as const);
  return toBase64Url(JSON.stringify(compact));
}

/** True when the encoded form would make an uncomfortably long URL. */
export function encodedTooLong(encoded: string): boolean {
  return encoded.length > MAX_ENCODED_LENGTH;
}

/** Decode `encodeBlocks` output; regenerates ids (b1, b2, …). Returns null on
 *  malformed input — never throws. */
export function decodeBlocks(encoded: string): PromptBlock[] | null {
  if (!encoded) return [];
  try {
    const raw: unknown = JSON.parse(fromBase64Url(encoded));
    if (!Array.isArray(raw)) return null;
    const blocks: PromptBlock[] = [];
    for (const [i, entry] of raw.entries()) {
      if (!Array.isArray(entry) || entry.length !== 3) return null;
      const [enabled, title, content] = entry as unknown[];
      if (typeof enabled !== 'number' || typeof title !== 'string' || typeof content !== 'string') {
        return null;
      }
      blocks.push({ id: `b${i + 1}`, title, content, enabled: enabled === 1 });
    }
    return blocks;
  } catch {
    return null;
  }
}

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 →