# Prompts (/docs/prompts)



> **Prompts are a thematic island.** Not one of the [six cluster
> triplets](/docs/concept-clusters#the-six-cluster-triplets) — the
> prompts surface is a pair (the contribution registry on this
> page + the [budgeted composer](/docs/prompt-builder)), not a
> three-concept cluster. See [What lives outside the cluster
> pattern](/docs/concept-clusters#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](/docs/fingerprint) for the
cache-key contract and [Prompt builder](/docs/prompt-builder) for
the composer.

```typescript
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";
```

<SourceMeta subpath="@pleach/core/prompts" source="{ label: &#x22;src/prompts/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/prompts&#x22; }" />

## The contribution shape [#the-contribution-shape]

```typescript
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 [#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 [#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.

```typescript
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) [#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 [#augmenting-runtime-roles]

The role enum is empty by default; consumers extend it via TS
module augmentation:

```typescript
declare module "@pleach/core/prompts" {
  interface RuntimeRoles {
    admin:       true;
    contributor: true;
    reviewer:    true;
  }
}
```

Then scope contributions to your role:

```typescript
{
  id: "my-plugin.admin-only",
  mode: "append",
  scope: { runtimeRole: "admin" },
  content: "You may use destructive tools.",
}
```

## `RuntimeStateSnapshot` (runtime-aware) [#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` [#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](/docs/prompt-builder) for the full surface; the
short form:

```typescript
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 [#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.

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

```typescript
// 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-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`:

```typescript
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 [#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 [#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) [#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) [#extension-contributions-4]

`ASYNC_JOB_OUTPUT_DEPENDENCY_ID`, `ASYNC_ORCHESTRATION_RULES_ID`,
`INDEPENDENT_SUBTASK_RULE_ID`, `TOOL_AVAILABILITY_VERIFICATION_ID`.

### Template scaffolds (41) [#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 [#phase-e0-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 [#listing-the-registry]

```typescript
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 [#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 [#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 [#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 [#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](/docs/safety) for the individual policies and [Facets](/docs/facets) for the accessor pattern these three methods follow.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Plugin contract" href="/docs/plugin-contract" description="The two hooks (`contributePrompts` / `contributeRuntimeAwarePrompts`) on `HarnessPlugin`." />

  <Card title="Providers" href="/docs/providers" description="Where the composed prompt lands — `AgentExecutionConfig.systemPrompt`." />

  <Card title="AuditableCall row" href="/docs/auditable-call-row" description="The audit row records the fingerprint that includes static contributions." />
</Cards>
