docs(adr): no hand-off and no connector (ADR 0022); several providers and personal keys in ADR 0021

ADR 0022, decided 23 Oct: the hand-off to assistants outside the app and
the Claude connector (ADR 0006) go in v0.19.0, app and server together;
the field keeps the web search. ADR 0021 takes the developer's D4-D6: the
operator's keys for several providers, granted per person; personal keys
on the person's devices, only where the operator allows them.

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-23 15:08:40 +02:00
commit 1454fd7bfc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 120 additions and 21 deletions

View file

@ -1,9 +1,9 @@
# ADR 0021: A private instance: approved sign-ups and an assistant the operator grants
- **Status:** proposed 23 Oct 2026. Decided by the developer on 23 Oct: D1–D3 below; the
- **Status:** proposed 23 Oct 2026. Decided by the developer on 23 Oct: D1–D6 below; the
rest under *Proposal* is for review before building.
- **Builds on:** ADR 0006 (the assistant connector), ADR 0016 (the register), ADR 0019
(private studysets), ADR 0020 (SimpleLogin).
- **Builds on:** ADR 0016 (the register), ADR 0019 (private studysets), ADR 0020
(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
key, for an instance whose operator opts in.
@ -16,8 +16,14 @@ 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. A family instance whose operator approves every account takes away the
first reason; the second holds if the app shows what it sends.
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
@ -36,6 +42,14 @@ Lumo API for autumn or winter 2026 without publishing its format; community proj
- **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.
## Proposal
@ -53,29 +67,35 @@ Lumo API for autumn or winter 2026 without publishing its format; community proj
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 provider**, set by the operator: `ASSISTANT_PROVIDER` (`anthropic`, or `openai`
for every OpenAI-compatible service), `ASSISTANT_BASE_URL` (each provider's own
unless set), `ASSISTANT_MODEL`, and the key `assistantApiKey` in the passwords.
Unset: no assistant on the instance, as today. Switching from Claude to OpenAI or to
Lumo is a change of these and a restart; no app changes.
5. **Two adapters of our own** behind one interface in the server, over plain HTTP, no
SDKs. Features use only what both protocols share: a system prompt and messages,
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
OpenAI-compatible service), its base URL (the provider's own unless set) and model in
`deploy/.env` (`ASSISTANTS=claude,gpt`, then `ASSISTANT_CLAUDE_NAME`, `_PROTOCOL`,
`_BASE_URL`, `_MODEL`), and its key in the passwords (`ASSISTANT_CLAUDE_KEY`). 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. **Two adapters of our own** 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 recorded
replies of each. 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): nobody has the assistant until the operator switches it on for them,
with a limit. The person agrees once to what goes where: the provider's name, that the
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.
7. **Asking:** the app shows what it will send, the person sends it to the instance
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.
8. **Limits and statistics** (D3): per person, per day and per month (a day's limit stops a
runaway loop early). Counted per person, day and model: requests, input and output
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;
without prices, tokens only, and limits in tokens). Neither prompts nor answers are
kept. The operator sees everyone's figures in the app, per person and month; each
@ -83,14 +103,23 @@ Lumo API for autumn or winter 2026 without publishing its format; community proj
9. **The privacy page** names the provider, says what is sent and that the instance sees
it in passing, that the operator sees each person's figures, and that no content is
kept.
10. **The connector and the hand-off stay** (ADR 0006): anyone may still use their own
Claude or Lumo.
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.
## 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; the provider's own terms
then apply to it.
- A provider that is down stops the in-app assistant, not the hand-off or the connector.
- 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 assistant unset.
- Public instances keep the rule of 10 Oct: their operators leave the assistants unset,
and may allow personal keys.

View file

@ -0,0 +1,70 @@
# ADR 0022: No hand-off and no connector
- **Status:** decided 23 Oct 2026, by the developer (D1–D3 below); built for v0.19.0
(build 22).
- **Supersedes:** ADR 0006 (Claude reads studysets through a connector on the instance),
and the hand-off to an AI assistant outside the app (decided 10 and 14 Oct 2026, Learn's
`AGENTS.md`).
- **Followed by:** ADR 0021 (an assistant on the instance, on its operator's keys), still
proposed.
## Context
Since 10 Oct a question asked from the app could leave it in two ways, both through the
person's own subscription, as Halkyon called no language model with an API key: the
hand-off opened the chosen assistant's new conversation (Lumo by default, ChatGPT,
Claude, …) with the question and the chosen parts written in, for the person to send; and
Claude's apps read the person's studysets through the instance's connector (ADR 0006: MCP
at `/mcp` with its own OAuth, connected with a code from Settings › Assistant › Connect
Claude). Beside them, the same field searched the web.
ADR 0021 proposes an assistant inside the app on an instance whose operator pays for it:
several providers on the operator's keys, granted per person, with limits and statistics,
and personal keys where the operator allows them. The developer asked on 23 Oct to "drop
the Assistant part that just opens the assistant outside of the app, but let's keep the
web open part", or else decide how the instance's own assistant and opening one's own
outside the app go together.
## Decisions (23 Oct 2026, the developer's)
- **D1 The hand-off goes now** (over keeping it until the instance's assistant comes,
which was recommended): until then the field searches the web only.
- **D2 The connector goes too** (over keeping it, which was recommended): an instance has
one way to an assistant, its operator's (ADR 0021), and nothing else on it reads
people's content for a language model.
- **D3 In v0.19.0, the app and the server together** (over releasing v0.19.0 first and
taking the server's part out with the next raise of `minimumAppBuild`, which was
recommended): build 22 raises `minimumAppBuild` to 22 anyway, so no app that calls the
connector's endpoints can use the instance after it, and nothing needs to stay for
older apps.
## What goes, and what stays
1. **In the app:** the assistants a question opened in (`AiAssistant`, `AssistantHandOff`),
the switch at the field's start between them and the web (`assistantTarget`), Settings'
choice of assistant, Connect Claude with its route, cubit, repository and gateway, and
their texts. A device's settings file from before keeps the old keys (`aiAssistant`,
`customAssistantUrl`, `assistantTarget`) until the next change of a setting writes it
anew without them; nothing reads them.
2. **On the server:** `lib/src/assistant/` (the MCP endpoint, its OAuth pages and metadata,
the five tools, connect codes, tokens), `AssistantEndpoint`, the daily forgetting of
the connector's codes and tokens, their lines in Learn's deletion hook,
`instance.info().assistantConnectorUrl` (nullable, so an older app reads it as none),
and Caddy's forwarding of `/mcp`, `/oauth/*` and `/.well-known/oauth-*`. A migration
drops the six tables (`assistant_client`, `assistant_code`, `assistant_connect_code`,
`assistant_connection`, `assistant_grant`, `assistant_token`) with every connection,
code and token in them: from the deploy on, a connected Claude gets no answer, and
people remove the connector in Claude's settings.
3. **Stays:** the field (`AssistantBar` on Home, `AssistantButton` in the Library, on a
studyset and while studying), off unless the person turns it on in Settings, its chips
of what the question is about (those a web search takes: a card's front, back, hints
and explanation, never a studyset's name or description), and the web search through
the engine chosen in Settings, or Android's own search.
## Consequences
- Until an instance offers ADR 0021's assistant, its people ask no language model from the
app. learn.halkyon.eu, a public instance, offers none (ADR 0021: public instances leave
the assistant unset).
- The instance reads payloads in one place fewer (ADR 0019's context named four).
- The privacy page (website repository) drops the connector and the hand-off to Lumo.