Append install-wide instructions to every bot's prompt

Settings > General gains a prompt appendix the super admin sets once:
markdown every bot runs with, right after its own core instructions.

- The text lives in app_settings (bot_system_prompt_append, read through
  grabSystemPromptAppend, cached like every app setting), so it changes
  without a deploy and applies to every bot on the next run.
- composeBotPrompt takes an appendix and pushes it as
  # Install-wide instructions directly after system_prompt, before the
  workspace block and memory, so the bot's own more specific rules still
  win. grabBotPrompt feeds it in, so chat replies, tasks, scheduled runs
  and the coordinator all get it with no call site touched.
- api/admin/settings/system-prompt-append saves it (super admin only,
  capped at AppData.PromptMaxLength, empty clears it) and is audit
  logged under system.
- The section and its form are hidden from everyone but the super admin,
  save in place with a character count and no reload, and say what an
  empty text does.
This commit is contained in:
2026-10-22 11:12:38 +01:00
parent a88898d169
commit a8d8b680ad
13 changed files with 278 additions and 9 deletions
+6
View File
@@ -54,6 +54,12 @@ export type BotKind = (typeof BotKinds)[number]["value"];
*/
export const CoordinatorBotName = "Coordinator";
/**
* The `app_settings` key holding the install-wide text appended to every
* bot's prompt, right after its own core instructions.
*/
export const BotSystemPromptAppendSettingKey = "bot_system_prompt_append";
/**
* A bot's face is its status. There are no badges or spinners.
*/
@@ -0,0 +1,27 @@
import { BotSystemPromptAppendSettingKey } from "@/src/dict/bot-dict";
import grabAppSetting from "./grab-app-setting";
/**
* The install-wide text appended to every bot's prompt, right after its own
* core instructions (`bot_system_prompt_append`, from Settings > General).
* Empty when the install never set one.
*/
export default async function grabSystemPromptAppend() {
const saved = await grabAppSetting({ key: BotSystemPromptAppendSettingKey });
return saved?.trim() || "";
}
/**
* The same text with its setting key, for the settings page.
*/
export async function grabSystemPromptAppendOverview() {
return {
setting_key: BotSystemPromptAppendSettingKey,
text: await grabSystemPromptAppend(),
};
}
export type SystemPromptAppendOverview = Awaited<
ReturnType<typeof grabSystemPromptAppendOverview>
>;
@@ -1,5 +1,6 @@
import composeBotPrompt from "@/src/utils/compose-bot-prompt";
import AppError from "@/src/utils/app-error";
import grabSystemPromptAppend from "../app-settings/grab-system-prompt-append";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
import grabBotUserMemory from "./grab-bot-user-memory";
import grabUserBot from "./grab-user-bot";
@@ -19,8 +20,9 @@ type Params = {
/**
* The full prompt for one of the user's bots (`composeBotPrompt`): its
* core instructions and memory, what it remembers about this user, and in
* a workspace that workspace's context and what the bot has for it.
* core instructions, the install-wide appendix, its memory, what it
* remembers about this user, and in a workspace that workspace's context
* and what the bot has for it.
*/
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
@@ -39,6 +41,7 @@ export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: P
return composeBotPrompt({
system_prompt: bot.system_prompt,
appendix: await grabSystemPromptAppend(),
memory: bot.memory,
user_memory: await grabBotUserMemory({ bot_id: bot.id, user_id }),
workspace: found?.workspace,
@@ -6,6 +6,7 @@ import grabUserPasswordHash from "@/src/functions/backend/auth/grab-user-passwor
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
import { grabSystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append";
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
@@ -42,6 +43,7 @@ export default async function grabSettingsServerProps({ req }: Params) {
person: sanitizeUserBio({ user: user_res.singleRes }),
has_password,
storage_quota: await grabStorageQuotaOverview().catch(() => null),
system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null),
},
};
}
@@ -0,0 +1,95 @@
import { useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import Form from "@moduletrace/twui/form/Form";
import Textarea from "@moduletrace/twui/form/Textarea";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
import type { SystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append";
import SettingsSaveBar from "../../(partials)/settings-save-bar";
type Props = {
appendix?: SystemPromptAppendOverview | null;
};
/**
* The install-wide prompt appendix: saved on its own, without a reload, as
* markdown. An empty text removes it.
*/
export default function SystemPromptAppendForm({ appendix }: Props) {
const [overview, setOverview] = useState(appendix || undefined);
const [dirty, setDirty] = useState(false);
const [value, setValue] = useState(appendix?.text ?? "");
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<{ msg?: string; error?: boolean; success?: boolean }>();
const save = async () => {
setLoading(true);
setStatus(undefined);
const res = await fetchApi<{}, APIResponseObject<SystemPromptAppendOverview>>(
"/api/admin/settings/system-prompt-append",
{ method: "POST", body: { text: value } },
).catch(() => null);
setLoading(false);
if (!res?.success || !res.singleRes) {
setStatus({ error: true, msg: res?.msg || `Couldn't save the appendix.` });
return;
}
setOverview(res.singleRes);
setValue(res.singleRes.text);
setDirty(false);
setStatus({
success: true,
msg: res.singleRes.text
? `Saved. Every bot gets it from its next reply.`
: `Removed. Bots no longer get an appendix.`,
});
};
return (
<Form
onSubmit={(event) => {
event.preventDefault();
save().catch(() => {});
}}
className="w-full gap-3! items-stretch"
>
<Stack className="w-full gap-1! items-stretch">
<Textarea
id="system-prompt-append"
label="Appendix"
title="Appendix"
showLabel
rows={6}
placeholder={"Never invent sources.\nAlways say which computer a task ran on."}
value={value}
onChange={(event) => {
setValue(event.target.value);
setDirty(true);
setStatus(undefined);
}}
info="Markdown. It sits under its own heading, after the bot's core instructions and before its workspace context and memory, so the bot's own rules there still win where they are more specific. Leave it empty to remove it."
/>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`${value.trim().length} / ${AppData.PromptMaxLength} characters`}
</Span>
</Stack>
<SettingsSaveBar
dirty={dirty}
loading={loading}
status={status}
save_label="Save appendix"
onDiscard={() => {
setValue(overview?.text ?? "");
setDirty(false);
setStatus(undefined);
}}
/>
</Form>
);
}
@@ -0,0 +1,25 @@
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import SettingsGroup from "../../(partials)/settings-group";
import SystemPromptAppendForm from "../(partials)/system-prompt-append-form";
/**
* The install-wide text every bot gets in its prompt. Only the super admin
* sees it, since it changes what every bot on the install is told.
*/
export default function PromptSection() {
const { pageProps } = useContext(AppContext);
if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null;
return (
<SettingsGroup
title="Prompt appendix"
description="Text added to every bot's instructions, right after its own. Use it for rules that hold for the whole install, like how bots should talk or what they must never do."
id="prompt-appendix"
>
<SystemPromptAppendForm appendix={pageProps?.system_prompt_append} />
</SettingsGroup>
);
}
@@ -1,4 +1,5 @@
import AppearanceSection from "./(sections)/appearance-section";
import PromptSection from "./(sections)/prompt-section";
import StorageSection from "./(sections)/storage-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
@@ -10,6 +11,7 @@ export default function AdminSettingsGeneralPage() {
description="How the app looks and behaves."
>
<AppearanceSection />
<PromptSection />
<StorageSection />
</SettingsShell>
);
@@ -0,0 +1,55 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import {
grabSystemPromptAppendOverview,
type SystemPromptAppendOverview,
} from "@/src/functions/backend/db/app-settings/grab-system-prompt-append";
import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { BotSystemPromptAppendSettingKey } from "@/src/dict/bot-dict";
import { SaveSystemPromptAppendRequestSchema } from "@/src/schemas/bot-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Super admin only: the install-wide text every bot gets right after its
* own core instructions. An empty text removes it.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<SystemPromptAppendOverview>> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") throw new AppError(`Method not allowed`);
const user = await grabSuperAdminUser({ req });
if (!user?.id) throw new AppError(`Only a super admin can change the prompt appendix.`);
const { text } = parseWithSchema({
schema: SaveSystemPromptAppendRequestSchema,
data: body,
});
await saveAppSetting({
key: BotSystemPromptAppendSettingKey,
value: text || null,
});
await logEvent({
title: "Bot prompt appendix changed",
category: "system",
user_id: user.id,
plain_text: text
? `User #${user.id} set the install-wide bot prompt appendix (${text.length} characters).`
: `User #${user.id} removed the install-wide bot prompt appendix.`,
metadata: { characters: text.length },
ip_address: grabRequestIP(req),
});
return { success: true, singleRes: await grabSystemPromptAppendOverview() };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+11
View File
@@ -13,6 +13,17 @@ import {
*/
const WorkspaceIdField = z.coerce.number().int().positive().nullish();
export const SaveSystemPromptAppendRequestSchema = z.object({
text: z
.string()
.trim()
.max(
AppData.PromptMaxLength,
`Keep it under ${AppData.PromptMaxLength} characters`,
)
.default(""),
});
export const SendBotMessageRequestSchema = z.object({
bot_id: z.coerce.number().int().positive("Pick a bot to message"),
workspace_id: WorkspaceIdField,
+8
View File
@@ -993,6 +993,14 @@ export type PagePropsType = {
total_files: number;
folders: number;
} | null;
/**
* The install-wide text appended to every bot's prompt (the General
* settings page).
*/
system_prompt_append?: {
setting_key: string;
text: string;
} | null;
push_overview?: PushOverview | null;
mcps_overview?: McpServersOverviewPayload | null;
tools_overview?: CustomToolsOverviewPayload | null;
+25
View File
@@ -30,6 +30,31 @@ describe("composeBotPrompt", () => {
);
});
test("puts the install-wide appendix right after the core instructions", () => {
expect(
composeBotPrompt({
system_prompt: "Core.",
appendix: "Never invent sources.",
workspace: { name: "Launch", description: "The spring launch." },
memory: "- Cite sources.",
}),
).toBe(
[
"Core.",
"# Install-wide instructions\n\nNever invent sources.",
`# Workspace: Launch\n\nYou're working in the workspace "Launch". Follow its context and your instructions for it below, over your general habits where they differ.\n\nThe workspace's context, which the user sets:\n\nThe spring launch.`,
"# Memory\n\n- Cite sources.",
].join("\n\n"),
);
});
test("shows an appendix on its own and ignores a blank one", () => {
expect(composeBotPrompt({ appendix: "Be brief." })).toBe(
"# Install-wide instructions\n\nBe brief.",
);
expect(composeBotPrompt({ system_prompt: "Core.", appendix: " " })).toBe("Core.");
});
test("leaves out empty parts and has no workspace outside one", () => {
expect(
composeBotPrompt({
+15 -5
View File
@@ -11,6 +11,11 @@ type WorkspaceBotPrompt = {
type Params = {
system_prompt?: string | null;
/**
* The install-wide text every bot gets, right after its own core
* instructions (Settings > General).
*/
appendix?: string | null;
memory?: string | null;
/**
* What the bot remembers about the user it's working with.
@@ -28,14 +33,15 @@ type Params = {
/**
* The full prompt a bot runs with (markdown): its own core instructions,
* then, in a workspace, the workspace's context and the bot's
* instructions there, then its core memory, its memory about the user and
* its memory in the workspace (said to be the bot's own notes, not the
* workspace's context). Empty parts are left out; the workspace heading
* always shows, so the bot knows where it is.
* then the install-wide appendix, then, in a workspace, the workspace's
* context and the bot's instructions there, then its core memory, its
* memory about the user and its memory in the workspace (said to be the
* bot's own notes, not the workspace's context). Empty parts are left out;
* the workspace heading always shows, so the bot knows where it is.
*/
export default function composeBotPrompt({
system_prompt,
appendix,
memory,
user_memory,
workspace,
@@ -48,6 +54,10 @@ export default function composeBotPrompt({
parts.push(system_prompt.trim());
}
if (appendix?.trim()) {
parts.push(`# Install-wide instructions\n\n${appendix.trim()}`);
}
if (workspace) {
const context = [workspace.description?.trim(), workspace.system_prompt?.trim()].filter(
Boolean,