System Prompt Builder — JavaScript 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 JavaScript 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: JavaScript (ES2022+, ES module; runs unmodified in Node 18+
* and modern browsers)
* Source: CosmoDev polyglot showcase port of the System Prompt Builder
* tool, ported from src/lib/systemPromptBuilder.ts (the canonical
* TypeScript implementation).
* Tool page: https://dev.cosmolabs.org/tools/system-prompt-builder
* License: display source — part of CosmoDev's polyglot tool pages.
*
* Token counting inlines the chars-per-token heuristic from
* src/lib/tokenEstimator.ts (the original imports it); the soft-limit
* constant lives here as the domain rule.
*/
/** One editable section of the system prompt. */
// interface PromptBlock { id, title, content, enabled }
// interface PromptPreset { id, title, description, content }
// interface PromptReport { assembled, tokens, warnings }
/** 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 = [
{
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.',
},
];
// ---- token estimate (tokens figure only, from tokenEstimator.ts) ------------
const CHARS_PER_TOKEN = { prose: 4, code: 3.5, json: 3, cjk: 1.5 };
const CJK_RE = /[一-鿿-ヿ가-]/;
const CODE_SYMBOL_RE = /[{}();=<>\[\]#]/g;
/** Classify a single line by its shape. Order: json, cjk, code, prose. */
function detectLineType(line) {
const trimmed = line.trim();
// JSON-ish: opens like a JSON fragment AND carries a separator.
if ((trimmed.startsWith('{') || trimmed.startsWith('}') || trimmed.startsWith('[') || trimmed.startsWith('"')) && (line.includes(':') || line.includes(','))) {
return 'json';
}
// CJK ideographs / kana / Hangul pack roughly one token per 1.5 chars.
if (CJK_RE.test(line)) return 'cjk';
// Code: symbol-dense, or a statement terminator / block opener at EOL.
const density = (line.match(CODE_SYMBOL_RE) ?? []).length / line.length;
if (density > 0.08 || trimmed.endsWith(';') || trimmed.endsWith('{') || trimmed.endsWith('}')) {
return 'code';
}
return 'prose';
}
/** Sum of per-line token estimates (excludes chat framing). `type` may be
* 'prose' | 'code' | 'json' | 'cjk' | 'auto'; 'auto' classifies per line,
* with a document that parses as JSON counted as json throughout. */
function estimateTokens(text, type = 'prose') {
const forced = type && type !== 'auto' ? type : null;
let wholeTextJson = false;
if (forced === null && text.trim()) {
try {
JSON.parse(text);
wholeTextJson = true;
} catch {
/* not JSON — classify line by line */
}
}
let tokens = 0;
for (const line of text.split(/\r?\n/)) {
if (line.trim() === '') continue;
const t = forced ?? (wholeTextJson ? 'json' : detectLineType(line));
tokens += Math.max(1, Math.round(line.length / CHARS_PER_TOKEN[t]));
}
return tokens;
}
/** Render enabled, non-empty blocks (in order) as one markdown-structured prompt. */
export function assemblePrompt(blocks, opts = {}) {
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, id, title, content = '', enabled = true) {
return [...blocks, { id, title, content, enabled }];
}
/** Patch one block by id; unknown ids leave the list unchanged. */
export function updateBlock(blocks, id, patch) {
return blocks.map((b) => (b.id === id ? { ...b, ...patch } : b));
}
/** Flip one block's enabled flag by id. */
export function toggleBlock(blocks, id) {
return blocks.map((b) => (b.id === id ? { ...b, enabled: !b.enabled } : b));
}
/** Remove one block by id. */
export function removeBlock(blocks, id) {
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, from, to) {
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;
}
/** Assemble + count + lint in one pass — the island's live report. */
export function buildReport(blocks, contentType = 'prose') {
const assembled = assemblePrompt(blocks);
const tokens = assembled ? estimateTokens(assembled, contentType) : 0;
const warnings = [];
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) {
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) {
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) {
if (blocks.length === 0) return '';
const compact = blocks.map((b) => [b.enabled ? 1 : 0, b.title, b.content]);
return toBase64Url(JSON.stringify(compact));
}
/** True when the encoded form would make an uncomfortably long URL. */
export function encodedTooLong(encoded) {
return encoded.length > MAX_ENCODED_LENGTH;
}
/** Decode `encodeBlocks` output; regenerates ids (b1, b2, …). Returns null on
* malformed input — never throws. */
export function decodeBlocks(encoded) {
if (!encoded) return [];
try {
const raw = JSON.parse(fromBase64Url(encoded));
if (!Array.isArray(raw)) return null;
const blocks = [];
for (const [i, entry] of raw.entries()) {
if (!Array.isArray(entry) || entry.length !== 3) return null;
const [enabled, title, content] = entry;
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 →