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
@@ -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>
);