pleach
Build

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";
Subpath@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);
}
FieldPurpose
idNamespaced identifier. core.* reserved; plugins MUST use <plugin-name>.*
modeHow this contribution combines — append/prepend stacks; replace overrides a same-id contribution from another origin
scopeOptional filter. Contribution only fires when the call's callClass / family / runtimeRole matches
contentVerbatim 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.

RuleError
core.* is reservedReservedNamespaceError
Unnamespaced ids are forbiddenUnnamespacedIdError
Duplicate ids within the same originDuplicateContributionIdError

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.

BucketFired fromFingerprint?When to use
StaticHarnessPlugin.contributePrompts()Eligible — participates in cache keyContributions whose content depends only on PromptContext (callClass, family, tenantId, runtimeRole)
Runtime-awareHarnessPlugin.contributeRuntimeAwarePrompts()Excluded — re-collected per turnContributions 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.

FieldType
callClass"utility" | "reasoning" | "converse" | "synthesize"
familyProviderFamily ("anthropic" | "openai" | ...)
tenantIdstring?
runtimeRoleA 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.

FieldType
loadedToolsreadonly string[]? — tool names registered for this seam
detectedIntentsreadonly string[]? — intent classifier output
turnDepthnumber — 0 for the root turn, >0 inside subagents
priorTurnHadToolCallsboolean? — set on retries / recovery paths
pluginMetadataRecord<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 * 100

Friendly-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.

HelperBuilds
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:

ArrayOrigin tagCount
allCoreDefaultContributionscore-default10 base ids — see table below
allCoreFragmentExtensionContributionscore-default4 extension ids — see table below
allCoreFragmentTemplateContributionscore-template41 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.

GroupIds
Defaults (shippable)RESPONSE_STYLESHEET_DEFAULTS_ID, TOOL_ERROR_RECOVERY_DEFAULTS_ID
Identity & rulesROLE_TEMPLATE_ID, RULES_AND_PROTOCOL_TEMPLATE_ID, EXPORT_RULES_TEMPLATE_ID
Workflow & planningWORKFLOW_PROGRESS_TEMPLATE_ID, MULTI_STEP_WORKFLOW_TEMPLATE_ID, SOFT_PLAN_PROGRESS_TEMPLATE_ID
Tool surfacesDYNAMIC_TOOL_AVAILABILITY_TEMPLATE_ID, LOADED_TOOLS_CONDENSED_TEMPLATE_ID, TOOL_BUDGET_CONSTRAINT_TEMPLATE_ID, TOOL_FAILURE_RECOVERY_TEMPLATE_ID
Code executionCODE_EXECUTION_TEMPLATE_ID, CODE_EXECUTION_GUEST_TEMPLATE_ID
Search & dataSEARCH_STRATEGY_TEMPLATE_ID, DATA_REF_ATTRIBUTION_TEMPLATE_ID, OFFLOADED_RESULT_EXPLORATION_TEMPLATE_ID
Async jobsASYNC_JOB_LIFECYCLE_TEMPLATE_ID, JOB_HISTORY_TEMPLATE_ID
DelegationSUBAGENT_DELEGATION_TEMPLATE_ID, SUBAGENT_DELEGATION_GUEST_TEMPLATE_ID
Context surfacesINTENT_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 registryGUIDANCE_REGISTRY_TEMPLATE_ID, PROACTIVE_EXAMPLES_TEMPLATE_ID, ANTI_PATTERNS_EXTENDED_TEMPLATE_ID
Condensed-tier siblingsRULES_CONDENSED_TEMPLATE_ID, ROLE_CONDENSED_TEMPLATE_ID, STYLESHEET_CONDENSED_TEMPLATE_ID
Guest-tier siblingsROLE_TOOL_ALLOWLIST_TEMPLATE_ID, RULES_AND_PROTOCOL_GUEST_TEMPLATE_ID
Output disciplineUNCERTAINTY_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:

  1. Core fragments — the bundled core's baseline prompt fragments, identified by core.<name> ids.
  2. Host contributePrompts(ctx) — static contributions registered via HarnessPlugin.
  3. Host contributeRuntimeAwarePrompts(ctx) — per-turn dynamic contributions.
  4. 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:

AccessorReturns
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.

Where to go next

On this page