How the budgets, handles, the operator's screens and effort read where the decisions left something open: optional limits, money stopping only priced assistants, a handle typed whole as one suggested, 50 at a time with no search where lists stay short, lists changed in place after an action, the effort's ceiling on output, and own keys without effort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
525 lines
38 KiB
Markdown
525 lines
38 KiB
Markdown
# ADR 0021: A private instance: approved sign-ups and an assistant the operator grants
|
||
|
||
- **Status:** accepted 25 Oct 2026, built on 25 and 26 Oct for v0.21.0 (D18). Decided by
|
||
the 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 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), how the first
|
||
step is built (D31–D34), the second (D35–D46) and the third (D47–D56), whose D55 and D56
|
||
the developer amended on 26 Oct to what was built; on 26 Oct, after the first use, the
|
||
operator's screen, budgets per person and the assistants' effort (D57–D63), for
|
||
v0.22.0.
|
||
- **Builds on:** ADR 0016 (the register), ADR 0019 (private studysets), ADR 0020
|
||
(SimpleLogin), ADR 0022 (the hand-off and the connector gone).
|
||
- **Amended by:** [ADR 0024](0024-languagetool-sidecar.md), D3 (26 Oct 2026): the field's
|
||
Check (D54) goes to the instance's LanguageTool first where it checks the language,
|
||
whose sheet offers to ask the assistant why; elsewhere it asks the assistant, as D54
|
||
has it.
|
||
- **Changes:** the rule of 10 Oct 2026 that Halkyon calls no language model with an API
|
||
key, for an instance whose operator opts in.
|
||
|
||
## Context
|
||
|
||
The developer may stop the public instance and run halkyon.li for themselves and their
|
||
close family instead. They don't know in advance which addresses their family will sign in
|
||
with, and with SimpleLogin (ADR 0020) the instance keeps no addresses anyway.
|
||
|
||
On 10 Oct the developer ruled out API keys for language models: on a public instance anyone
|
||
could drive up the bill, and each person should decide what a model sees. Assistants came
|
||
through people's own subscriptions instead: the connector (ADR 0006) and the hand-off to
|
||
Claude or Lumo, both gone since 23 Oct (ADR 0022). A family instance whose operator
|
||
approves every account takes away the first reason; the second holds if the app shows
|
||
what it sends.
|
||
|
||
People in one family may prefer different providers (the developer: "I might use myself
|
||
the Claude API while other might want to use the OpenAI API, then one other the Proton
|
||
Lumo API"). A plan for Claude's or ChatGPT's apps is no API access: the APIs are billed
|
||
apart, by their providers' consoles.
|
||
|
||
Two protocols reach nearly every provider: Anthropic's Messages API (Claude), and the
|
||
OpenAI-compatible chat completions (OpenAI, Mistral, Google Gemini's compatibility
|
||
endpoint, OpenRouter, local models through Ollama, llama.cpp or vLLM). Proton announced a
|
||
Lumo API for autumn or winter 2026 without publishing its format; community projects
|
||
(lumo-tamer, an unofficial tracker) show an OpenAI-compatible interface. Still none by
|
||
25 Oct, but for the unofficial tracker's "early access".
|
||
|
||
No remote provider keeps what it is asked confidential while its model answers: the model
|
||
reads plain text. Lumo too: its prompts are encrypted beyond TLS on their way to Proton's
|
||
servers, decrypted there to be answered and, Proton says, discarded; its zero-access
|
||
encryption covers the chats it stores, which an API call doesn't make. What keeps a
|
||
private studyset private there is a promise, not cryptography. Only a model on the
|
||
person's own machine does without one.
|
||
|
||
## Decisions (23 Oct 2026, the developer's)
|
||
|
||
- **D1 Sign-ups wait for the operator's approval** (over invitation links, which were
|
||
recommended, and a sign-up window): anyone can sign up; nothing works until the operator
|
||
approves them.
|
||
- **D2 The assistant runs on the instance's key, granted per person** (recommended, over
|
||
each person's own key and both): the operator pays and switches it on for whom they
|
||
choose.
|
||
- **D3 Individual limits and statistics** (the developer: "so I can see after the fact who
|
||
potentially drove up the bill"): each person's use is limited and counted, and the
|
||
operator sees it per person.
|
||
- **D4 Several providers, all on the operator's keys** (over personal keys alone, and over
|
||
both, which was recommended): the operator sets up the assistants the instance offers
|
||
and grants each person one or more.
|
||
- **D5 Personal keys stay on the person's devices** (recommended, over keeping them on the
|
||
instance): the app calls the provider itself, and the instance sees neither key nor
|
||
text.
|
||
- **D6 Personal keys only where the operator allows them** (over every instance, which was
|
||
recommended): an instance setting, off unless switched on.
|
||
|
||
On 25 Oct, what an assistant may see and do (the developer: "Only what is chosen, within a
|
||
studyset, within a tree of studysets, and a complete library (maybe even an option
|
||
including/excluding private studysets?)"):
|
||
|
||
- **D7 The person chooses the scope of each question:** the parts they tick (the field's
|
||
chips, as for a web search), this studyset (the one open, with everything inside it, as
|
||
studying it covers), its tree (from its top studyset down), or their library (decided
|
||
over a scope for a studyset without its topics). Beyond the parts ticked, nothing is
|
||
put into the request whole, as a library outgrows any model's context: the model looks
|
||
things up through tools the app answers on the device, read-only and within the scope,
|
||
each lookup shown in the conversation and counted in the person's limits.
|
||
- **D8 Private studysets stay out of a wider scope unless switched on** (recommended, over
|
||
taking them in with the warning, and over never sending them): beside the scope,
|
||
"Include private studysets", off for every question.
|
||
- **D9 A warning whenever a private studyset's content would go to a remote model**
|
||
(recommended): it leaves end-to-end encryption; the warning names the provider and links
|
||
its terms. The same for every remote provider, Lumo included (see *Context*), not
|
||
graded by promises the app can't verify, and with no exception for an address on the
|
||
local network, which may be a company's or a shared one; none for a model at a loopback
|
||
address, on the device itself. Shown for parts ticked as for the switch of D8.
|
||
- **D10 Changes only as drafts** (recommended, over answers alone and over writes with
|
||
Undo): the model proposes cards, notes or edits, which open in the composer or editor;
|
||
nothing is saved until the person accepts it, so neither a wrong answer nor
|
||
instructions hidden in a copied studyset (prompt injection) change the library.
|
||
- **D11 Progress never goes along** (over a chip for it, which was recommended): no
|
||
reviews, ratings, statistics or study time, in any scope.
|
||
- **D12 Text first, images when ticked or asked for** (the developer: "Can't it generally
|
||
be text, only images if necessary (e.g. graphs within a PDF)?"; then the recommended
|
||
way): text by default, a PDF's as PDFium reads it; where a part in scope holds an image
|
||
(a card's picture, a PDF page with figures), its image is a chip of its own, off; and
|
||
the model may ask for one, which the person approves each time. Only for an assistant
|
||
that reads images. The app can't tell by itself when an image is needed: a graph is
|
||
often drawn as lines, not stored as a picture.
|
||
- **D13 Conversations are kept, sealed** (recommended, over this device only and over
|
||
none): in a synced table sealed with the person's own key (ADR 0019, as their settings
|
||
and tags), so they reach all their devices and the instance can't read them, as Proton
|
||
can't read Lumo's stored chats; kept until the person deletes one.
|
||
- **D14 The first version does four things:** answers questions about what is open, in
|
||
the field on Home, in the Library, on a studyset and in a session (D7's scopes); makes
|
||
cards from text (a paragraph, a note, a PDF page) as drafts (D10); explains, in a
|
||
session after Bad, why the answer is what it is, from the card's own parts; and checks a
|
||
written answer or a sentence written in the studyset's language, saying what is wrong
|
||
and why, as feedback, changing nothing.
|
||
- **D15 The module and a package of its own** (recommended, over Learn alone first):
|
||
grants, limits, statistics and the forwarding are halkyon_base's, the register's, one
|
||
per instance as accounts are, so a person's grant and limits hold in every service of
|
||
the instance; the two adapters (5) are `halkyon_llm`, a pure Dart package, BSD-3-Clause
|
||
as `halkyon_oidc` is, in the monorepo, its publishing to pub.dev asked first; the field,
|
||
the scopes and their tools are Learn's.
|
||
- **D16 Points 4 to 8 and 10 of the proposal stand as written** (recommended, over going
|
||
through them one by one); what comes up while building is put to the developer.
|
||
- **D17 The warning of D9 is asked once per studyset and assistant** (recommended, over
|
||
once per session and every time), as explaining a miss and checking an answer go out
|
||
once per card: the answer is kept in the person's sealed settings, the studyset's
|
||
privacy dialog shows it with Take back, and the assistant's button in that studyset
|
||
carries a mark while it holds.
|
||
- **D18 Built for v0.21.0** (over shipping the batch held for it first, which was
|
||
recommended, and over later): the batch on `feat/remove-github` (GitHub's removal,
|
||
build 24) waits for it.
|
||
- **D19 `SIGN_UP` takes three values** (recommended, over approval on or off alone and
|
||
over invitation links now): `open`, `approval`, `closed` (point 1).
|
||
- **D20 The first operator by SQL** (recommended, over a one-time setup code, over a
|
||
handle in `deploy/.env` and over the instance's first account): the operator signs up
|
||
in the app, then, on the server, one statement written here makes that account an
|
||
operator and approves it. No code hands out the role, so nothing is there for a
|
||
stranger to guess; whoever runs an instance reaches its database anyway.
|
||
- **D21 Further operators are named in the app** (recommended, over SQL for each): an
|
||
operator names another by handle and can take it back; the last operator can't be
|
||
removed.
|
||
- **D22 An operator can suspend an approved account** (recommended, over deleting it too
|
||
and over grants alone): its sessions end and it waits again, nothing syncing, its data
|
||
kept, until an operator approves it anew. Deleting stays the person's own act, or the
|
||
operator's SQL.
|
||
- **D23 A waiting account shows a wait screen only** (recommended, over using the app on
|
||
the device until approved): it says the account waits for the operator, and offers to
|
||
delete it; nothing syncs or uploads, so a refusal loses nothing.
|
||
- **D24 A matching code tells the operator who waits** (recommended, over an
|
||
introduction line and over the handle alone): the wait screen shows a short code of
|
||
four words, and the operator sees the same code beside the handle, asking the person
|
||
(by phone, in person) what theirs says; a stranger who took a family member's handle
|
||
can't know it.
|
||
- **D25 The limits stand as proposed** (recommended, over 7 days and over 30): deleted
|
||
after 14 days unapproved, at most 10 waiting at once, sign-ups per network limited as
|
||
today's, a badge in the operator's app as the only notice.
|
||
- **D26 Someone refused may sign up again and wait again** (recommended, over refusing
|
||
their SimpleLogin identity for 30 days): nothing about them is kept; the cap and the
|
||
network limits hold strangers back.
|
||
- **D27 Each instance says what its assistants do, in the app and on a page of its own**
|
||
(over the app alone, which was recommended, and over the website's page alone, which
|
||
describes learn.halkyon.eu): the consent sheet when a grant is first used, the same
|
||
facts in Settings › Assistant at any time, and `/privacy` on the instance's web server,
|
||
made from its settings in English and German as the profile pages are, readable before
|
||
signing up (point 9).
|
||
- **D28 Others' posts go to a model only with their author's agreement** (the developer:
|
||
"not to send the content from others without their explicit (direct or indirect)
|
||
agreement to LLM providers"; recommended, over a licence for one's posts and over
|
||
never): a switch in the account's settings, "Others may send my posts to their AI
|
||
assistant", off unless switched on and shown on the profile; while it is on, their
|
||
posts in discussions and feedback threads are chips like the others, off. The rule
|
||
binds what Halkyon sends on a person's behalf: someone who copies text into a model by
|
||
hand acts on their own, and the app doesn't try to stop it. No standard says this yet
|
||
(25 Oct): the IETF's AI preferences are drafts naming training and search, not a
|
||
model's answer; Creative Commons' signals are still being worked out. Should one come,
|
||
the switch can be expressed in it.
|
||
- **D29 A copy's licence is its agreement** (recommended, over an author's switch per
|
||
studyset): publishing takes a licence that allows passing on (ADR 0005), so a copy is
|
||
the person's own to send.
|
||
- **D30 The provider learns an opaque ID per person** (recommended, over nothing): with the
|
||
operator's key, each request carries an ID the instance derives from the account with a
|
||
key of its own (OpenAI's `safety_identifier`, OpenRouter's `user`, Anthropic's
|
||
`metadata.user_id`; servers with no such field get none), never the handle, so
|
||
the provider can act against one person's abuse without blocking the operator's key,
|
||
and can't tell who it is.
|
||
|
||
On 25 Oct, how the first step is built (sign-ups and operators):
|
||
|
||
- **D31 A waiting account has no session** (recommended, over a session with a `waiting`
|
||
scope that every endpoint, the sync and the uploads would have to refuse): every
|
||
session on every service is made in one place (`issueSession`), which refuses while
|
||
the account waits and hands the device a waiting pass instead (a random secret, kept as
|
||
its hash) with the matching code. The pass opens only the wait screen's calls: whether
|
||
the account still waits, deleting the account, and forgetting the pass. Once approved,
|
||
the wait screen says so, the pass going with that answer, and the person signs in once
|
||
more as they did before (the developer's choice, recommended, over the pass handing
|
||
over a session): that sign-in opens the keys and, after a password's sign-up, sets up
|
||
the authenticator as every sign-in does, and a pass is never worth a session. The pass
|
||
deletes only a new account that was never approved: a suspended one keeps its data,
|
||
and deleting it takes its owner's password and a code, as ADR 0017 has every deletion.
|
||
A pass holds 30 days, at most five per account. A member asks the register before it
|
||
makes a session, as it asks everything else about accounts (ADR 0016).
|
||
- **D32 The operator role is a scope**, `operator`, on the account and its sessions, as
|
||
`identified` is (recommended, over a table of operators): every operator's call requires
|
||
it (`requiredScopes`). The first operator, once signed up, is made by two statements on
|
||
the register's database, run as `deploy` in the deploy's folder: the scope onto the
|
||
account, and onto its sessions, as scopes are copied onto them (handle in lower case):
|
||
|
||
```sql
|
||
BEGIN;
|
||
UPDATE "serverpod_auth_core_user"
|
||
SET "scopeNames" = ("scopeNames"::jsonb || '["operator"]')::json
|
||
WHERE "id" = (SELECT "authUserId" FROM "public_profile" WHERE "handleKey" = 'their_handle')
|
||
AND NOT ("scopeNames"::jsonb ? 'operator');
|
||
UPDATE "serverpod_auth_core_session"
|
||
SET "scopeNames" = ("scopeNames"::jsonb || '["operator"]')::json
|
||
WHERE "authUserId" = (SELECT "authUserId" FROM "public_profile" WHERE "handleKey" = 'their_handle')
|
||
AND NOT ("scopeNames"::jsonb ? 'operator');
|
||
COMMIT;
|
||
```
|
||
|
||
and an operator never waits: the wait screen's next look approves them and gives them
|
||
their session.
|
||
- **D33 The matching code is four words of BIP-39's English list, read by the operator**
|
||
(the developer's "Four words" over six digits, which were recommended; then the
|
||
operator reading and BIP-39's list, both recommended, over the person reading theirs
|
||
out and over EFF's short list): the operator reads the words beside the handle and asks
|
||
whether the person sees them, who only says yes or no. The wait screen says that
|
||
Halkyon never asks anyone to read words out, as a recovery phrase is words too, of the
|
||
same list (already in the repository under the MIT License, unique by their first four
|
||
letters; four words hold 44 bits).
|
||
- **D34 The operator's section is a card in Settings**, "This instance", below the
|
||
account's (recommended, over the Profile tab): halkyon_app's, shown to operators alone,
|
||
opening the waiting accounts, the accounts and the operators; a badge on the Settings
|
||
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.
|
||
- **D45 Chat completions are sent in each known host's dialect, and in a common one to
|
||
every other** (recommended, over the fields every service takes, which loses OpenAI's
|
||
`store: false` and its reasoning models' ceiling, and over a dialect the operator
|
||
names): OpenAI, Mistral and Gemini refuse a field they don't know, and they differ in
|
||
the ceiling's field (`max_completion_tokens`, `max_tokens`), in usage asked for or
|
||
always sent, and in `store`. By the base URL's host, OpenAI (api.openai.com), Mistral,
|
||
Gemini and OpenRouter each get only their fields; any other host gets what Ollama,
|
||
vLLM and llama.cpp all take. Replies are read leniently whatever the host, as each
|
||
streams usage, tool calls and errors in its own way.
|
||
- **D46 OpenAI's Responses API is a third protocol, `openai-responses`** (recommended,
|
||
over chat completions alone): from GPT-5.4 on, OpenAI's models call tools in chat
|
||
completions only without reasoning, and GPT-6 Astra and 6.1 Sol only through the
|
||
Responses API. Stateless: `store: false`, so OpenAI keeps no conversation, and each
|
||
request carries the whole of it, with the model's encrypted reasoning items as it
|
||
streamed them, sent back in their place (as Anthropic's thinking blocks are), and the
|
||
`phase` of its messages. Ollama, vLLM and OpenRouter speak it as well.
|
||
|
||
On 26 Oct, how the third step is built (the assistant answering in Learn):
|
||
|
||
- **D47 A conversation shows in a sheet over the screen it was asked on** (recommended,
|
||
over a screen of its own and over the answer unfolding under the field): at half
|
||
height, dragged up to full, follow-up questions in the same sheet, so the card in a
|
||
session or the studyset asked about stays in view.
|
||
- **D48 Kept conversations are found from the field** (recommended, over a tile in
|
||
Settings › Assistants and over both): a history button in the field lists them, newest
|
||
first, each with its assistant and scope, those about where the person is first;
|
||
deleting one asks first.
|
||
- **D49 The scope is one chip with a menu** (recommended, over a segmented row of the
|
||
four): it leads the parts' chips ("Only what I tick"), and its menu holds This
|
||
studyset, Its tree, Library and D8's switch, so the field takes one more line at most.
|
||
- **D50 The field starts with only what is ticked** (recommended, over where the person
|
||
is and over the last scope used): nothing goes beyond the chips ticked and the model
|
||
gets no lookups until the person widens the scope, as the chips themselves start
|
||
unticked (decided 10 Oct).
|
||
- **D51 A conversation is a row and its turns, appended** (recommended, over one row
|
||
rewritten per conversation): `assistant_conversation` holds its assistant, scope and
|
||
title, `assistant_turn` one turn each, never changed once written (ADR 0001, rule 2), so
|
||
two devices continuing it offline lose nothing, and each turn is sealed alone (D13), so
|
||
a long conversation isn't sealed and synced anew with every question. Deleting one
|
||
blanks its turns, then deletes them (ADR 0002, rule 2).
|
||
- **D52 A device that can't seal answers but keeps nothing** (recommended, over offering
|
||
no assistant there): where the account's keys aren't open, the sheet says the
|
||
conversation isn't kept, and it is gone once the sheet closes; nothing unsealed is
|
||
stored or synced, as with own keys.
|
||
- **D53 Explaining a miss is offered in a snack bar after Bad** (recommended, over the card
|
||
waiting after Bad with Explain and Next, and over Explain beside the answer before
|
||
rating): rated Bad, a snack bar offers Explain for a few seconds, which opens the
|
||
conversation sheet over the next card, explaining the card just rated from its own
|
||
parts; a session that doesn't use it goes on as fast as before.
|
||
- **D54 Checking is a wrong typed reading's Why? and Check in the field** (recommended,
|
||
over a sentence exercise on vocabulary cards and over either part alone): a typed
|
||
reading checked wrong offers Why? beside it; the field offers Check beside Ask, which
|
||
checks what the person typed as writing in the studyset's language (with the card as
|
||
context in a session) and says what is wrong and why, changing nothing.
|
||
- **D55 Cards made from text are drafts in a sheet** (recommended, over one draft at a
|
||
time in the composer): listed as the text import lists cards, each chosen or not, a
|
||
tap opening one in a small sheet of its own to change it (front and back, both
|
||
required); adding takes those chosen, marked as made by an assistant
|
||
(`origin: assistant`); nothing is added before (D10). Amended 26 Oct, as built: the
|
||
sheet replaced the composer, which saves a card as soon as it is left, so it would add
|
||
the draft before the person chose to.
|
||
- **D56 Cards are made from a note, a PDF page or pasted text** (the three recommended,
|
||
over asking in the field as well): a note's app bar (the whole note, its title and
|
||
text), the PDF reader's card panel (the page shown, its text as PDFium reads it), and
|
||
Add material › Flashcards from a text (a paragraph pasted or typed). Amended 26 Oct, as
|
||
built: a note's text selected is left for later, as the note editor's selection can't
|
||
be reached from its app bar as the PDF reader's is; and the entry is named for what it
|
||
makes, as the other Add material entries are, not "With an assistant".
|
||
|
||
On 26 Oct, after the first use on learn.halkyon.eu (the developer: granting three people
|
||
all models of two providers would take dozens of sheets; a list of hundreds waiting would
|
||
push everything else down; and the effort of each model), how the operator's side and the
|
||
assistants' effort work:
|
||
|
||
- **D57 The months of the figures run oldest first** (recommended, over the newest first,
|
||
as built): Aug · Sep · Oct, the current month chosen on the right, as a timeline reads.
|
||
- **D58 A budget per person, and the assistants it covers** (recommended, over granting
|
||
several people and assistants at once with limits per assistant, and over a budget
|
||
with caps per assistant): a grant says only which assistant a person may use, and keeps
|
||
their agreement (D40); the limits move to the person, one daily and one monthly budget
|
||
for all their assistants together, as proxies in front of several providers keep them.
|
||
One sheet grants several people several assistants, each person getting the budget
|
||
set there. Amends D3, whose limits were each grant's.
|
||
- **D59 The budget counts money and tokens, each optional** (the developer's choice, over
|
||
money alone with unpriced assistants counting nothing, which was recommended, and over
|
||
refusing an assistant without prices): a daily and a monthly limit in the instance's
|
||
currency, and a daily and a monthly one in tokens; whichever runs out first stops the
|
||
person. An assistant without prices counts against the tokens alone.
|
||
- **D60 Assistants are grouped by their provider, each in its colour** (recommended, over
|
||
the provider's name alone and over its logo too): under the provider's name (Anthropic,
|
||
OpenAI, Google, Mistral, OpenRouter by the host; any other host by itself), with a
|
||
small accent in the provider's colour, wherever assistants are listed. No logos: they
|
||
are their companies' trademarks, under each one's terms.
|
||
- **D61 A handle is chosen among those that exist** (the developer's ask): typing one
|
||
suggests the accounts whose handle starts so, as the operator's search finds them, and
|
||
Grant and Appoint take only a handle suggested.
|
||
- **D62 The operator's screen is an overview of screens** (recommended, over its cards
|
||
showing five each with Show all, and over tabs): Waiting, Operators, Accounts and
|
||
Assistants, each a row with its count leading to a screen of its own, searched and
|
||
loaded 50 at a time as it scrolls, so hundreds waiting push nothing else down.
|
||
- **D63 Effort is the operator's range, chosen by the person within it** (recommended,
|
||
over a fixed level per assistant and over any level the model has): `assistants.env`
|
||
gives each assistant a default and a highest level (`low`, `medium`, `high`, `xhigh`,
|
||
`max`; Anthropic's names, mapped to each provider's own); the field offers a chip with
|
||
the levels allowed, remembered per assistant on the device, and the instance refuses
|
||
one above the highest. Effort spends more tokens at the same price per token, so the
|
||
budget (D58) still bounds what it costs.
|
||
|
||
As built on 26 Oct, for v0.22.0 (build 25, which the server needs: `minimumAppBuild` 25,
|
||
as older apps read limits per grant), D57–D63 read so where they left something open:
|
||
|
||
- **Budgets** (D58, D59): each of the four limits may be empty, and all four empty
|
||
leaves the person no budget; granting someone who has one replaces it, which the sheet
|
||
says first, naming whose. Money stops only an assistant with prices: one without counts
|
||
its tokens, which the token limits stop, and the sheet offers money limits only while an
|
||
assistant set up has prices. A change that leaves out an assistant no longer set up
|
||
drops its grant, which the sheet says.
|
||
- **Handles** (D61): Grant and Appoint take a handle picked among those suggested, or
|
||
typed whole as an account's (Enter picks it); any other is refused before anything is
|
||
asked.
|
||
- **The operator's screens** (D62): read 50 at a time from where the list stands, the
|
||
next 50 once the person scrolls to within 600 pixels of its end. Accounts and the
|
||
assistants' people are searched by their handle's start; Waiting and Operators aren't,
|
||
as at most 10 accounts wait at once (point 3) and operators are few. After an action the
|
||
list changes in place (approved, refused and taken back leave it; suspended and named
|
||
change their row; a changed grant reads its person anew) rather than reading everything
|
||
again.
|
||
- **Effort** (D63): `_MAX_EFFORT` without `_EFFORT` stops the server as it starts, as any
|
||
other setting it can't read. A question asks for its effort's ceiling on output, which
|
||
the instance holds it to (`low` 8,192 tokens to `max` 65,536; the model's own effort
|
||
16,384), since the thinking counts towards it. The level chosen in the field is the
|
||
person's for that assistant on that device (`askEfforts`): a conversation continued from
|
||
the history asks at it too, while the flashcards proposed, Explain and Why? ask at the
|
||
assistant's own, as nobody chose one there. A person's own key gets no effort, as its
|
||
provider's levels aren't known, and keeps the ceiling of 4,096 tokens it had.
|
||
- **The migration** moves each person's grants' limits into one budget (per unit, their
|
||
sum; a grant without a limit leaves that limit empty) and then drops the grants' three
|
||
limit columns. Unlike Learn's other migrations, it doesn't keep the old server working
|
||
until the switch: in the minute between them, the old server's assistant calls fail
|
||
(asking, granting, the figures), everything else serving on. The developer accepted
|
||
this (26 Oct), as assistants went live that day with one person using them, over
|
||
keeping the columns unused for a release and dropping them in the next.
|
||
|
||
## Proposal
|
||
|
||
1. **Who signs up, per instance:** `SIGN_UP`: `open` (as today), `approval`, or
|
||
`closed`. On halkyon.li `approval`.
|
||
2. **The operator** is a role on the register. The first one by SQL (D20), the others
|
||
named in the app (D21); an operator approves, refuses and suspends accounts (D22),
|
||
grants assistants with their limits, and sees everyone's figures, never content.
|
||
3. **Waiting accounts** (D1): after signing up (SimpleLogin, handle, keys: ADR 0020), an
|
||
account waits. It gets no session, only a pass for its wait screen, which sees that it
|
||
waits or deletes the account (D31); nothing syncs and nothing uploads (D23). The operator sees who waits in the app (the
|
||
handle, when they signed up, which is all the instance knows, and the matching code of
|
||
D24) and approves or refuses; a refusal deletes the account. Unapproved after 14 days, it is deleted. At most 10
|
||
waiting at once, and sign-ups per network limited as today's, so strangers can't flood
|
||
the list. The operator's app shows a badge; there is no mail, as the instance keeps no
|
||
addresses.
|
||
4. **The assistants**, set by the operator (D4): one or more, each with an ID, the name
|
||
people see ("Claude Sonnet"), its protocol (`anthropic`, `openai` for every
|
||
OpenAI-compatible service, or `openai-responses`, D46), its base URL (the provider's own unless set), model and key
|
||
in `deploy/assistants.env` (D37: `ASSISTANTS=claude,gpt`, then `ASSISTANT_CLAUDE_NAME`,
|
||
`_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
|
||
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
|
||
of these and a restart; no app changes.
|
||
5. **Adapters of our own, one per protocol (D46),** behind one interface, over plain HTTP, no SDKs, in a pure
|
||
Dart package of their own, which the server uses for the operator's assistants and the
|
||
app for personal keys (10). Features use only what both protocols share: a system prompt and messages,
|
||
streamed text, and tool calls where a feature needs them. Tested against documented
|
||
and recorded replies of each (D36). Not chosen: LiteLLM as a sidecar (a Python proxy for about 100
|
||
providers: one more container to run and update, for providers never used) and
|
||
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
|
||
assistant at a time, each grant with its limits. The person agrees once to what goes
|
||
where: the provider's name, that the instance sees the text on its way, and that the
|
||
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
|
||
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
|
||
of the limit, forwards the request, and counts what the provider's reply reports
|
||
(input and output tokens). Past the limit it refuses (`limitReached`), saying when it
|
||
resets. A private studyset (ADR 0019) goes only as far as the person sends it,
|
||
decrypted on the device for that request. The scope (D7) holds the conversation: the
|
||
app runs the tools' loop, answering each lookup from the person's local data within
|
||
it, so with the operator's assistant the lookups' answers pass through the instance as
|
||
the question does. A draft (D10) arrives as a tool call the app opens in the editor.
|
||
Whether an assistant reads images (D12) is its setting (`ASSISTANT_CLAUDE_IMAGES`; for
|
||
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
|
||
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,
|
||
D38; without prices, tokens only, and limits in tokens), by UTC's days (D42), for
|
||
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.
|
||
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
|
||
keeps no content, and that conversations are kept sealed on the person's devices
|
||
(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`,
|
||
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
|
||
several. The key goes into their settings, which are sealed with their personal key
|
||
(ADR 0019), so it reaches their other devices and the instance can't read it. The app
|
||
calls the provider directly with the same adapters (5); the instance sees neither key
|
||
nor text and counts nothing, and the person's limits are their provider's. The
|
||
instance can't stop a modified app from doing so, and needn't: nothing passes through
|
||
it. Personal assistants are offered in the field beside those granted.
|
||
11. **Never sent, in any scope:** the password, the account's keys, the recovery phrase,
|
||
codes, API keys, the Jellyfin token, other people's data (but posts whose author
|
||
agreed, D28), and progress (D11). Nothing is asked without the person: no assistant
|
||
runs in the background.
|
||
|
||
## Consequences
|
||
|
||
- The operator pays the provider, and sees who used how much, never what for.
|
||
- The instance sees what is asked on its way to the provider, and the lookups' answers;
|
||
the provider's own terms then apply to it. A private studyset leaves end-to-end
|
||
encryption wherever it goes to a remote model (D9).
|
||
- A provider that is down stops the assistants on it; the others granted, personal keys
|
||
and the web search go on.
|
||
- Strangers can sign up and wait, but can't use anything, and their accounts lapse.
|
||
- Public instances keep the rule of 10 Oct: their operators leave the assistants unset,
|
||
and may allow personal keys.
|