Prompts
Compose system prompts from typed contributions — namespaced ids, static vs runtime-aware split, budgeted composition, and the replay-safety invariant.
Prompts are a thematic island. Not one of the six cluster triplets — the prompts surface is a pair (the contribution registry on this page + the budgeted composer), not a three-concept cluster. See What lives outside the cluster pattern.
There is no systemPrompt string. System prompts are composed
from a registry of typed contributions: each one carries a
namespaced id, a composition mode (append / prepend / replace),
an optional scope filter (call class, family, runtime role), and
content (a string or a context-aware function).
The split between static and runtime-aware contributions is load-bearing for replay determinism: static contributions participate in the fingerprint cache key, runtime-aware ones are fingerprint-excluded. See Fingerprint for the cache-key contract and Prompt builder for the composer.
import {
PromptContributionRegistry,
resolvePromptContributions,
registerGlobal,
promptContributionId,
appendPrompt,
prependPersona,
replaceCore,
scopedPrompt,
gatedPrompt,
createPlugin,
ReservedNamespaceError,
UnnamespacedIdError,
DuplicateContributionIdError,
} from "@pleach/core/prompts";
import { composeBudgetedPrompt } from "@pleach/core/prompt-builder";
import type {
CallClass,
ProviderFamily,
PromptContribution,
PromptContributionId,
PromptContributionMode,
PromptContributionScope,
PromptContext,
ResolvedPromptBundle,
RuntimeRoles,
RuntimeStateSnapshot,
} from "@pleach/core/prompts";@pleach/core/promptsSourcesrc/prompts/The contribution shape
interface PromptContribution {
readonly id: PromptContributionId; // "<plugin>.<name>"
readonly mode: "append" | "prepend" | "replace";
readonly scope?: PromptContributionScope; // filter by callClass / family / runtimeRole
readonly content:
| string
| ((ctx: PromptContext) => string)
| ((ctx: PromptContext, state: RuntimeStateSnapshot) => string);
}| Field | Purpose |
|---|---|
id | Namespaced identifier. core.* reserved; plugins MUST use <plugin-name>.* |
mode | How this contribution combines — append/prepend stacks; replace overrides a same-id contribution from another origin |
scope | Optional filter. Contribution only fires when the call's callClass / family / runtimeRole matches |
content | Verbatim string, a static (ctx) => string, or a runtime-aware (ctx, state) => string. The resolver dispatches by function arity |
Composition order is registration order, preserved across
buckets. There is no priority field — order is the source of
truth; if you need finer control, register earlier or use
mode: "replace" against a known id.
Namespace rules
The registry enforces these at registration time — your plugin's own test suite fails fast, never in production.
| Rule | Error |
|---|---|
core.* is reserved | ReservedNamespaceError |
| Unnamespaced ids are forbidden | UnnamespacedIdError |
| Duplicate ids within the same origin | DuplicateContributionIdError |
To override a core.* or another plugin's contribution, register
your own with mode: "replace" and the same id — the registry
treats replace across origins as an explicit override.
Static vs runtime-aware
Two hooks, two buckets, one invariant.
| Bucket | Fired from | Fingerprint? | When to use |
|---|---|---|---|
| Static | HarnessPlugin.contributePrompts() | Eligible — participates in cache key | Contributions whose content depends only on PromptContext (callClass, family, tenantId, runtimeRole) |
| Runtime-aware | HarnessPlugin.contributeRuntimeAwarePrompts() | Excluded — re-collected per turn | Contributions whose content depends on session state (recent tool results, in-flight jobs, current artifacts) |
The structural split is what keeps replay deterministic. Static contributions can be safely included in the fingerprint; if they referenced runtime state, two runs of the "same" turn would hash differently. The contract says: if you read session state, declare yourself runtime-aware.
const myPlugin: HarnessPlugin = {
name: "my-plugin",
contributePrompts: () => [
{
id: "my-plugin.domain-instructions",
mode: "append",
scope: { callClass: "synthesize" },
content: "Respond in the domain-specific output format.",
},
],
contributeRuntimeAwarePrompts: (ctx, snapshot) => {
const loaded = snapshot.loadedTools ?? [];
if (loaded.length === 0) return [];
return [{
id: promptContributionId("my-plugin.loaded-tools"),
mode: "append",
content: `Available this turn: ${loaded.join(", ")}.`,
}];
},
};PromptContext (static)
What the static hook receives. Fingerprint-eligible — every field is part of the cache key.
| Field | Type |
|---|---|
callClass | "utility" | "reasoning" | "converse" | "synthesize" |
family | ProviderFamily ("anthropic" | "openai" | ...) |
tenantId | string? |
runtimeRole | A consumer-augmentable role enum |
Augmenting runtime roles
The role enum is empty by default; consumers extend it via TS module augmentation:
declare module "@pleach/core/prompts" {
interface RuntimeRoles {
admin: true;
contributor: true;
reviewer: true;
}
}Then scope contributions to your role:
{
id: "my-plugin.admin-only",
mode: "append",
scope: { runtimeRole: "admin" },
content: "You may use destructive tools.",
}RuntimeStateSnapshot (runtime-aware)
What the runtime-aware hook receives in addition to
PromptContext. Carries per-turn state; surfaced in
FingerprintMetadata but never participates in the lookup key.
| Field | Type |
|---|---|
loadedTools | readonly string[]? — tool names registered for this seam |
detectedIntents | readonly string[]? — intent classifier output |
turnDepth | number — 0 for the root turn, >0 inside subagents |
priorTurnHadToolCalls | boolean? — set on retries / recovery paths |
pluginMetadata | Record<string, unknown>? — opaque per-plugin metadata; plugin authors own the shape |
Read what you need, but be deliberate — anything you reference here means the contribution can't be replayed without that state being present.
Composition via composeBudgetedPrompt
The composer is character-budget-aware. Resolve contributions
through the registry, wrap each as a BudgetedSection with a
tier, and hand the candidate list to the composer. See
Prompt builder for the full surface; the
short form:
import { composeBudgetedPrompt } from "@pleach/core/prompt-builder";
const result = composeBudgetedPrompt(
candidates, // BudgetedSection[]
{ depth: 0, totalBudget: 40_000 }, // BudgetConfig
{ onBuilt: (event) => log(event) },
);
// result.composed — joined section text
// result.included — sections that made the budget
// result.dropped — ids of dropped candidates
// result.sectionChars — per-section char counts
// result.budgetUsedPct — used / budget * 100Friendly-API helpers
Six helpers cover the common contribution shapes so plugin code
doesn't repeat the boilerplate. Each expands to a raw
PromptContribution — drop down to the raw shape any time you need
more control.
| Helper | Builds |
|---|---|
appendPrompt(id, content) | An append-mode contribution. Default for the 90% case |
prependPersona(id, content) | A prepend-mode persona block — sits at the top of the composed prompt |
replaceCore(coreSlotId, content) | A mode: "replace" against a core.* slot. coreSlotId is typed `core.${string}` — non-core.* ids are a compile-time error |
scopedPrompt({ id, callClass?, family?, runtimeRole?, content, mode? }) | Flattened scope shape — pass scope fields directly without nesting. mode defaults to "append" |
gatedPrompt({ id, when, content, mode? }) | A runtime-aware contribution. When when(ctx, state) returns false the helper emits "" — fires the onEmptyContent probe for audit trails |
createPlugin({ name, version, prompts?, runtimePrompts?, safetyPolicies?, streamObservers? }) | Packages a HarnessPlugin from a flat config — hides the two-hook split |
// lib/plugins/myPlugin.ts
import {
appendPrompt,
prependPersona,
replaceCore,
scopedPrompt,
gatedPrompt,
createPlugin,
} from "@pleach/core/prompts";
// append-mode (the 90% case)
appendPrompt("my-plugin.outro", "Cite sources for every claim.");
// persona at the top of the prompt
prependPersona("my-plugin.persona", "You are a careful research assistant.");
// substitute a core.* slot
replaceCore("core.response-stylesheet-defaults", "Use code blocks. Be concise.");
// scope to a specific family without nesting
scopedPrompt({
id: "my-plugin.gemini-tweak",
family: "google",
content: "Tool call budget: 3-4 per response batch.",
});
// runtime-gated — emits "" when the predicate is false
gatedPrompt({
id: "my-plugin.modal-guidance",
when: (_, state) => state.loadedTools?.includes("modal") ?? false,
content: "Modal compute is available this turn.",
});
// hide the two-hook split entirely
export const myPlugin = createPlugin({
name: "my-plugin",
version: "1.0.0",
prompts: [
appendPrompt("my-plugin.intro", "I cite sources for every claim."),
],
runtimePrompts: [
gatedPrompt({
id: "my-plugin.modal",
when: (_, s) => s.loadedTools?.includes("modal") ?? false,
content: "Modal compute is available this turn.",
}),
],
});The onEmptyContent resolver probe
The resolver fires onEmptyContent(id, origin) whenever a registered
contribution resolves to an empty string — either a literal "" or a
content function that returns "". Useful for catching dead
contributions in production: a contribution that always resolves to
empty is doing no work and probably wants retiring.
Wire it through ResolverProbeSink:
import type { ResolverProbeSink } from "@pleach/core/prompts";
const probes: ResolverProbeSink = {
onEmptyContent: (id, origin) => {
metrics.increment("prompt.empty", { id, origin });
},
onReplaceTargetMissing: (id) => log.warn(`replace target missing: ${id}`),
onDuplicateReplace: (ids) => log.warn(`duplicate replace: ${ids.join(", ")}`),
};Probes are observability-only — the resolver's behavior does not depend on the return value. They MUST NOT throw; an uncaught throw aborts resolution.
Multi-slot replace composition
The resolver composes multiple replace-mode contributions targeting
the same id correctly: each replace's body lands in registration order,
joined with a paragraph break. Earlier behavior was last-wins with the
prior body silently discarded and a [PluginContract:duplicate-prompt-replace]
diagnostic emitted — that is no longer the case.
Two host plugins independently replacing core.response-stylesheet-defaults
both contribute; the order is registration order; the host that wants
total control still uses replace against a uniquely-named id rather
than fighting another plugin for the same slot.
The regression-lock test
__tests__/core/prompts/multiSlotReplaceComposition.test.ts pins this
contract — it flipped from RED tripwire to GREEN at the resolver
extension landing.
The core.* baseline
seedCoreDefaults: true (the default) auto-seeds the core
baseline. Three exported arrays drive what gets seeded:
| Array | Origin tag | Count |
|---|---|---|
allCoreDefaultContributions | core-default | 10 base ids — see table below |
allCoreFragmentExtensionContributions | core-default | 4 extension ids — see table below |
allCoreFragmentTemplateContributions | core-template | 41 template scaffolds — see table below |
CORE_DEFAULT_SYSTEM_PROMPT_ID is the id used by
defaultSystemPromptContribution; replace that single id to
fully substitute the baseline. Opt out wholesale with
seedCoreDefaults: false; opt into template seeding with
seedCoreTemplates: true.
Base contributions (10)
CORE_DIRECTIVES_ID, FORMATTING_BASELINE_ID, PRE_CALL_CHECKLIST_ID,
ACTION_BEFORE_NARRATION_ID, ANTI_PATTERNS_CORE_ID, BANNED_YIELD_PHRASES_ID,
DATA_INTEGRITY_ID, NO_DATA_PROTOCOL_ID, NO_AGGREGATE_FAILURE_ID,
SUBAGENT_DELEGATION_PRIMITIVE_ID, TOOL_REFERENCE_INTEGRITY_ID.
Extension contributions (4)
ASYNC_JOB_OUTPUT_DEPENDENCY_ID, ASYNC_ORCHESTRATION_RULES_ID,
INDEPENDENT_SUBTASK_RULE_ID, TOOL_AVAILABILITY_VERIFICATION_ID.
Template scaffolds (41)
The allCoreFragmentTemplateContributions array — ALL_CORE_FRAGMENT_TEMPLATE_IDS
is the enumeration tests + tooling key on. Slot-naming suffix -defaults
means the agnostic baseline is shippable as-is; -template means it is a
skeleton the host plugin is expected to flesh out.
| Group | Ids |
|---|---|
| Defaults (shippable) | RESPONSE_STYLESHEET_DEFAULTS_ID, TOOL_ERROR_RECOVERY_DEFAULTS_ID |
| Identity & rules | ROLE_TEMPLATE_ID, RULES_AND_PROTOCOL_TEMPLATE_ID, EXPORT_RULES_TEMPLATE_ID |
| Workflow & planning | WORKFLOW_PROGRESS_TEMPLATE_ID, MULTI_STEP_WORKFLOW_TEMPLATE_ID, SOFT_PLAN_PROGRESS_TEMPLATE_ID |
| Tool surfaces | DYNAMIC_TOOL_AVAILABILITY_TEMPLATE_ID, LOADED_TOOLS_CONDENSED_TEMPLATE_ID, TOOL_BUDGET_CONSTRAINT_TEMPLATE_ID, TOOL_FAILURE_RECOVERY_TEMPLATE_ID |
| Code execution | CODE_EXECUTION_TEMPLATE_ID, CODE_EXECUTION_GUEST_TEMPLATE_ID |
| Search & data | SEARCH_STRATEGY_TEMPLATE_ID, DATA_REF_ATTRIBUTION_TEMPLATE_ID, OFFLOADED_RESULT_EXPLORATION_TEMPLATE_ID |
| Async jobs | ASYNC_JOB_LIFECYCLE_TEMPLATE_ID, JOB_HISTORY_TEMPLATE_ID |
| Delegation | SUBAGENT_DELEGATION_TEMPLATE_ID, SUBAGENT_DELEGATION_GUEST_TEMPLATE_ID |
| Context surfaces | INTENT_CONTEXT_TEMPLATE_ID, DOMAIN_CONTEXT_TEMPLATE_ID, APP_STATE_MANIFEST_TEMPLATE_ID, ARTIFACT_CONTEXT_TEMPLATE_ID, META_LEARNING_CONTEXT_TEMPLATE_ID, EXTRACTED_FACTS_TEMPLATE_ID |
| Guidance registry | GUIDANCE_REGISTRY_TEMPLATE_ID, PROACTIVE_EXAMPLES_TEMPLATE_ID, ANTI_PATTERNS_EXTENDED_TEMPLATE_ID |
| Condensed-tier siblings | RULES_CONDENSED_TEMPLATE_ID, ROLE_CONDENSED_TEMPLATE_ID, STYLESHEET_CONDENSED_TEMPLATE_ID |
| Guest-tier siblings | ROLE_TOOL_ALLOWLIST_TEMPLATE_ID, RULES_AND_PROTOCOL_GUEST_TEMPLATE_ID |
| Output discipline | UNCERTAINTY_ACKNOWLEDGMENT_TEMPLATE_ID, CITATION_DISCIPLINE_TEMPLATE_ID, RESPONSE_LENGTH_CALIBRATION_TEMPLATE_ID, STRUCTURED_OUTPUT_DISCIPLINE_TEMPLATE_ID, EVIDENCE_GROUNDING_TEMPLATE_ID |
Phase E.0 additive-scaffold protocol
Templates ship via allCoreFragmentTemplateContributions() —
a separate array from allCoreFragmentContributions() and
allCoreFragmentExtensionContributions(). The template array is
NOT wired into composeDefaultSystemPrompt by default.
That separation is load-bearing. Landing a new template carries no
BYTE_EQUALITY_FIXTURE_BUMP: the composed-default fingerprint stays
byte-identical pre- and post-landing, so caches don't cliff and the
existing byte-equality CI gate stays green. A host opts a template into
composition explicitly (registry registration with seedCoreTemplates: true, or a host-side composer that pulls from the template array); only
then does the fingerprint cliff fire — once, predictably, on the host's
schedule rather than on every library expansion.
The protocol exists so the template library can grow continuously (per-template micro-sessions, one slot at a time) without breaking downstream consumers. Templates are skeletons waiting for a host to opt in, not retroactive changes to anyone's baseline.
Listing the registry
const list = registry.list();
// → RegisteredContribution[]
interface RegisteredContribution {
readonly contribution: PromptContribution;
readonly origin: ContributionOrigin; // "static" | "runtime-aware" | "core-default" | "core-template"
readonly plugin: string | null; // null for core defaults + globals
readonly registrationOrder: number;
}PromptContributionRegistry also exposes get(id) and
listByOrigin(origin) for narrower introspection. Records are
stable across reads — the resolver doesn't mutate them.
Cross-runtime contributions
registerGlobal(contribution, { targetRuntimes, origin? }) is
the escape hatch for cross-cutting telemetry / compliance plugins
that need to observe every runtime. The * literal in
targetRuntimes matches all runtimes. Treat this as the rare
path — it re-introduces shared mutable state and complicates
replay determinism.
Examples
The package ships reference contributions under
@pleach/core/prompts/examples. They exercise every branch of
the contribution contract and round-trip through the resolver
without surprises.
Where prompt content comes from
A composed system prompt is the layered output of four contribution sources, applied in order:
- Core fragments — the bundled core's baseline prompt fragments, identified by
core.<name>ids. - Host
contributePrompts(ctx)— static contributions registered viaHarnessPlugin. - Host
contributeRuntimeAwarePrompts(ctx)— per-turn dynamic contributions. - Safety — composed LAST via the
SafetyPolicyRegistry(see next section).
The composer holds to a byte-equality contract: a refactor of the layering can't silently change the composed byte output for a given input. A fixture-hash test in the reference implementation hashes the composed prompt across a fixture matrix and fails on drift, so consumer plugins don't break on substrate upgrades.
SafetyPolicyRegistry
A per-runtime registry constructed at SessionRuntime construction time. It holds the safety policies whose text gets layered onto every composed prompt.
Configure it via the enabledSafetyPolicies? field on SessionRuntimeConfig — a list of policy ids to enable. A missing field means defaults apply.
Three accessors on the runtime expose the registry state:
| Accessor | Returns |
|---|---|
runtime.listAvailableSafetyPolicies() | Every registered policy, enabled or not. |
runtime.getActiveSafetyPolicies() | Only the currently-enabled subset. |
runtime.getSafetyRegistry() | The live registry object for direct interrogation. |
The registry composes LAST via composeSafetyContent inside resolvePromptForSeam. Safety policy text always rides over host prompt contributions, never under them — a host plugin can't suppress a safety directive by appending later.
See Safety for the individual policies and Facets for the accessor pattern these three methods follow.