docs(adr): ADR 0021, how the assistants are built (D35–D44)

Step 2's decisions: the answer streams over Serverpod's own stream; the
adapters are tested on replies written from the providers' documentation and
on recorded ones later; the assistants, their keys and prices sit in
deploy/assistants.env, passed whole to the server alone; the instance's
/privacy describes the whole instance; the agreement is kept on the grant;
figures are kept 3 months, by UTC's days and months; a member offers no
assistant yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 16:52:21 +01:00
commit 115683e180
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -4,8 +4,8 @@
developer: D1–D6 on 23 Oct; on 25 Oct what an assistant may see and do (D7–D12), what it developer: D1–D6 on 23 Oct; on 25 Oct what an assistant may see and do (D7–D12), what it
keeps and does, where it lives, the proposal's points 4–8 and 10, how often it warns and keeps and does, where it lives, the proposal's points 4–8 and 10, how often it warns and
when it is built (D13–D18), who signs up and who operates (D19–D22), waiting accounts when it is built (D13–D18), who signs up and who operates (D19–D22), waiting accounts
(D23–D26), what people are told and what never goes (D27–D30), and how the first step (D23–D26), what people are told and what never goes (D27–D30), how the first step is
is built (D31–D34). built (D31–D34) and the second (D35–D44).
- **Builds on:** ADR 0016 (the register), ADR 0019 (private studysets), ADR 0020 - **Builds on:** ADR 0016 (the register), ADR 0019 (private studysets), ADR 0020
(SimpleLogin), ADR 0022 (the hand-off and the connector gone). (SimpleLogin), ADR 0022 (the hand-off and the connector gone).
- **Changes:** the rule of 10 Oct 2026 that Halkyon calls no language model with an API - **Changes:** the rule of 10 Oct 2026 that Halkyon calls no language model with an API
@ -226,6 +226,70 @@ On 25 Oct, how the first step is built (sign-ups and operators):
opening the waiting accounts, the accounts and the operators; a badge on the Settings opening the waiting accounts, the accounts and the operators; a badge on the Settings
cog while someone waits, as the sync trouble dot is. cog while someone waits, as the sync trouble dot is.
On 25 Oct, how the second step is built (the assistants, their grants and figures):
- **D35 The answer streams over Serverpod's own stream** (recommended, over the whole
answer at once and over a route sending Server-Sent Events): `assistant.ask` is an
endpoint method returning a stream, over the WebSocket the client already keeps, signed
in as every call is (Caddy upgrades it as it is). Text appears as the model writes it,
and a round of the tools' loop ends as soon as its tool call is complete. A route of
our own would have needed its own check of the session, for nothing Serverpod's stream
lacks.
- **D36 The adapters are tested on replies written from the providers' documentation
first, and on recorded ones later** (recommended, over recording first, which would
have held the step up until the developer had keys, and over the documentation alone):
the fixtures follow Anthropic's and OpenAI's documented stream formats event by event;
`tool/record_llm.dart`, which the developer runs once with their own key, writes the
real replies beside them, and the tests run on both, so a provider that strays from its
documentation is caught there, not in someone's hands.
- **D37 The assistants are set in `deploy/assistants.env`** (recommended, over the whole
of `deploy/.env` going to the server and over three fixed numbered slots): Compose names
every variable it passes to a container, and the IDs an operator chooses can't be named
in `compose.yaml` ahead. The file of their own is passed whole to the server alone
(`env_file`, optional, so an instance without it starts as before), git-ignored,
mode 600, with an example committed. It holds the keys too (`ASSISTANT_CLAUDE_KEY`):
everything about the assistants is in one place, outside the database and its backups,
and nothing of `deploy/.env` reaches the server that doesn't today. It holds the key of
D30's IDs as well (`ASSISTANT_ID_KEY`, at least 32 characters, or a server with
assistants doesn't start): changing it gives everyone a new ID at the providers, and
costs nothing else.
- **D38 Prices sit beside the model** (recommended, over typing them in the app):
`ASSISTANT_CLAUDE_PRICE_INPUT` and `_PRICE_OUTPUT`, per million tokens, in
`ASSISTANT_CURRENCY` (USD unless set, as the providers bill), so a model and its price
change together, with one restart. A request's cost is worked out when it is counted
and kept with the figures, so a later price leaves the past as it was billed. A limit
is checked before each request against what was counted: the last request before it
may go past it by its own answer, which the request's ceiling on output tokens keeps
small.
- **D39 The instance's `/privacy` describes the whole instance** (recommended, over the
assistants alone): made from its settings on its web server, in English and German as
the profile pages are, readable before signing up: who may sign up, where data and
backups are, what is kept (addresses only as hashes, no IP address past the hour its
limits count, sessions), private studysets, then each assistant (provider, model, what
goes, D27) and whether personal keys are allowed. halkyon.li has no website page, and a
family member reads this one before they sign up.
- **D40 The agreement is kept on the grant** (recommended, over the person's sealed
settings): the grant records when the person agreed and to which provider (its
protocol and host), and `assistant.ask` refuses (`notAgreed`) until they have, so a
modified app can't skip it and every device of theirs knows. Should the operator move
an assistant to another provider, the person is asked again; a new model of the same
provider asks nothing. The operator sees that they agreed, and when.
- **D41 Figures are kept 3 months** (the developer's choice, over 13 months, which were
recommended, and over keeping them as long as the account): enough for the last bills,
less kept about each person. Older days are deleted daily, and all of them with the
account.
- **D42 Days and months are UTC's** (recommended, over the instance's time zone, which no
setting holds today): the providers bill by UTC, so the figures add up to their bills;
a day's limit resets at midnight UTC, which the app shows in the person's own time.
- **D43 A member service offers no assistant yet** (recommended, over building the
register's routes for grants now): grants and figures live on the register (Learn's
server), whose service asks. Todo, a member, answers that it has none, until a member
needs the assistant; then the member asks the register before forwarding, as it asks
everything about accounts (ADR 0016).
- **D44 Nobody is granted an assistant that isn't set**: a grant names the assistant by
its ID, and stays, unused, should the operator remove that assistant from the file;
put back, it holds again, with the person's agreement if the provider is the same.
## Proposal ## Proposal
1. **Who signs up, per instance:** `SIGN_UP`: `open` (as today), `approval`, or 1. **Who signs up, per instance:** `SIGN_UP`: `open` (as today), `approval`, or
@ -243,9 +307,9 @@ On 25 Oct, how the first step is built (sign-ups and operators):
addresses. addresses.
4. **The assistants**, set by the operator (D4): one or more, each with an ID, the name 4. **The assistants**, set by the operator (D4): one or more, each with an ID, the name
people see ("Claude Sonnet"), its protocol (`anthropic`, or `openai` for every people see ("Claude Sonnet"), its protocol (`anthropic`, or `openai` for every
OpenAI-compatible service), its base URL (the provider's own unless set) and model in OpenAI-compatible service), its base URL (the provider's own unless set), model and key
`deploy/.env` (`ASSISTANTS=claude,gpt`, then `ASSISTANT_CLAUDE_NAME`, `_PROTOCOL`, in `deploy/assistants.env` (D37: `ASSISTANTS=claude,gpt`, then `ASSISTANT_CLAUDE_NAME`,
`_BASE_URL`, `_MODEL`), and its key in the passwords (`ASSISTANT_CLAUDE_KEY`). There `_PROTOCOL`, `_BASE_URL`, `_MODEL`, `_KEY`, `_IMAGES`, and the prices of D38). There
rather than in the app: the keys stay out of the database and its hourly backups, where rather than in the app: the keys stay out of the database and its hourly backups, where
the operator keeps the instance's other secrets. None set: no assistant on the the operator keeps the instance's other secrets. None set: no assistant on the
instance, as today. Adding Lumo once its API is out, or switching a model, is a change instance, as today. Adding Lumo once its API is out, or switching a model, is a change
@ -258,9 +322,9 @@ On 25 Oct, how the first step is built (sign-ups and operators):
providers: one more container to run and update, for providers never used) and providers: one more container to run and update, for providers never used) and
langchain_dart (a large framework for a small job). langchain_dart (a large framework for a small job).
6. **Grants** (D2, D4): nobody has an assistant until the operator grants it to them, one 6. **Grants** (D2, D4): nobody has an assistant until the operator grants it to them, one
assistant at a time, each grant with its limits. The person agrees once to what goes where: the provider's name, that the assistant at a time, each grant with its limits. The person agrees once to what goes
instance sees the text on its way, and that the operator sees how much they used, where: the provider's name, that the instance sees the text on its way, and that the
never what. operator sees how much they used, never what; the grant keeps their agreement (D40).
7. **Asking:** the field offers the assistants granted beside the web search (the one last 7. **Asking:** the field offers the assistants granted beside the web search (the one last
used first), the app shows what it will send, the person sends it to the instance used first), the app shows what it will send, the person sends it to the instance
(`assistant.ask`, the answer streamed), the instance checks the grant and what is left (`assistant.ask`, the answer streamed), the instance checks the grant and what is left
@ -275,14 +339,15 @@ On 25 Oct, how the first step is built (sign-ups and operators):
a personal key, a switch beside it). a personal key, a switch beside it).
8. **Limits and statistics** (D3): per grant, per day and per month (a day's limit stops a 8. **Limits and statistics** (D3): per grant, per day and per month (a day's limit stops a
runaway loop early). Counted per person, day and assistant: requests, input and output runaway loop early). Counted per person, day and assistant: requests, input and output
tokens, and the cost at the prices the operator enters per model (per million tokens; tokens, and the cost at the prices the operator enters per model (per million tokens,
without prices, tokens only, and limits in tokens). Neither prompts nor answers are D38; without prices, tokens only, and limits in tokens), by UTC's days (D42), for
kept. The operator sees everyone's figures in the app, per person and month; each 3 months (D41). Neither prompts nor answers are kept. The operator sees everyone's figures in the app, per person and month; each
person sees their own. person sees their own.
9. **What people are told** (D27): the provider and model, what is sent, that the instance 9. **What people are told** (D27): the provider and model, what is sent, that the instance
sees it in passing, that the operator sees each person's figures, that the instance sees it in passing, that the operator sees each person's figures, that the instance
keeps no content, and that conversations are kept sealed on the person's devices keeps no content, and that conversations are kept sealed on the person's devices
(D13). (D13): on the consent sheet, in Settings › Assistant, and on the instance's `/privacy`
(D39).
10. **Personal keys** (D5, D6), where the operator switches them on (`ASSISTANT_OWN_KEYS`, 10. **Personal keys** (D5, D6), where the operator switches them on (`ASSISTANT_OWN_KEYS`,
off unless `on`; `instance.info()` says so, and the app offers them only then): a off unless `on`; `instance.info()` says so, and the app offers them only then): a
person adds a provider in Settings (protocol, base URL, model, their key), and may add person adds a provider in Settings (protocol, base URL, model, their key), and may add