diff --git a/packages/core/src/prompter/example.ts b/packages/core/src/prompter/example.ts
new file mode 100644
index 0000000000..e38e8b5285
--- /dev/null
+++ b/packages/core/src/prompter/example.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Prompt, prompt } from './prompter.js';
+
+// prompts can be functions that depend on context
+export const identity = prompt(
+ (ctx) =>
+ `You are Gemini CLI, an ${ctx.preamble?.interactive ? 'interactive' : 'autonomous'} CLI agent.`,
+);
+
+// or they can be objects representing an XML or Markdown section
+export const securityMandates = prompt({
+ tag: 'security',
+ attrs: { importance: 'MAXIMUM' },
+ content: 'Never log or commit secrets. Protect .env and .git folders.',
+});
+
+// they can compose together, with individual content parts resolving based
+// on context and not rendering if conditions aren't met
+export const subagents = prompt({
+ id: 'subagents',
+ heading: 'Available Subagents',
+ condition: (ctx) => (ctx.subAgents?.length ?? 0) > 0,
+ content: [
+ 'Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise.',
+ {
+ tag: 'available_subagents',
+ content: (ctx) =>
+ ctx.subAgents?.map((s) => `- ${s.name}: ${s.description}`).join('\n') ||
+ '',
+ },
+ ],
+});
+
+export const emptySection = prompt({
+ heading: 'This Should Not Render',
+ content: '', // Empty content causes section to be skipped
+});
+
+const myPrompt = new Prompt(
+ identity,
+ {
+ id: 'general_guidance',
+ heading: 'General Guidance',
+ content: [
+ 'The following sections tell you how to behave. ALWAYS FOLLOW.',
+ securityMandates,
+ ],
+ },
+ { heading: 'Stuff You Can Use', content: subagents },
+ emptySection,
+);
+
+// We can add new prompt elements dynamically
+myPrompt.add('Here is an added note.');
+
+// We can dynamically contribute to sections by ID, allowing for
+// post-facto composability when adding new features
+myPrompt.contribute({
+ general_guidance: 'Be nice to humans.',
+ subagents: 'Consider using the web_search subagent for recent info.',
+});
+
+//eslint-disable-next-line no-console
+console.log(
+ await myPrompt.render({
+ preamble: { interactive: true },
+ subAgents: [{ name: 'foo', description: 'does foo stuff' }],
+ }),
+);
diff --git a/packages/core/src/prompter/prompter.test.ts b/packages/core/src/prompter/prompter.test.ts
new file mode 100644
index 0000000000..0d099f44ba
--- /dev/null
+++ b/packages/core/src/prompter/prompter.test.ts
@@ -0,0 +1,162 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from 'vitest';
+import { Prompt } from './prompter.js';
+
+describe('Prompt', () => {
+ it('renders a simple string', async () => {
+ const prompt = new Prompt('Hello world');
+ expect(await prompt.render({})).toBe('Hello world');
+ });
+
+ it('renders a function content resolving dynamically', async () => {
+ const prompt = new Prompt<{ name: string }>((ctx) => `Hello ${ctx.name}`);
+ expect(await prompt.render({ name: 'Alice' })).toBe('Hello Alice');
+ });
+
+ it('renders an array of contents', async () => {
+ const prompt = new Prompt(['Part 1', 'Part 2']);
+ expect(await prompt.render({})).toBe('Part 1\n\nPart 2');
+ });
+
+ it('renders a section with heading', async () => {
+ const prompt = new Prompt({
+ heading: 'My Section',
+ content: 'This is the body',
+ });
+ expect(await prompt.render({})).toBe('# My Section\n\nThis is the body');
+ });
+
+ it('renders a section with tag and attrs', async () => {
+ const prompt = new Prompt({
+ tag: 'foo',
+ attrs: { bar: 'baz' },
+ content: 'Hello',
+ });
+ expect(await prompt.render({})).toBe('\nHello\n');
+ });
+
+ it('allows adding content via .add()', async () => {
+ const prompt = new Prompt('Original');
+ prompt.add('Added later');
+ expect(await prompt.render({})).toBe('Original\n\nAdded later');
+ });
+
+ it('conditionally omits rendering a section based on condition', async () => {
+ const prompt = new Prompt<{ shouldRender: boolean }>(
+ {
+ heading: 'Conditional',
+ condition: (ctx) => ctx.shouldRender,
+ content: 'This might not appear',
+ },
+ 'Always appears',
+ );
+ expect(await prompt.render({ shouldRender: false })).toBe('Always appears');
+ expect(await prompt.render({ shouldRender: true })).toBe(
+ '# Conditional\n\nThis might not appear\n\nAlways appears',
+ );
+ });
+
+ it('conditionally omits rendering a section based on an async condition', async () => {
+ const prompt = new Prompt<{ shouldRender: boolean }>(
+ {
+ heading: 'Conditional',
+ condition: async (ctx) => ctx.shouldRender,
+ content: 'This might not appear',
+ },
+ 'Always appears',
+ );
+ expect(await prompt.render({ shouldRender: false })).toBe('Always appears');
+ expect(await prompt.render({ shouldRender: true })).toBe(
+ '# Conditional\n\nThis might not appear\n\nAlways appears',
+ );
+ });
+
+ it('allows dynamic contributions via .contribute()', async () => {
+ const prompt = new Prompt(
+ {
+ id: 'target',
+ heading: 'Target Section',
+ content: ['Initial content'],
+ },
+ {
+ id: 'other',
+ content: 'Other content',
+ },
+ );
+
+ prompt.contribute({
+ target: 'Contributed content',
+ missing: 'Should be ignored',
+ });
+
+ const result = await prompt.render({});
+ expect(result).toBe(
+ '# Target Section\n\nInitial content\n\nContributed content\n\nOther content',
+ );
+ });
+
+ it('converts single content into an array when contributing', async () => {
+ const prompt = new Prompt({
+ id: 'target',
+ heading: 'Target Section',
+ content: 'Initial content', // String, not array
+ });
+
+ prompt.contribute({
+ target: 'Contributed content',
+ });
+
+ const result = await prompt.render({});
+ expect(result).toBe(
+ '# Target Section\n\nInitial content\n\nContributed content',
+ );
+ });
+
+ it('skips rendering headings and tags if the content is empty', async () => {
+ const prompt = new Prompt(
+ {
+ heading: 'Empty Section',
+ tag: 'empty',
+ content: '', // Empty string
+ },
+ {
+ heading: 'Empty Array Section',
+ content: [], // Empty array
+ },
+ {
+ heading: 'Function resolving to empty',
+ content: () => '',
+ },
+ 'Visible content',
+ );
+
+ expect(await prompt.render({})).toBe('Visible content');
+ });
+
+ it('handles nested structures properly in contribute', async () => {
+ const prompt = new Prompt({
+ heading: 'Outer',
+ content: [
+ {
+ id: 'inner',
+ heading: 'Inner',
+ content: 'Inner content',
+ },
+ ],
+ });
+
+ prompt.contribute({
+ inner: 'Injected into inner',
+ });
+
+ const result = await prompt.render({});
+ expect(result).toBe(
+ '# Outer\n\n## Inner\n\nInner content\n\nInjected into inner',
+ );
+ });
+});
diff --git a/packages/core/src/prompter/prompter.ts b/packages/core/src/prompter/prompter.ts
new file mode 100644
index 0000000000..3b336d2b71
--- /dev/null
+++ b/packages/core/src/prompter/prompter.ts
@@ -0,0 +1,139 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { SystemPromptOptions } from 'src/prompts/snippets.js';
+import type { PromptContent, PromptSection } from './types.js';
+
+// Helper to stringify XML attributes cleanly
+function renderAttributes(attrs?: Record): string {
+ if (!attrs) return '';
+ return Object.entries(attrs)
+ .map(([key, value]) => ` ${key}="${value}"`)
+ .join('');
+}
+
+export class Prompt {
+ private contents: Array>;
+
+ constructor(...contents: Array>) {
+ this.contents = contents;
+ }
+
+ add(content: PromptContent): void {
+ this.contents.push(content);
+ }
+
+ contribute(contributions: Record>): void {
+ const traverse = (node: PromptContent) => {
+ if (Array.isArray(node)) {
+ node.forEach(traverse);
+ } else if (typeof node === 'object' && node !== null) {
+ // In this branch, node is a PromptSection
+ const section = node;
+ const contribution = contributions[section.id ?? ''];
+ if (section.id && contribution) {
+ const content = section.content;
+ if (Array.isArray(content)) {
+ content.push(contribution);
+ } else {
+ section.content = [content, contribution];
+ }
+ }
+ traverse(section.content);
+ }
+ };
+
+ this.contents.forEach(traverse);
+ }
+
+ async render(context: C, options?: { depth?: number }): Promise {
+ const parts = await Promise.all(
+ this.contents.map((item) => Prompt.renderContent(context, item, options)),
+ );
+ return parts.filter((part) => part.length > 0).join('\n\n');
+ }
+
+ private static async renderContent(
+ context: C,
+ content: PromptContent,
+ options?: { depth?: number },
+ ): Promise {
+ // 1. if function: run function with context and process result
+ if (typeof content === 'function') {
+ const resolved = await content(context);
+ // keep passing options down so depth isn't lost
+ return Prompt.renderContent(context, resolved, options);
+ }
+
+ // 2. if string: simple string append
+ if (typeof content === 'string') {
+ return content;
+ }
+
+ // 3. if array: process and concatenate each item in the array
+ if (Array.isArray(content)) {
+ const parts = await Promise.all(
+ content.map((item) => Prompt.renderContent(context, item, options)),
+ );
+ // Filter out empty strings to prevent huge gaps, then separate with double newline
+ return parts.filter((part) => part.length > 0).join('\n\n');
+ }
+
+ // 4. if object: process as section
+ if (typeof content === 'object' && content !== null) {
+ if (content.condition) {
+ const shouldRender = await content.condition(context);
+ if (!shouldRender) {
+ return '';
+ }
+ }
+ return Prompt.renderSection(context, content, options);
+ }
+
+ return '';
+ }
+
+ private static async renderSection(
+ context: C,
+ section: PromptSection,
+ options?: { depth?: number },
+ ): Promise {
+ const depth = options?.depth ?? 1;
+ // Standard Markdown headings max out at h6 (######)
+ const headingLevel = Math.min(depth, 6);
+
+ // Pass context and increment depth for nested sections
+ // section.content is an array, which renderPrompt already knows how to handle
+ const innerContent = await Prompt.renderContent(context, section.content, {
+ depth: depth + 1,
+ });
+
+ if (!innerContent) {
+ return '';
+ }
+
+ let result = '';
+
+ if (section.heading) {
+ result += `${'#'.repeat(headingLevel)} ${section.heading}\n\n`;
+ }
+
+ if (section.tag) {
+ const attrs = renderAttributes(section.attrs);
+ result += `<${section.tag}${attrs}>\n${innerContent}\n${section.tag}>`;
+ } else {
+ result += innerContent;
+ }
+
+ return result.trim();
+ }
+}
+
+export function prompt(
+ content: PromptContent,
+): PromptContent {
+ return content;
+}
diff --git a/packages/core/src/prompter/types.ts b/packages/core/src/prompter/types.ts
new file mode 100644
index 0000000000..84a6f01c48
--- /dev/null
+++ b/packages/core/src/prompter/types.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export type ContextResolver = O | ((ctx: C) => O | Promise);
+
+export type PromptSection = {
+ /** Unique identifier for the section if it can receive contributions. */
+ id?: string;
+ /** Add a Markdown heading of appropriate level to this section. */
+ heading?: string;
+ /** If supplied, wrap this section in an XML tag. */
+ tag?: string;
+ /** If supplied, add attributes to the XML section tag. */
+ attrs?: Record;
+
+ /** Condition that must evaluate to true for the section to be rendered. */
+ condition?: (ctx: C) => boolean | Promise;
+
+ // Notice we use the generic here so the children know about the context
+ content: PromptContent;
+};
+
+// The core recursive type.
+// It wraps your 3 base node shapes (string, section, or array) in the resolver.
+export type PromptContent = ContextResolver<
+ C,
+ string | PromptSection | Array>
+>;