The prompt registry¶
Every piece of text the platform writes for a model, or that steers its behaviour, has a key, a shipped default in a resource file, and optional overrides at three scopes. One resolver is the only way code reads them. This page is for developers who add or migrate a prompt and for operators who change one.
Module: core/prompts (package com.agenticai.core.prompts). It depends only on security and audit, so any
module can depend on it. Configuration: app.prompts.* (see configuration).
Keys¶
A key is two or more lower-case segments joined by dots, <family>.<name>[.<variant>]:
| Key | What it is |
|---|---|
directive.tool_trust, directive.tool_trust.compact |
a standing system-prompt block (full and small-model wording are two keys) |
nudge.continue_unfinished |
a platform note that steers the next model call |
toolresult.park_pending |
text a tool call returns to the model |
refusal.guardrail.policy |
text the user reads when a turn is stopped |
summarizer.compaction, judge.tool_call_verifier, router.complexity, planner.task |
the instruction of a secondary model call |
tool.description.<toolName>, tool.param.<toolName>.<param>, tool.template.<toolName> |
the description of a tool and of its parameters, as sent in the tool list; see Tool descriptions |
Families in use: directive, nudge, toolresult, refusal, message, summarizer, judge, router,
planner, memory, skills, delegation, compaction, guard (detector data), workflow, rag, learning, shim, tool.description, tool.param, tool.template, edge. A new
family needs no registration; the key only has to match PromptKeys.PATTERN (at most 128 characters). A released
key never changes meaning: retire it (retired: true, empty text) and add a new one.
Adding a prompt¶
- Create
src/main/resources/prompts/en/<key>.mdin the module that owns the text. Any module may contribute keys; the catalog scansclasspath*:prompts/*/*.md.
---
title: Tool search
description: When some tools are withheld to save context and reachable via search_tools.
risk: STEERS_EVERY_TURN
securityCritical: false
disableable: true
maxLength: 4000
placeholders: date!, tz
placeholder.date: Today's date, UTC, ISO-8601
---
Text of the prompt, with {date} where the call site supplies a value.
title and risk are mandatory. risk is STEERS_EVERY_TURN, SECONDARY_CALL, USER_VISIBLE or
TOOL_DESCRIPTION. A placeholder name ending in ! is required in every override. disableable: true lets an
override switch the prompt off (standing directives; not the instruction of a secondary call that cannot run
without one). securityCritical: true makes changing or switching it off need acknowledgeRisk.
strictPlaceholders: false is for free text where braces are plain text. minLength / maxLength bound
overrides (default app.prompts.default-max-length). The body is taken byte for byte, except that CRLF becomes LF
and one final line break is dropped. Other locales are prompts/<locale>/<key>.md holding only the body.
2. Ask for the text where it is used:
@Autowired PromptResolver prompts; // a Spring bean from the prompts module
ResolvedPrompt p = prompts.resolve("nudge.call_tool_now",
PromptContext.forAgent(agentId).withVariable("tool", name));
String text = p.textOrNull(); // null: switched off, or unknown key
Hand-built code (static helpers, tests) uses PromptResolver.defaultsOnly(): the shipped text, no database.
3. Add a golden test: the text the key resolves by default equals the literal it replaces
(DirectivePromptGoldenTest in core/agents-turn is the model). If the migration is meant to change wording,
say so in the change log.
4. A module with its own keys should assert PromptResolver.defaultsOnly().catalog().problems() is empty; a broken
file is skipped and logged at startup, never fatal, and listed there.
Resolving¶
PromptResolver.resolve(key, ctx) returns a ResolvedPrompt { key, text, source, scope, version, locale, enabled,
known, hash, estimatedTokens }. Order: the agent's override, the tenant's, the platform's, the shipped text. Inside
a scope an override in the context's locale wins over one in the default locale. A text that is blank means "no
override here". An override that switched the prompt off yields text == null, enabled == false (and wins over the
lower scopes: an agent-scope override can switch it back on for one agent). resolveAll(ctx) does every key.
It never throws: a missing key, a database error or a broken row gives the shipped text (source = FALLBACK for a
failed lookup) and a log line; an unknown key gives an inactive result and an ERROR log once per key. The call site
decides whether a prompt applies (the tool is on the list, the feature flag); the resolver decides what text.
Overrides are cached in process (app.prompts.cache.ttl, default 30 s). A write evicts the writing replica at
once; the other replicas serve their cached copy until the TTL runs out, so a change reaches all replicas within one
TTL. There is no cross-replica push and no per-lookup version query. A failed load caches "no overrides" for
app.prompts.cache.failure-ttl.
PromptBudget is the hook for token awareness: the assembler adds each block and reads back whether the standing
text is over app.prompts.system-prompt-token-budget. Nothing is dropped or truncated; the preview flags it.
Overrides, versions and history¶
| Table | Scope |
|---|---|
prompt_override, prompt_override_history (tenant schema, V664) |
tenant (scope id empty) and agent (scope id = agent UUID) |
control_plane.platform_prompt_override, ..._history (V665) |
platform |
prompt_override holds the current override of a (scope, scope id, key, locale); version is 1 for the first save and
+1 for every change, a revert included. prompt_override_history keeps every version with its author, reason, new
and previous wording; a trigger refuses UPDATE and DELETE on it. A revert removes the current row and appends a
REVERT row; the version counter continues. An agent-scope override is accepted only for an agent of the calling
tenant; tenant and agent rows live in the tenant's schema, so one tenant can never read or write another's.
Saving text equal to the shipped default removes the override instead (a stale copy never pins old wording), unless a lower scope (platform below a tenant; tenant or platform below an agent) overrides the key. Then the default wording is stored as a real override at the higher scope, so it opts out of the lower one.
Admin API (/api/prompts)¶
Tenant admin only; the platform scope also needs a platform admin. scope is platform, tenant or agent
(agent also takes ?agentId=); locale defaults to app.prompts.default-locale.
| Call | Does |
|---|---|
GET /api/prompts?agentId=&locale=&family=&q=&limit=&offset= |
every key: definition, default, the effective value with its source and version, the stored overrides. No limit returns the whole registry; with limit (clamped to app.prompts.list-max-limit) and offset the list is windowed and X-Total-Count / X-Has-More are sent |
GET /api/prompts/{key} |
one key |
GET /api/prompts/{key}/history?scope=&agentId=&locale=&limit= |
the immutable history, newest first |
PUT /api/prompts/{key}/overrides/{scope} |
create or update; body {text, enabled, acknowledgeRisk, reason, expectedVersion}; enabled=false switches a disableable prompt off at that scope; a stale expectedVersion is a 409 |
DELETE /api/prompts/{key}/overrides/{scope} |
revert: the next lower scope or the default applies again |
POST /api/prompts/{key}/overrides/{scope}/restore |
re-apply an earlier version's wording as a new version |
POST /api/prompts/{key}/validate |
check a draft without saving it |
Every write is audited (PROMPT_OVERRIDE_UPDATED / PROMPT_OVERRIDE_REVERTED) with the actor, scope, key, locale,
version, SHA-256 of the old and new text and the line diff of the wording.
The older /api/prompt-directives routes keep working as a thin layer over the tenant scope; a directive with two
wordings is written to both keys.
Prompt preview¶
POST /api/agents/{agentId}/prompt-preview (EDITOR access to the agent) builds the real system text and tool list
of an agent exactly as a turn would, without calling a model and without storing anything. Body (all optional):
{message, sessionId, smallModel, includeMemoryRecall}. The answer has the systemPrompt, the per-turn
memoryBlock, every block with {message, id, key, source, scope, version, hash, estimatedTokens, text}, the
tools with their descriptions and schemas, token totals, and notes naming every input a preview cannot know
(hook notes, RL arms, the live model's small-model status). It shares ChatSystemPromptComposer.compose with real
turns, so the two cannot drift apart.
Turn pipeline texts¶
Besides the standing directives, the text a turn writes to the model or to the person is a key. The default of each
is the resource file core/agents-turn/src/main/resources/prompts/en/<key>.md (the spotlight banner is in
core/guardrails), byte for byte the wording that was in the Java source before; TurnTextsGoldenTest pins that.
An override of the agent, the tenant or the platform applies on every path that sends the text (streamed, buffered,
delegation and specialist calls, the continuation after an approval); where a reply callback runs on a thread with no
tenant, the text is read in the turn's tenant.
| Key | Sent when | Placeholders |
|---|---|---|
planner.plan_block |
PLANNED depth: the plan, as a system message | plan! |
memory.recall.private, memory.recall.shared |
the line that frames the facts recalled for the user (this agent's, the team's) | facts! |
memory.notes |
the line that frames the user's own notes (persona, profile, project notes); security critical | none |
memory.summary_preamble |
the rolling summary replayed ahead of the history; tenant or platform wording only (the replay does not know the agent) | none |
skills.header, skills.header.compact |
the instruction at the head of the skills list (full, small-model); the ## Skills heading is part of the text |
none |
delegation.context_header.collaborator, .specialist |
the summary a delegating agent forwards to a collaborator or specialist | context! |
delegation.empty_answer.collaborator, .specialist |
the tool result when the delegate answered with nothing | none |
refusal.safety_check_unavailable, refusal.moderation.blocked, refusal.moderation.queued, refusal.guardrail.input_default |
what the person reads when a check could not run, moderation blocked or held the message, or an input guardrail without its own reason blocked it | none |
refusal.guardrail.output_withheld |
replaces an answer an output guardrail withheld; stored as the answer | guardrail! |
message.fabricated_url.not_generated, message.fabricated_url.correction |
replaces / corrects an answer that carried an invented file link | none |
message.generic_failure |
the sentence shown and stored when a reply failed and the provider gave no message | none |
message.finish.truncated, .refusal, .malformed_tool_call, .empty_reply |
what the person reads when a reply is still cut off after the continuation passes, was stopped by the provider's safety system, had an unusable tool call, or came back empty; read for the turn's agent and tenant on the streamed, buffered and delegated paths. Deprecated aliases: app.chat.finish.truncated-notice, refusal-notice, malformed-tool-call-notice, empty-reply-message (a value that differs from the shipped text seeds the platform default; none is set in application.yml any more) |
reason (in .refusal) |
toolresult.skill.workspace_framing |
the line that opens a load_skill result for a skill written in the workspace (not a built-in): task guidance that does not override the core instructions; security critical |
none |
message.degraded.tools_disabled |
step-event text when a turn was answered without tools after a provider bug | none |
message.hook.* |
what a workspace hook says: the tool result for a stopped call or withheld output (tool_denied, tool_output_withheld), the default reasons, the refusals a person reads, the note framing. A hook author's own reason / message / note always wins over the default wording. These results stay recognised as "the tool did not run" whatever the words |
per key |
compaction.summary_prefix, compaction.restore_hint, compaction.restore_hint.no_restore |
the summary that replaces old turns, and the note that replaces a cleared tool result; taken once per run, so a summary is recognised by the text that wrote it | stepId! in the hint |
toolresult.untrusted_content |
the banner around untrusted tool output; the delimiters around the content are code and cannot be changed; security critical | tool! |
nudge.continue_unfinished, nudge.reply_cut_off, nudge.retry_tool_call |
see below | none |
guard.fabricated_url.markers, .file_url_pattern, .claim_phrases |
see below; security critical | none |
Nudges are registry keys, not settings. The text sent to the model after an announced-but-untaken step
(nudge.continue_unfinished), a reply cut off at the output limit (nudge.reply_cut_off) and an unusable tool call
(nudge.retry_tool_call) lives in the registry and nowhere else: the shipped file is the default, and the agent, the
workspace or the platform can override it like any other prompt. The turn reads only the resolver. The older properties
app.chat.continuation.nudge.<lang>, app.chat.finish.continue-nudge and app.chat.finish.retry-tool-call-nudge are
deprecated aliases: a value that differs from the shipped text is applied as the PLATFORM-scope default of its key
(below every stored override, above the shipped file) and logged at startup naming the key to edit instead; a property
left at the shipped text, or unset, changes nothing, and none of them is set in application.yml any more. The
continuation nudge is chosen by the language whose patterns matched the reply: that language's override, else the
default language's override, then the property entry for the language (else the default language's entry), then the
shipped file of the language (else the default language's). To add a language, ship
prompts/<locale>/nudge.continue_unfinished.md holding only the text, and add its app.chat.continuation.intent.<lang>
patterns. The same rule covers app.chat.escalation.low-confidence-markers (alias of
detector.escalation.low_confidence_markers, one marker per line). PromptPropertyAliasTest pins the precedence, the
warning and the language selection, and fails if a call site reads one of these properties.
Detector data. The guard that recognises an invented file link reads its lists from the registry:
guard.fabricated_url.markers (one host per line), .file_url_pattern (one regular expression; an invalid one is
ignored and the shipped one is used, so a broken override never leaves the guard blind) and .claim_phrases (one
phrase per line). Every locale the catalog ships a file for is checked: to cover another language add
prompts/<locale>/guard.fabricated_url.claim_phrases.md holding only the phrases. Only English ships today.
The console¶
/admin/instructions (tenant admins) has three prompt tabs. All prompts lists every key with the wording in
force now, which scope supplies it (shipped default, platform, workspace or agent) and its version; it searches by
key, title or description, filters by risk class and shows only customised prompts. Open a prompt to edit it at the
scope chosen at the top (workspace, one agent, or the whole platform for a platform admin): the draft is checked
by POST /api/prompts/{key}/validate as you type and problems (an unknown or missing {placeholder}, a length
over the limit, a prompt that cannot be switched off) are listed under the text, and saving is blocked until they
are fixed. A save carries the version the editor started from, so a change made meanwhile is a conflict, not an
overwrite. A security-critical prompt also needs the risk confirmation. History lists every version with
author, time and reason, the line diff against the version before, and one click to restore an earlier wording
(saved as a new version) or revert the override. Preview final prompt calls POST /api/agents/{id}/prompt-preview
and shows the blocks of the agent's system text, each tagged with its key, scope and version and a token estimate.
Edge (in-browser) turns¶
Turns that run on the person's device never reach the server's prompt code, so their wording is registry keys too
(family edge): edge.default_system_prompt (used when an agent has no system prompt), edge.reasoning_nudge
(appended to every edge system prompt; the only one that may be switched off), the on-device guardrail refusals
edge.refusal.jailbreak and edge.refusal.pii_*, edge.localcode_ask.system (codebase Q&A) and edge.ask_ai.system (the "Ask AI" form-field writer, also read by the server's AssistService, so one override covers the hosted and the on-device draft). The web app reads
the effective values from GET /api/edge/prompts?agentId= (any signed-in user; resolved in their tenant, with the
agent's override when agentId is an agent of that tenant; cached by the browser for app.prompts.cache.ttl) and
keeps the bundled defaults in web/app/lib/edge/edgePrompts.defaults.json for everything it cannot reach: offline,
signed out (the anonymous try page), a server without the endpoint, a timeout or a malformed answer. A web test keeps
those defaults byte-equal to the shipped edge.*.md files. Only wording comes from the server: whether a message is
blocked is still decided by the patterns in edgeGuardrails.ts, and a refusal can never be switched off (a blank or
switched-off value for a key that is not disableable is ignored).
Request inspector¶
The request inspector (Request X-ray: GET /api/agents/{agentId}/sessions/{sessionId}/llm-requests) shows what each
model call was sent. With app.llm.request-snapshot-store.mode=redis (default) every redacted copy is also written to
Redis with the same time-to-live (app.llm.request-snapshot-store.ttl, 6 h) and per-session depth, so another replica
answers for a turn this one ran and the copies survive a restart; memory keeps them in this JVM only. The write is
queued off the model-call path. Each system message lists the registry blocks it is made of (key, scope, version,
wording hash, token estimate), recorded by the turn's prompt assembler (PromptBlockProvenance) and matched to the
message by content; text that does not match carries no label.
Tool descriptions¶
Every built-in tool's description, and the description of each parameter in its input schema, is a key. The shipped
text is a resource file in the module that owns the tool (core/agents-tools, agents-browser, agents-interop,
agents-jobs, agents-studio, ...), byte for byte the string the tool's code builds, so the tool list the model
sees is unchanged until someone overrides a key.
| Key | Holds |
|---|---|
tool.description.<id> |
the tool's description as sent in the tool list. <id> is the tool's name (generate_document); a second implementation that shares a name has an id <name>.<variant> (http_request.safe, local_shell.unattended) |
tool.param.<id>.<path> |
the description of one parameter. <path> is the property names from the schema root joined by dots (an array's items level adds nothing), each in snake case: maxChars is max_chars, the agent of tasks[] is tasks.agent. A variant without parameter keys of its own uses its plain tool's |
tool.template.<id> |
the description of a tool that fills run time values in itself (delegate_parallel, delegate_to_specialist, delegate_to_agent); the placeholders are listed in the key |
One place applies them. ToolDescriptionResolver (core/agents-tools) runs inside ToolResolver for every
list a turn is given, so streamed and buffered turns, delegation and specialist hand-offs, agent jobs, the
continuation after an approval and the prompt preview all see an override, for the agent the list is built for. A
tool with no override is returned as the same object. A tool with one is wrapped: same name, same calls, new
description and/or schema texts. MCP tools, user-defined tools (custom code, OpenAPI, scripts) and any tool without a
key pass through. An override can change words and nothing else: the tool name is never touched, only the
description text of schema properties that already have one is replaced, and the result must equal the built schema
once those texts are ignored, or the override is dropped and logged.
Templates. Where the code fills a value into the description (the shell's environment note, whether each call
needs approval) the key is a template and the value a placeholder: local_shell / local_shell.unattended /
local_shell_read take {environment}, the actions of local_desktop_* that need approval end in
{approval_note} (the sentence the platform appends when calls need approval; leave the placeholder out and the
sentence is dropped), delegate_parallel takes {roster}, delegate_to_specialist {roster}, {max_depth},
{hop}, delegate_to_agent {name} and {specialty}. For tool.description templates the values are recovered
from the text the code built, which must equal the shipped template filled in; tool.template keys are rendered by
the tool itself when it is built (ToolPromptTexts, bound around the resolution of the list), so a tool built
outside a resolution, such as the quick-task delegation tools, uses the shipped text.
Risk. All of them have risk class TOOL_DESCRIPTION and cannot be switched off. Tools that act on the
outside world or handle credentials (http_request*, local_shell*, local_desktop_*, run_code, the device
readers, payments, channel_send_message, forward_attachment, data_table_write, schedule_task, request_human,
browser_totp, run_browser_recipe, ...) are securityCritical: changing one needs acknowledgeRisk, because a
weaker description can make the model skip an approval or paste a secret.
Adding a tool. Create the keys (the description, then one per described parameter) in the tool's module and add
the class to that module's golden test: it builds the tool without running its constructor
(ToolPromptGolden.probe) and fails if a shipped text differs from what the code builds, if a key is missing, or if a
tool.param.<id>.* key has no parameter. Dynamic parts of a schema (maxItems) are never keys.
The feature-module tools (second half). The memory (remember_fact, remember_shared_fact), skill
(load_skill, read_skill_file, run_skill_script, propose_skill, improve_skill), restore_tool_result,
workspace file (workspace_list|read|search|write|edit), code-intelligence (search_code, code_callers,
code_callees, code_impact, code_graph_stats, create_code_workspace, apply_code_changes,
run_in_code_workspace, diff_code_workspace, delete_code_workspace), Google (google_calendar,
google_calendar_find_free_slots, google_calendar_move_event, google_calendar_cancel_event, google_gmail,
google_drive, google_sheets, youtube_content_performance, upload_video_to_youtube), graph-memory recall,
generate_image|video|music, knowledge (search_knowledge_base, add_to_knowledge_base,
list_knowledge_collections), mandate payment (create_payment_link.mandate, check_payment_status.mandate,
request_upi_collect), Sarvam, and create_workflow tools are keys in the module that owns them. The two payment
tools share their names with the commerce tools of core/agents-tools, so they are the .mandate variants. The
workspace file tools fill the label of the projects folder into their description, so theirs are templates with the
placeholder {root}, which an override must keep. The tools that send, write, move money, run code or carry an
approval notice in their wording (apply_code_changes, run_in_code_workspace, delete_code_workspace, the
write/send Google tools, upload_video_to_youtube, create_payment_link.mandate, request_upi_collect,
run_skill_script, the workspace file tools) are securityCritical.
Tools that are not in the registry, and why. The eight tools the platform's own MCP server offers to external
clients (list_my_agents, call_agent, list_my_workflows, call_workflow, search_knowledge,
start_agent_task, get_task, cancel_task) are built once at start-up for every tenant and caller at once, so
there is no agent or tenant to resolve an override for; their wording is part of the MCP interface. The pseudo tools
of the provider shims and structured output (final_answer, the forced structured-result tool) are built by the
chat module's shims, outside any agent's tool list, and their one-line descriptions are still code (the shim.* keys
below cover the shims' instructions, not these two descriptions). The placeholder definition of an unavailable tool is
never listed to the model, and the description of a client-supplied tool is the client's own text.
Guardrail, tool-loop and swarm text¶
These keys steer what the model does after a block, a hold or a stop. They are securityCritical (changing one needs
acknowledgeRisk) and none can be switched off, because the call site cannot work without text.
| Keys | Where the text is used |
|---|---|
toolresult.guard.dispatch_denied (+ .default_reason), toolresult.guard.dispatch_check_failed, toolresult.guard.verifier_rejected (+ verifier_default_reason), toolresult.guard.procedure_blocked, toolresult.guard.output_withheld, toolresult.guard.arg_policy_violation |
the tool result GuardedToolCallback and ToolArgPolicyCallback return when they stop a call or withhold an output |
toolresult.guard.tenant_rule_matched, .pattern_injection_marker, .semantic_default_reason, .semantic_unavailable |
the reason sentence inside toolresult.guard.output_withheld |
toolresult.park.awaiting, .queued, .queue_full, .record_failed |
a call held for human approval; compatibility aliases app.guardrails.approval-park.*-message |
toolresult.eval_sandbox.simulated |
the tool result of a call an offline eval turn answers instead of running the tool, because the tool could change data or reach outside the platform; it tells the model nothing was changed or sent and to carry on as after a success (GuardedToolCallback; see "Evals ... on the real pipeline" in configuration.md) |
toolresult.tool_budget.calls, toolresult.tool_budget.rounds, message.tool_budget.hard_stop |
the tool-call ceiling, the tool-round cap and the closing reply; aliases app.guardrails.step-limit.exhausted-message, app.chat.tools.budget-exhausted-message, app.chat.tools.hard-stop-reply |
toolresult.unavailable.* |
a tool that exists but is not switched on for the workspace, or a name the model invented. toolresult.unavailable.generate_image is the chat module's answer to a never-offered generate_image (OpenAI wording); the tool's own answer when no image provider is active is toolresult.image_generation.not_configured (names OpenAI and Google Vertex AI), a separate key so each keeps its shipped wording |
toolresult.tool_breaker.disabled, .last_error, .wait.one, .wait.many |
the result of a call to a tool whose circuit breaker is open (LoggingToolCallback); last_error and the wait are fragments filled into {last_error} and {wait} of the first |
toolresult.invalid_arguments.hint, .unknown_keys, .problem.not_json, .problem.not_object, .problem.missing_required |
the "NOT executed" feedback when a tool call's arguments do not match its input schema (ToolArgumentValidator); the machine-readable error and the schema in the same result are code |
toolresult.image_generation.not_configured, .failed, .quota_suffix, .success_instructions |
the text generate_image returns for a failed, quota-limited or successful generation ({image_url} in the last) |
procedures.header, procedures.marker.* |
the "Operating procedures" block of the system prompt (the heading and numbering are layout, not text) |
judge.tool_verifier (+ judge.tool_verifier.context, the line in front of the conversation tail), judge.injection, judge.policy_pack (+ judge.policy_pack.<category>), judge.toxicity |
the instruction of each guardrail judge; the answer format they ask for (APPROVED / REJECTED: ..., SAFE / UNSAFE: ..., SCORE: n, a bare number) is parsed by code, so an override must keep it |
refusal.guardrail.* |
what the user reads when a content guardrail blocks a message |
shim.router.format, shim.router.required, shim.structured.schema_instruction |
the tool-routing and structured-output shims for local models |
delegation.swarm_worker, delegation.swarm_worker.role |
the system addendum of every run_subtasks worker |
Compatibility aliases. A text that was already a property keeps working: an override of the key at any scope wins, then the property when an operator changed it (it differs from the shipped wording), then the shipped wording. With nothing customised the result is the shipped text, byte for byte.
Whose override applies. The tenant is the one on the calling thread. The agent is the agent of the guarded call
(GuardedToolCallback), of the turn (the tool loop: the turn's ToolLoopTranscript.forPrompts(agent, tenantScope) carries
both onto the loop's threads) or of the swarm (run_subtasks). The judges receive the agent through
GuardrailPromptScope.open(agentId), opened by GuardedToolCallback around the call. The content gate and the
moderation layer do the same: ContentGuardrailEngine.evaluate and ModerationService.moderate(content, context, chatClient)
open the scope for context.agentId() (and close it again, also when a guardrail throws), so an agent override of a
refusal.guardrail.* text, judge.toxicity or judge.policy_pack applies on the inbound and outbound gates of a chat
turn, whose refusal is persisted into the history. A caller that invokes a single ContentGuardrail directly, without the
engine, sees the tenant's override only unless it opens a GuardrailPromptScope itself.
Known limit: no platform-only scope. A tenant admin who passes acknowledgeRisk can override judge.injection,
judge.tool_verifier and the toolresult.guard.* texts for their tenant. The registry has no per-key "platform scope
only" flag yet; until it does, treat the acknowledgeRisk audit entry as the control and review those overrides in the
override history.
Detector pattern sets¶
The regular expressions the pattern guardrails match are resource files, not Java literals: guardrails/patterns/<locale>/<set>.txt
(jailbreak, injection-markers, policy-child-safety, policy-self-harm, policy-violence, policy-illegal-activity,
policy-hate-speech) and guardrails/patterns/redaction.txt for the secret formats that are redacted from tool output (not
language dependent). One Java regex per line; # starts a comment; a leading (?i) / (?im) becomes the pattern's flags;
{GAP} in jailbreak.txt is the bounded gap between keywords (the ReDoS guard, [^\n]{0,40}?); a redaction line is
label :: regex. The English file of each set is mandatory (a missing or empty one fails fast, because a detector with no
patterns would stop detecting silently). To cover another language, ship guardrails/patterns/<locale>/<set>.txt in any
module's resources: the patterns of every locale on the classpath are checked together, English first, because an attacker
chooses the language; a file never replaces the English one, and a broken line in another locale is logged and skipped. The
shipped English files hold exactly the patterns that used to be compiled in the classes (a golden test pins them). The sets are
read at startup and are not editable through the admin API: they are the platform's floor, and a tenant adds to it with its
own guardrail rules.
Secondary model calls and detector lists¶
The instruction of every cheap helper call the platform makes besides the answer itself is a key with risk class
SECONDARY_CALL. The default of each is a resource file in the module that owns the text (prompts/en/<key>.md),
byte for byte the wording that was a Java literal; a golden test in each module pins that. None of them can be switched
off (the call cannot run without an instruction). Where a call knows its agent, an agent override applies; where it does
not, the tenant's (or the platform's) does. Keep the reply format a text asks for (SOLVED / INCOMPLETE:, OK /
FLAG:, a JSON shape, SCORE:): the code that reads the reply depends on it, and a reply it cannot read fails open.
| Key | Used by | Placeholders | Agent scope |
|---|---|---|---|
summarizer.compaction, summarizer.session |
context compaction; the rolling session summary | none | yes |
quality.reflexion.critique, .revise; quality.consistency.check, .revise |
the RIGOROUS and CAREFUL depths' second look at an answer | none | yes |
planner.task, planner.task.replan; planner.task.tools_none, .tools |
the PLANNED depth's plan, the re-plan, and the sentence naming the agent's tools | tools (in .tools) |
yes |
judge.quality |
the sampled quality judge behind the Issues portal | none | yes |
router.complexity |
the YES/NO question that lets a cheaper model answer | none | yes |
router.agent_select, router.agent_additional |
choosing an agent for a task and finding extra specialists | agent (in _additional) |
no (tenant) |
skills.distilled.purpose, .when_to_use, .procedure, .footer, .description |
the text of a skill distilled from repeated tool sequences (staged disabled) | runs, sessions, sequence |
yes |
swarm.verifier; swarm.brief, swarm.brief.objective |
the verification step of a multi-agent team (security critical); the brief each worker receives | objective, subtask, goal (in the brief) |
no (tenant) |
memory.entity_extraction, memory.arbitration |
graph-memory triples and the add / update / discard decision | none | yes |
learning.lesson, learning.skill_draft, learning.skill_self_check |
lessons and skill drafts learned from finished turns, and the check of a draft; the first two are security critical (they carry the rule against copying personal data) | none | yes |
research.system |
the seed prompt of a new deep-research agent (an existing agent keeps the prompt stored on it); security critical (it says passage text is data) | none | no (tenant) |
research.goal.untrusted_notice, research.goal.tail |
the sentence after the fenced question in the goal of every research run (the fenced text is data), and its closing instructions (call deep_research first, no placeholder sources); the first is security critical |
fence_open!, fence_close! (in the notice) |
yes (the research agent) |
workflow.generator.system (+ .condition_expression_hint, .per_item_send_hint, .expression_section), workflow.generator.ref_mode, workflow.generator.direct_addendum (+ .approval_rule), workflow.generator.edit_suffix |
the AI workflow generator's meta-prompt: the graph contract and node types, the expression-language lesson (the function list is built from the live registry), the Brief planner's reference mode, the schedule contract and the change-request suffix. The reply must stay the JSON the designer parses | agent_list!, knowledge_list!, functions!, current_graph!, hints |
no (tenant) |
edge.ask_ai.system |
the "Ask AI" form-field writer: one key for the server (AssistService) and the console's on-device draft (served in the edge bundle) |
none | no (tenant) |
diagnostics.failure_triage, diagnostics.technical_explanation |
the background failure triage and the novel-failure explanation; the replies are parsed by their CATEGORY: / EXPLANATION: and TECHNICAL: / EXPERT: prefixes |
none | no (tenant) |
studioapp.default_system |
the persona of a studio app's AI when the app has no system prompt of its own | none | no (tenant) |
rag.multi_query, rag.hyde |
retrieval query expansion | none | no (tenant) |
eval.judge.helpfulness, .relevance, .safety, .tone, .faithfulness, .accuracy (+ .accuracy.reference, .accuracy.reference_check) |
the LLM judges of the quality evaluators | question, response, system_prompt, context, answer, reference, reference_block, reference_check |
yes |
eval.judge.case |
the golden-case grader of the command-line eval runner; that runner has no tenant, so it reads the shipped file | none | no |
lab.judge.score, lab.judge.same |
the Agent Lab graders | none | yes |
workflow.ai_classifier, workflow.ai_extractor, workflow.ai_extractor.repair |
the Question Classifier and Parameter Extractor nodes (the first two are security critical: they say the text is data) | issues (in .repair) |
no (tenant) |
workflow.supervisor.route, workflow.supervisor.instructions |
the Supervisor node's routing request | routes, instructions, input |
yes (the node's agent) |
workflow.params_inference, workflow.edit_redaction_note |
the execution-limits helper; the note that asks the generator to copy [[REDACTED_n]] back (security critical) |
none | no (tenant) |
Not in the registry yet: the small framing lines around the data of the two summarizers, the labels of the research goal
(Depth (pass as effort):, Seed URLs ...), and the hand-built explanation templates of LlmBackedTechnicalStrategy.
Word lists are keys too. The tool-name lists that decide whether a turn may be replayed (a cheap answer escalated to
the primary model) and the phrases that mark a reply as low confidence were Java sets. They are now one entry per line
(# starts a comment, blank lines are skipped) in detector.replay.read_only_prefixes, detector.replay.mutating_words,
detector.replay.never_replay_tools and detector.escalation.low_confidence_markers. The first three are security
critical, so a change needs acknowledgeRisk. They are a floor in one direction: the shipped mutating words and
never-replay tools are always applied in addition to an override (an override can add, never remove, so a tenant edit
cannot make a mutating tool replayable); an override of the read-only prefixes replaces the list (removing a prefix only
makes the check stricter, adding one needs the acknowledgement). app.chat.escalation.low-confidence-markers, when set,
is a deprecated alias that seeds the platform default of the registry list (an override of the list beats it). The failover gate (FailoverReplayGate) is built by hand and reads the
shipped lists only. English ships today; for another language add prompts/<locale>/<key>.md holding only the entries: the shipped entries
of every locale are applied together with the default locale's (which a tenant or agent override replaces).
Migrating a call site¶
For each hardcoded prompt: move the text to a resource file under the key from the audit, replace the literal with
a lookup, keep the gating in code, and add a golden test. Keep any static final String that tests use as a
resource-backed constant (PromptResolver.defaultsOnly().catalog().defaultText(key)); never send a constant from a
turn path, resolve it so an override applies. Directives built per turn from tool lists choose the key in code
(toolTrustKey(tools, compact)) and resolve it through ResolvedDirectives.