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 →