From a048929e82299260599e54e8069ccf54413f63dc Mon Sep 17 00:00:00 2001 From: Nick Launces <1409277+nicklaunches@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:12:06 -0700 Subject: [PATCH] Let a person set standing instructions that every coworker follows A deployment had two carriers for a durable instruction and both are about the work rather than about the person. A coworker's role belongs to the coworker and reads the same to everybody who talks to it; a skill is pulled in for one task. Neither can hold "always write to me in British English" or "we are a two-person company, never call us a team", which is a fact about the person and true in every channel, so people retyped it into the composer or went without. Settings now carries one text per person. It is spliced into every built-in coworker's prompt straight after the role, on every run in every channel, and into the runs a routine starts on the owner's behalf while they are asleep, so overnight work reads the way their own turn would. The block carries its own precedence sentence: the role decides what a coworker does and these decide how it does it, because a person may say how they want things done and must not be able to say what a coworker is for. The text belongs to the person who wrote it. The routes take the actor from the session and never from the path or the body, there is no route for reading or writing somebody else's, an administrator included, and the row cascades with the account. Remote AG-UI Bots are not given it: that prompt is composed at somebody else's endpoint, so there is no knowing whether it is read or how it ranks against the role. Empty is absence rather than a stored empty string, so there is one representation of "has written none". Reading them fails silently at build time: a coworker that could not be told loses a paragraph, where one that refused to start would lose the conversation. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WaHWJ1niprhBc5NzJ9pxme --- CHANGELOG.md | 17 + .../settings/standing-instructions.tsx | 115 + app/src/lib/settings/mutations.ts | 24 + app/src/lib/settings/queries.ts | 28 + app/src/routes/_authed/settings/index.tsx | 6 + server/drizzle/0026_user_instructions.sql | 8 + server/drizzle/meta/0026_snapshot.json | 3073 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/app.ts | 99 + server/src/copilot.ts | 116 +- server/src/db/schema/core.ts | 27 + server/src/index.ts | 23 + server/src/user-instructions.ts | 107 + server/tests/copilot.test.ts | 206 ++ server/tests/schema.test.ts | 59 + .../settings-instructions-routes.test.ts | 317 ++ .../user-instructions.integration.test.ts | 150 + 17 files changed, 4381 insertions(+), 1 deletion(-) create mode 100644 app/src/components/settings/standing-instructions.tsx create mode 100644 app/src/lib/settings/mutations.ts create mode 100644 app/src/lib/settings/queries.ts create mode 100644 server/drizzle/0026_user_instructions.sql create mode 100644 server/drizzle/meta/0026_snapshot.json create mode 100644 server/src/user-instructions.ts create mode 100644 server/tests/settings-instructions-routes.test.ts create mode 100644 server/tests/user-instructions.integration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d446e0438..e3719eb96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A person can set standing instructions that every coworker follows + +Settings now has a box for standing instructions: one piece of text per person, saved once and +spliced into every built-in coworker's prompt, in every channel, on every run, including the runs a +routine starts overnight. It is the place for what is true of every task rather than of any one of +them, such as how somebody wants to be written to or what their company is and is not to be called. +A coworker's role still decides what it does; these decide how it does it, and the prompt says so, +so an instruction cannot quietly redefine what a coworker is for. + +Instructions belong to the person who wrote them. Nobody, administrators included, can read or set +somebody else's, and they are deleted with the account. A coworker running at a remote AG-UI +endpoint is not sent them, since this deployment does not compose that prompt. Nothing is added to +any prompt until somebody writes something, so a deployment where nobody uses this behaves exactly +as before. + +This adds migration `0026_user_instructions`, which creates one table. + ### Coworkers are made in a wizard and managed in a dialog Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs, diff --git a/app/src/components/settings/standing-instructions.tsx b/app/src/components/settings/standing-instructions.tsx new file mode 100644 index 000000000..c4ea2c32e --- /dev/null +++ b/app/src/components/settings/standing-instructions.tsx @@ -0,0 +1,115 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { PageSection } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { saveInstructionsMutationOptions } from "@/lib/settings/mutations"; +import { + INSTRUCTIONS_LIMIT, + instructionsQueryOptions, +} from "@/lib/settings/queries"; +import { queryClient } from "@/query-client"; + +/** + * One box, applied to every coworker in every channel. + * + * WHAT IT IS FOR, said on the screen rather than left to be guessed. A coworker's role and this are + * both durable instructions and a person meeting them for the first time has no reason to know which + * belongs where, so the description draws the line the prompt draws: the role says what a coworker + * is for, this says how the person wants things done. Without that sentence the obvious mistake is + * to write a job description here and get it applied to every coworker at once. + */ +export function StandingInstructions() { + const stored = useQuery(instructionsQueryOptions()); + const save = useMutation(saveInstructionsMutationOptions(queryClient)); + + const [draft, setDraft] = useState(null); + const [problem, setProblem] = useState(null); + const [saved, setSaved] = useState(false); + + /* + * The box holds the stored text until somebody types, and their typing after that. + * + * Null is "has not been edited", which is not the same as "is empty": seeding the state with "" + * and then filling it in when the read lands would overwrite whatever they had already started + * typing into a box that was ready before the network was. + */ + useEffect(() => { + if (stored.data !== undefined && draft === null) setDraft(stored.data); + }, [stored.data, draft]); + + const text = draft ?? stored.data ?? ""; + const over = text.trim().length > INSTRUCTIONS_LIMIT; + const unchanged = stored.data !== undefined && text === stored.data; + + const submit = () => { + setProblem(null); + setSaved(false); + save.mutate(text, { + onError: (thrown: Error) => setProblem(thrown.message), + /* The server trims, so the box settles to what was actually stored rather than what was sent. */ + onSuccess: (asStored) => { + setDraft(asStored); + setSaved(true); + }, + }); + }; + + return ( + + {stored.isPending ? null : stored.error ? ( +

+ {stored.error.message} +

+ ) : ( +
+