Noggin — Architecture Specification v0.1

Purpose and scope

This specification turns the Noggin design document into something Claude Code can build from: the components, their interfaces, their data, and the order to build them in. It incorporates the all my noggins brand system v0.1 (tokens.css, tokens.json, logo/, the README) as the single source of visual truth for the website, the console and the app at talkto.allmynoggins.com.

How to use it with Claude Code

Out of scope for v0.1: Produce and Draft tools for voices (Read only in v0.1), voices talking to each other, acting on learned router weights, native iOS and Android apps, the voice marketplace, hosted brains until a compute provider is enabled in Ops.

System overview

ComponentWhat it isWho runs itStack
Noggin (brain)Single-owner agent engine: router and cost optimiser, model and speech adapters, personalities (voices), memory, feedback, audit, schedulerThe owner, on their hardware or a hosted containerPython 3.12, FastAPI, SQLite with sqlite-vec, one multi-arch container image
Console<slug>.allmynoggins.com: open the app, manage noggins, create a nogginThe platformNext.js on Cloudflare Pages, Postgres (Neon), Auth.js
Ops consoleops.allmynoggins.com: providers, tiers, accounts, fleet, templates, settings, auditThe platform (staff only)Next.js, shares the console API and database
Noggin designerdesignmynoggin.allmynoggins.com: guided creation of personalitiesThe platform (stateless)React, shares packages/ui
talkto.allmynoggins.comThe mouthpiece: chat and talk PWA, paired directly to a brain; signs in with the console account to list and open nogginsPlatform serves static files; runs in the owner’s browserReact, Vite, PWA, design tokens
Marketing siteallmynoggins.com landing, docs, build logThe platformAstro on Cloudflare Pages, Markdown content

Three layers (decided 2026-10-24)

The service is described in three layers; every boundary between them is a privacy boundary.

The three-layer architecture: the platform on Cloudflare, managed compute on noggin hosts, and self-run noggins; the owner's phone reaches any noggin through that noggin's own tunnel

Figure: docs/architecture.svg (light theme values from design/tokens.css; rebuilt by hand when the layers change). The owner’s phone signs in at the console, is handed a single-use pairing token by the registry, and from then on talks to its noggin through that noggin’s own tunnel in every layer.

LayerWhat it isRuns whereHolds
1 · PlatformThe site, talkto, the designer, the console (the owner’s management area: manage noggins, design my noggins, later billing and subscription), ops (users, noggin hosts, templates, service monitoring, later the billing gateway), and the registryEntirely on Cloudflare: Pages for the static surfaces, Workers with D1 for the console, ops and the registry, Durable Objects for live state, Cron Triggers for probesAccounts, pointers to noggins and noggin hosts, tunnels, templates, settings, counts. Never a message, memory, voice file or key
2 · Managed computeThe noggin hosts we run: gpu-1 first, more laterOur own servers; each runs a node agent, the noggin containers it is asked to run (hosted, founder’s, demo), native Ollama and the shared speech servicesHosted owners’ noggin folders, for which the platform holds only a pointer. Shared engines process their prompts, which the hosted-tier copy says plainly
3 · Self-run nogginsNoggins installed by their owners on their own laptop, Pi or VPS from an install packageThe owner’s machineEverything; we manage nothing and hold a pointer only if the owner’s noggin sends a heartbeat

The registry (platform layer) is the only thing that connects the layers: it knows where each noggin is and whether it is online, hands the phone a single-use pairing token, makes each noggin’s tunnel, renders install packages, and tells node agents what to start and stop. It never carries a conversation: the phone talks to a noggin through that noggin’s own tunnel in every layer. An opt-in relay for noggins that cannot run a tunnel may be added later and will be labelled as such.

Node agent (managed compute): a small service on each noggin host that receives start, stop, move and destroy for a noggin from the registry, pulls the template image, reports capacity (RAM, VRAM, containers, disk) every 60 seconds, and nothing else. It has no access to a noggin folder’s contents beyond mounting it, and no route to another host. Placement is the registry’s: the host with room. Switch-over to a bigger host is export, import and start on the new host; the tunnel token lives in the folder, so the address never changes. A second host is the same shape with its own engines.

Billing (platform layer, later): a subscription pointer per account; the console’s billing & subscription and ops’s billing gateway are designed and not built. Provider: [decide] (Stripe if UK-first, a merchant of record if selling abroad from day one).

Milestones: 12 (shared speech services), 13 (console, ops and the registry to Workers and D1), 14 (node agent, the noggin-host adapter, hosted noggins behind the hosting flag).

Read it top down: the console opens the app and hands it a pairing token (or, signed in at talkto since milestone 10b, the app asks the console API for one itself); from then on the app talks to a brain directly. The console’s only link to a brain is management (create, pause, delete for hosted ones; an optional heartbeat from self-run ones). Only brains reach cloud providers, and only where a voice’s privacy level allows.

Repository layout (monorepo)

allmynoggins/
  brain/            Noggin engine (Python)
  app/              talkto.allmynoggins.com PWA (React); app/designer for the designer
  console/          personal console and console API (Next.js)
  ops/              staff console (Next.js)
  site/             marketing site (Astro)
  packages/ui/      shared React components built on the design tokens
  design/           brand system handoff v0.1 (tokens, logos, README) — read-only
  templates/        starter voices, brain profiles, install templates
  docs/             this spec, the design doc, decisions, build log
  prompts/          one prompt per milestone
  infra/            account and server setup notes

packages/ui is the only place components are defined; console, ops, app and site import from it so the surfaces cannot drift.

Component 1: Noggin (brain)

Responsibilities: serve the brain API; route each request to a model and speech backend the voice permits; keep personalities, memory and the prompt library; log every outbound call; run scheduled jobs; export and import the whole folder or one personality.

Modules (brain/noggin/)

ModuleDoesNotes
api/FastAPI routes, bearer-token auth, SSE streamingVersioned under /v1/
router/Picks model and speech backends from host capability, voice privacy level, request class, memory context and feedback scores; records estimated and actual costRules in rules.yaml; learned weights recorded, not acted on, in v0.1
adapters/llm/generate(messages, params) -> AsyncIterator[str]ollama, anthropic, openai_compat (base URL configurable: Groq, Together, OpenAI)
adapters/speech/transcribe(audio) -> AsyncIterator[str], speak(text) -> AsyncIterator[bytes]whisper_local, kokoro_local, whisper_http and kokoro_http (shared services on a GPU server, milestone 12), elevenlabs, deepgram, openai_tts
adapters/media/image, video, audio (v1.5)Produce tier; files under voices/<slug>/media/
voices/Loads voices/*.md, validates front-matter, writes new voices; copies templates/ on first startSchema in the Data section
memory/Session history, scoped memory, prompt library, embeddingsSQLite plus sqlite-vec in memory.db
redact/Masks names, emails, phone numbers, addresses and account numbers before a cloud callRuns for asks voices
audit/Writes every ModelRun, SpeechRun, ToolRun before the call is maderuns.db is append-only
scheduler/Runs schedule entries from voice front-matter in-process through the same router and auditMissed jobs fire at next start; asks cloud jobs wait for consent
tools/read/search, fetch, calendar.read, mail.readRead tier; behind the privacy gate
transport/export and import of the encrypted archive; personality archivesPassphrase-derived key
registry/Registers this brain’s address with the console and keeps a 60-second heartbeat (id, version, host capability, and this month’s spend, replies and main model per voice, not sent under e2e: required)Optional; brain runs without it

Router and cost optimiser

This is the module the design document calls the cost optimiser; it sits in front of every model and speech adapter and makes one decision per request.

(Diagram in the Claude Doc: inputs — request, voice policy, host capability, memory and feedback; decision order; outputs — local engines, runs.db, redaction and consent, cloud connectors.)

Decision order: 1. privacy gate, hard: local never goes out; 2. drop backends the host cannot run; 3. quality floor for the request class; 4. cheapest remaining, by estimated tokens; 5. same steps for speech backends.

Request classes in rules.yaml (v0.1): short_factual, conversation, long_reasoning, code, speech_stt, speech_tts. Each class names a minimum model tier (small local, large local, cloud) and a latency budget. The router classifies by prompt length, the presence of attachments or code, and the voice’s own hint. The estimate uses the provider’s published price per token and the tokenised prompt length; a ModelRun is committed with it before the call, and the actual figures replace it on completion. The monthly cost view and the per-reply cost line in the app both read runs.db.

Learning and the session adviser. Feedback and memory are router inputs. Every reply’s ModelRun is joined to its Feedback to give a per-voice, per-class score for quality (ratings), cost (actual spend) and performance (latency). The session adviser runs when a voice is opened: GET /v1/voices/{id}/advice returns three options, Performance, Quality and Cost, each with the model it would pick, expected cost per reply and typical latency from this owner’s history; the app shows them as a card and the choice becomes a session bias (POST /v1/sessions takes bias: performance | quality | cost). The privacy gate is applied before the bias, always. Built in v0.1: the scores and the adviser. The rest of this section is Continuous learning (decided 2026-10-24, milestone 12c).

Continuous learning (decided 2026-10-24; milestone 12c, not built)

Three loops, the first two inside the noggin and on by default, the third across noggins and on by default with an opt-out. None of them can widen the privacy gate, and none of them sends what the owner said anywhere.

1. Memory from every conversation (in the noggin, on by default). Every conversation is kept in memory.db, as now, with its feedback. When a session has been idle for 10 minutes or is closed, the noggin’s own local model (redaction.model, else default_local_model; never a cloud model, for every privacy level) reads it and writes up to five lasting facts into the voice’s memory scope (kind: learned, with the session id it came from). A fact whose embedding is within 0.9 cosine of an existing one updates that one instead of adding a duplicate. The run is audited as a ModelRun of kind remember before the call (invariant 4). The app lists what each voice remembers, with where it came from, and every item can be edited or deleted; deleting a session deletes the facts learned only from it. Parts of a conversation under Off the record are never stored, so never learned from. A voice file may set memory: manual to keep only what the owner saves by hand. Without a local model installed the step is skipped and the app says why.

2. The router learns from this owner (in the noggin, stage 2). Per voice and request class, once 20 ratings exist the learned score replaces the static quality floor from rules.yaml (a voice still never drops below its privacy gate or the host’s capability); the owner’s bias still applies. The app shows the learning maturity of each voice’s cost model: Learning with the count (12 of 20 ratings) below 20, Learning from your ratings from 20, and Fully mature at 50 ratings for a class. Before 20, the shared model (loop 3) is used as the starting point when it is on, then the owner’s own scores take over as they mature (weight n / 50, capped at 1). Learned weights live in memory.db, are listed in the app and can be reset per voice.

3. The shared cost-quality-performance model (across noggins, on by default, opt-out). Each noggin contributes engineered features only, never content: per reply, the model used (logical name and engine model), request class, host class (CPU or GPU, memory band), bands for tokens in and out, latency band, actual cost, the rating (up, down or none) and the session bias. Never: message text, memories, prompts, voice names or files, embeddings, names found by redaction, the owner’s account, the noggin’s id or name, or a time finer than the day. The noggin sums its features into daily buckets and sends one batch a day to the registry (POST /v1/learning/contributions, the registry endpoint the egress rule already allows), with a random id per batch and no noggin id; the registry adds the batch to totals per (model, class, host class) and discards the batch. A total is published only once at least 10 batches have contributed to it [decide: the minimum]. What comes back is the published table, fetched with the heartbeat: expected quality, latency and cost per model, class and host class. local voices contribute features too (owner’s decision); their label says so. The setting is in the app and the console (Help improve routing for everyone), on by default, and turning it off stops contributions at once and makes the noggin ignore the shared table. Before this is switched on in production: a legitimate-interests assessment and the privacy notice updated to name the features (owner, with advice), since usage data can be personal data under UK GDPR even without content.

What the owner is told (app, console and site, in these words or close): “Your voices learn from your conversations and your ratings, on your noggin, so conversations get richer and the right model answers for less. To help every noggin choose well, yours also shares anonymous numbers about each reply: which model, what kind of request, how long it took, what it cost and whether you rated it. Never what you said, what it remembers, or who you are. You can turn sharing off at any time.”

Optional intelligent classifier (v2): a small local model tags the request class before the rules run; off by default, a few hundred milliseconds per request, and never able to widen the privacy gate.

What it does not do in v0.1: act on learned weights (milestone 12c), split one prompt across several models, or cache responses.

Personalities. The unit the owner talks to is a personality: one voice file, its memory scope, its prompt library and its feedback record with learned weights, kept together under voices/<slug>/. The brain’s router, engines, audit log and scheduler are shared across all personalities in the noggin. POST /v1/voices/{id}/export produces a personality archive (the voice folder plus the Memory, Prompt, Feedback and learned-weight rows for its scope) that POST /v1/voices/import on any noggin can accept; this is distinct from whole-noggin export.

Host capability detection: on startup the brain reads RAM, GPU, installed engines and available models, publishes them at /v1/health, and the router narrows its choices to what is present. A voice that cannot run on this host is listed with status unavailable and a plain reason.

Exposure per noggin (decided 2026-10-17): each noggin runs a Cloudflare Tunnel sidecar (cloudflared) beside the brain container in the same compose project, and is published at https://<name>.allmynoggins.net. The brain shares the sidecar’s network and opens no port on the host; nothing is installed on the host but Docker, and phones need only a browser. Each noggin has its own tunnel; its token lives in the noggin folder (cloudflared/token), so the address moves with the folder, the token is covered by the encrypted export, and only one copy may run at a time. Other exposure values: lan (local network only) and public (the brain’s own certificate). Tailscale is not used anywhere in the product.

Trust in transit (decided 2026-10-17): Cloudflare terminates HTTPS at its edge, so it can technically read traffic between the app and a noggin, including message text and the bearer token. The owner accepts this by default. Milestone 05b adds an owner option for end-to-end encryption, WhatsApp-style: keys are agreed at pairing from the one-time code (never sent), message content travels encrypted between app and brain, and Cloudflare carries only ciphertext and metadata (which endpoint, when, how much). Built in 05b: e2e: off | on | required in config.yaml (off by default), SPAKE2 (RFC 9382) pairing, every request of an end-to-end device sealed inside POST /v1/e2e/call (so even the route and ids are hidden), talk over WS /v1/e2e/talk, and a safety number on both sides.

Because the brain is on the public internet, it treats every request as hostile: every endpoint except POST /v1/pair needs the bearer token; pairing codes last ten minutes, work once, and five wrong codes in ten minutes cancel all open codes; the API does not publish /docs or /openapi.json; CORS allows only the app’s origin.

Packaging: one Dockerfile producing a multi-arch image (linux/amd64, linux/arm64) published as ghcr.io/allmynoggins/noggin; one noggin binary built with PyInstaller for people without Docker. All state under the mounted noggin/ folder (/data in the container).

Component 2: Console and accounts

Responsibilities: sign-up and sign-in; claim a subdomain; keep pointers to the person’s noggins; issue pairing tokens; generate install packages; create, pause, export, move and delete hosted noggins; billing. It never proxies chat traffic and never reads a brain’s folder.

Routes (<slug>.allmynoggins.com)

RouteScreenBehaviour
/My nogginsList with name, host, status (online, offline since <time>, paused, unavailable here), hosting tier, month-to-date cost, last export. Primary action per row: Open.
/open/[id]MouthpieceFetches a short-lived pairing token, opens talkto.allmynoggins.com with the noggin and token in the URL fragment. After load, the console is out of the path.
/newCreate a nogginStarter templates, brain profile, host preset. Run it myself generates an install package for the chosen target; Host it for me is hidden until a compute provider is enabled in Ops.
/noggins/[id]ManageRename, pause, export (brain streams the archive to the browser), move (export, create elsewhere, import), delete (export offered first). Ownership statement beside the deletion controls.
/accountAccountSign-in methods, email, billing, delete account.

Starter templates at creation. /new offers starter voices (career adviser, travel agent, reading coach, house and garden, study tutor), a brain profile (Private first, Balanced, Best quality) that pre-fills rules.yaml and default models, and a host preset. Templates are files under templates/ in the repo and are copied into the new noggin/ folder by the brain on first start.

Install package generator (console/api/installer/). POST /noggins/{id}/install-package with {target: laptop-macos | laptop-windows | laptop-linux | vps-linux | cloud-vm | gpu-server, exposure: tunnel | lan, ram_gb, name, port_offset} returns a zip rendered from templates/install/. Contents: install.sh or install.ps1 (idempotent: installs Docker and Ollama if absent, writes config, starts the service, prints pairing status), docker-compose.yml, noggin.env (single-use 5-minute pairing token, noggin id, console URL), cloudflared/token (this noggin’s tunnel token), config.yaml (brain profile, default models by ram_gb, exposure), README.md (platform steps, uninstall, ownership statement). Target extras: laptop adds the Docker Desktop link and native Ollama; vps-linux adds ufw deny-inbound, name and port offset; every target runs the brain with its cloudflared sidecar and a tunnel minted per noggin; cloud-vm emits a cloud-init.yaml user-data file that runs the same install.sh on first boot. No provider keys are ever in a package; the only secrets are the pairing token and the noggin’s tunnel token. A test renders every target and runs install.sh in a container with --dry-run.

Availability. Brains send a heartbeat (POST /noggins/register, every 60 s, opt-out in config.yaml) carrying id, version and host capability, and (milestone 10) this month’s figures per voice (name, privacy level, spend, replies, the model that answered most), which the console holds in its memory only, never in Postgres, shows only to the signed-in owner and forgets when the noggin goes offline; a brain with e2e: required does not send them. The console derives online, offline since <time> and unavailable here (per voice) and exposes them at GET /noggins; the app shows the same state in its header and mic button.

Authentication. Passkeys (WebAuthn) plus OpenID Connect sign-in with Google and Apple at launch, implemented with Auth.js on the console; further providers are configuration only. Scope is openid email; the console stores AuthMethod rows (account_id, kind: passkey | oidc, provider, subject, created_at) and nothing else from the provider. Sessions are HTTP-only cookies on *.allmynoggins.com (SameSite=Lax), each tied to a DeviceSession row (account_id, a browser-and-device label, created_at, last_seen_at) so Account → Signed-in devices can sign one browser out; a cookie works only while its row exists. talkto.allmynoggins.com uses the same cookie (milestone 10b): it is same-site with api.allmynoggins.com, which answers credentialed CORS for that one origin on three routes (list noggins, pairing token, sign out); the sign-in ceremony itself happens on my.allmynoggins.com and returns to the app. SSO identities never appear in pairing tokens or reach a brain. Account → Sign-in methods lets the owner add or remove any method, keeping at least one. “Continue with Google” and “Continue with Apple” use the brand system’s secondary button with the provider mark at 20 px.

Subdomain claim: [a-z0-9][a-z0-9-]{2,30}, reserved list for www, api, brains, docs, my, ops, talkto, designmynoggin. One per account in v0.1 [decide: vanity alias per noggin later].

Hosted noggin lifecycle: creating → pairing → online ↔ paused → deleting. Each hosted noggin is one machine with one volume at the enabled provider; the console talks to the provider through a small worker so credentials never reach the browser.

Pairing token: JWT signed by the console, 5-minute life, single-use, carrying noggin_id and the brain’s address. The brain exchanges it for its own long-lived bearer token on first contact; thereafter the brain’s token is all the app uses. Signed in at talkto (milestone 10b), the app fetches one whenever it opens a noggin it has no token for, and once more if the brain refuses its token (silent renewal). A noggin with end-to-end on is never paired with a console token, which crosses Cloudflare: the app asks that device for a one-time E2- code from the noggin instead. Signing a device out in the console stops its pairing tokens at once; a device token it already holds is revoked only at the brain, because the console has no path to a brain.

Component 2b: Ops console (site administration)

ops.allmynoggins.com, a separate Next.js app sharing the console API and database but with its own OpsUser table. Sign-in rule: an OpsUser can only be created for an email address whose domain is allmynoggins.com or allmynoggins.net (allow-list enforced at creation and at every sign-in), with a passkey registered to that address; no OIDC or consumer SSO is accepted for ops. It administers providers, tiers, accounts and the hosted fleet; it has no code path to any brain’s API or folder.

Provider adapters (console/api/providers/): one interface, configuration-driven.

interface HostingProvider {
  id: string;                       // "fly", "hetzner", "ionos"
  create(spec: TierSpec): Promise<Instance>;
  pause(id: string): Promise<void>;
  resume(id: string): Promise<void>;
  destroy(id: string): Promise<void>;
  status(id: string): Promise<InstanceStatus>;
  cost(id: string, period: Period): Promise<Money>;
}

A provider is enabled in Ops by entering credentials and regions; until one is enabled, personal consoles hide Host it for me. The first compute provider is deferred; Cloudflare is fixed as edge and DNS and recorded as a ServiceSetting.

Routes: /providers, /tiers, /accounts (search, suspend, delete with export), /fleet (every hosted brain: provider, region, status, heartbeat lag, cost; pause, restart, migrate), /templates (versioned starter voices, profiles, presets), /settings (edge, email provider, feature flags, legal text versions, maintenance notice), /audit, /health.

Tables added: OpsUser, Provider (id, enabled, credentials_ref, regions), Tier (name, provider_id, size, local_models, price_gbp, visible), ServiceSetting (key, value, version), Template (kind, slug, version, body), OpsAudit (ops_user_id, action, target, at).

Rules: destructive actions on a user’s noggin require two ops users (request and approve) and always offer export first; all ops actions are audited; credentials live in the worker’s secret store, never in Postgres.

Component 2c: Noggin designer

designmynoggin.allmynoggins.com: a stateless React app (shares packages/ui) that runs a structured interview and produces a personality archive. Two back ends, selected by whether a brain is paired:

Later: adapters/llm/designer_local wraps a fine-tuned open-weights model trained on published templates and consented interviews, and becomes the local backend for _designer. Archives carry made_with (brain, platform-designer, designer-local) for provenance. The same flow is embedded in the app under New voice → Design it with me.

Component 3: talkto.allmynoggins.com (PWA)

Decision: a Progressive Web App first; a native wrapper (Expo) only if hands-free talk or store presence proves necessary. The PWA is served at talkto.allmynoggins.com (one domain, no separate app domains); a person’s console opens it with the chosen noggin and pairing token in the URL fragment, or (milestone 10b) the owner signs in there with the console account and picks a noggin from the list; pairing codes and Scan pairing code remain the fallback. It installs to the home screen with the Talk mark as its icon.

Screens (match design/README.md section 07 for the chat and talk layouts)

ScreenContentState
Voice pickerVoice cards (name, purpose, privacy pill, host) from /v1/voices; New voice form; Design it with me—
ChatMessage list with chat bubble pairs, Today separator, composer with text field and 48px accent mic button, rating on each reply, model and cost shown per reply; session adviser card on open`voiceState: idle
TalkPrevious reply, live caption, level line, Speaker / Mic / End row, truthful processing caption`micState: idle
Settings sheetTheme (light, dark, system), noggin address, forget pairing, deletion controls with the ownership statementtheme kept on the brain

Speech on the device: use the Web Speech API for speech-to-text and speechSynthesis for text-to-speech when the voice’s backend is device. On iOS Safari, recognition is processed by Apple, so the router treats device STT as not local. Decided: brain-side Whisper is offered as an option; local voices default to it wherever the host can run Whisper, and the app streams microphone audio to /v1/sessions/{id}/audio in that case. The processing caption states which path is in use.

Session adviser card: shown on opening a voice; three options from /v1/voices/{id}/advice with expected cost and latency; the pick sets the session bias and is remembered per voice. Availability: header caption and mic button reflect the brain state from the console or from a failed /v1/health probe, whichever is fresher.

Footprint and offline: the device holds only, for each noggin it is paired with, the pairing token (or end-to-end pairing secret) and the brain address (localStorage, cleared by Forget pairing for one noggin and by Sign out for all), the cached app shell and, signed in, the console’s HTTP-only session cookie, which no script reads (invariant 6 as amended 2026-10-18). No message history, memory, audio, drafts or per-voice preferences are stored on the device. Offline, the app opens and shows Noggin offline; it never answers on its own.

Streaming: POST /v1/sessions/{id}/messages returns SSE; audio for Talk arrives on a WebSocket at /v1/sessions/{id}/audio as Opus chunks, sentence by sentence.

Component 4: Marketing site

Astro on Cloudflare Pages, content in Markdown under site/content/. Pages: landing (hero as specified in design/README.md section 07), Voices (voice cards generated from the founder’s real voice files, redacted), Build log (rendered from docs/BUILDLOG.md), Docs (rendered from docs/), privacy page. Interest list posts to Buttondown; no analytics beyond Cloudflare’s server-side counts, stated on the page. The Owner sign-in button links to the console.

Design system integration

The brand system v0.1 is final for colour, type, spacing, radius and copy. Claude Code recreates it; it does not redesign it.

Tokens

Components in packages/ui (specs in design/README.md sections 05 and 08): Button (primary, secondary), TextInput with label and helper, Card, PrivacyPill, TopNav, VoiceCard, ChatBubble, MicButton (four states), OutputSegmented, LevelLine, ThinkingDots, Wordmark and Mark (stack, noggin, talk; colour, mono, reversed). Icons from Lucide at 24px, stroke 2. Every component has light and dark stories.

Brand architecture in code: the stack mark appears only in the marketing site nav; the noggin mark in docs, README, CLI and favicon; the talk mark as the PWA icon and in the app header. Never all three as equals. The app wordmark is talkto.allmynoggins; the name Talk2UrNoggin is retired.

Privacy level mapping. The design system names the levels in plain English; the brain uses machine keys.

Design labelBrain keyBehaviour
Local onlylocalNothing leaves the host; cloud adapters refused
Asks before sharingasksRedaction pass runs, then the owner confirms once per session before a cloud call
Uses the webwebCloud model and speech adapters allowed, full prompt

Decided: asks is once per session; the first cloud call in a session prompts the owner, later calls in that session do not; a Consent row is keyed by session.

Truthful copy rule: the talk-screen caption (“Audio is processed on pi-kitchen…”) is generated from the actual backends chosen for that turn, never hard-coded.

Motion: only the three keyframes in tokens.css; all stop under prefers-reduced-motion.

API contracts

Brain API (https://<brain-address>/v1, bearer token, JSON, SSE for streams)

Method and pathPurpose
POST /pairExchange a console pairing token or a local one-time code for a brain bearer token
GET /pair, POST /pair/e2e/start, POST /pair/e2e/finishEnd-to-end pairing (05b): the noggin’s e2e setting; SPAKE2 over the one-time code, which is never sent
POST /e2e/channel, POST /e2e/call, WS /e2e/talk, GET/PUT /e2eEnd-to-end mode: open a sealed channel; one sealed inner request (any route above) with its reply as sealed frames; sealed talk mode; the owner’s switch
GET /healthHost capability: RAM, GPU, engines, models, speech backends, version
GET /voices, POST /voices, PATCH /voices/{id}, DELETE /voices/{id}Voice registry; POST takes the short-form fields and writes the Markdown file; delete removes the memory scope too
GET /voices/{id}/adviceSession adviser options
POST /voices/{id}/export, POST /voices/importPersonality archives
GET /voices/{id}/prompts, POST /voices/{id}/promptsPrompt library
POST /sessions (bias), GET /sessions?voice=, DELETE /sessions/{id}Conversations
GET /sessions/{id}/messagesHistory, paged
POST /sessions/{id}/messagesSend text; SSE events token, run (model, provider, local, tokens, cost, redacted), done
DELETE /messages/{id}Remove one message
WS /sessions/{id}/audioTalk mode: client sends audio frames or text; server sends caption, token, audio (Opus), run
POST /messages/{id}/feedbackrating, note, promote_to_memory
GET /memory?scope=, DELETE /memory/{id}Inspect and remove memory
GET /costs?period=Spend by voice, by provider, by kind (model, speech, tool)
POST /consentRecords an asks confirmation for a session
POST /export, POST /importWhole-noggin archive
POST /models/pull, GET /models/pullsInstall model: the host’s Ollama pulls a catalogue model on the owner’s tap; progress
DELETE /runs, POST /wipeClear the audit log; wipe the entire folder (passphrase required)

Console API (https://api.allmynoggins.com/v1, session cookie)

Method and pathPurpose
POST /signup, POST /login (WebAuthn or OIDC), POST /logoutAccounts; sign out ends this browser’s session
GET /account/sessions, DELETE /account/sessions/{id}Signed-in devices: list, sign one out
POST /subdomainClaim or check a subdomain
GET /noggins, POST /noggins, PATCH /noggins/{id}, DELETE /noggins/{id}Pointers and hosted lifecycle (hosted: true triggers creation when a provider is enabled)
POST /noggins/{id}/pairing-tokenShort-lived JWT for the app to pair (also called by talkto, signed in)
POST /noggins/{id}/install-packageInstall package for a target
POST /noggins/registerCalled by a brain with its address and heartbeat
POST /designer/sessionPlatform-backed designer trial
GET /billingHosted spend and invoices

Both APIs are described in OpenAPI 3.1 files committed to the repo; clients are generated from them.

Data and storage

The noggin/ folder (brain; all open formats)

noggin/
  config.yaml            exposure, engines, keys reference, registry on/off, profile
  secrets.enc            model and speech provider keys only, encrypted with the passphrase-derived key
  voices/<slug>.md       one voice: YAML front-matter plus system prompt
  voices/<slug>/prompts/*.md
  voices/<slug>/media/   produced files (v1.5)
  memory.db              sessions, messages, memory + embeddings (sqlite-vec), prompts, feedback, consent, learned weights (encrypted at rest)
  runs.db                ModelRun, SpeechRun, ToolRun audit, append-only

Voice front-matter schema

name: Career adviser
privacy: asks            # local | asks | web
model:
  default: claude-sonnet
  fallback: llama3.1-8b-local
speech:
  stt: device            # device | whisper-local | elevenlabs | deepgram
  tts: kokoro-local      # device | kokoro-local | chatterbox-local | elevenlabs | openai | cartesia
  voice_id: null
memory_scope: career
reads: [~/notes/work]    # shown on the voice card as "reads: ..."
tools:
  read: [web, calendar]
  produce: []
  draft: []
spend_cap_gbp_month: 20
schedule:
  - cron: "0 7 * * *"
    prompt: "Check fares for the Lisbon trip"

Brain tables: Session, Message, Memory (scope, kind, content, embedding), Prompt, Feedback, LearnedWeight (voice, class, model, score), ModelRun, SpeechRun (backend, seconds, cost, local), ToolRun, Consent (session, granted_at).

Console tables (Postgres): Account (id, email, created_at, accepted_terms_at), AuthMethod, Subdomain (slug, account_id), Noggin (id, account_id, name, address, hosted, tier, status, last_seen, last_export_at, accepted_at), HostedInstance (noggin_id, provider_id, instance_id, volume_id, region), Invoice, plus the Ops tables above. No message content, no voice content, no keys. A schema test enforces this list.

Outbound actions and tools

Invariant: the brain never acts on the world. There is no Act tier and no code path that submits, posts, pays, sends or cancels. Three capability tiers are granted per voice under tools: and enforced by a tools/ module behind the router’s privacy gate. v0.1 ships Read only.

TierModulev0.1Interface
Readtools/read/yessearch(query), fetch(url), calendar.read(), mail.read(); local voices refused; asks voices show the query first
Produceadapters/media/v1.5image(prompt), video(prompt, assets), audio(prompt); files to voices/<slug>/media/; MediaRun row with model, prompt, cost
Drafttools/draft/v1.5Returns a Draft (kind, payload, preview, handoff) that the app renders as a card with one button; handoff is a share intent, deep link or copy-to-clipboard into the platform’s own app, never an API call made by the brain

Requirements

Security requirements

  1. Egress allow-list in the brain. Only adapters/ and tools/read/ may open outbound connections, to the hosts configured, plus the console registry endpoint. Enforced in code and by the container’s network policy where available.
  2. Audit before send. A ModelRun, SpeechRun or ToolRun row is committed before any outbound request; a failure to write aborts the call.
  3. Privacy level is a hard gate. The router cannot select a cloud adapter for a local voice under any fallback; for asks, no cloud call without a Consent row for the session.
  4. Encryption at rest. memory.db, secrets.enc and the export archive use a key derived from the owner’s passphrase (Argon2id); the brain holds the key in memory only while running.
  5. Transport. TLS everywhere. Default exposure is tunnel: a per-noggin Cloudflare Tunnel, no inbound port on the host, HTTPS terminated at Cloudflare’s edge (accepted; see Trust in transit). End-to-end encryption between app and brain is an owner option from milestone 05b. The brain refuses to start in public mode without its own certificate.
  6. Tokens. Console pairing JWTs expire in 5 minutes and are single-use; brain bearer tokens are revocable from the brain and listed in the app’s settings.
  7. Console minimalism. The console database never stores message, memory, voice or key content; a schema test enforces the table list.
  8. Hosted isolation. One machine and one volume per noggin; no shared database; the control worker holds provider credentials, the browser never does.
  9. Dependencies. Pinned versions; images rebuilt and scanned weekly.
  10. Account deletion removes all pointers and hosted machines within 24 hours, after an export is offered.

Deletion and liability requirements

Deployment

ComponentWhereHowApprox. monthly cost
Marketing siteCloudflare PagesGit push builds Astro; custom domain, TLS automatic£0
talkto, designerCloudflare Pages with wildcard *.allmynoggins.comVite builds; wildcard certificate from Cloudflare Free£0
Console, ops and the registryCloudflare Workers (Next via OpenNext) with D1, Durable Objects and Cron Triggers; my, api, get and ops hostnamesMilestone 13; until then the Node build runs only locally$5 (Workers Paid)
Platform databaseCloudflare D1Pointer rows only; browsable in the dashboard; nightly export with wrangler d1 export£0
Noggin host gpu-1 (founder’s and demo noggins, later hosted ones)GigaGPU dedicated bare metal (UK), RTX 5060 Ti 16 GB, Ryzen 3700X, 32 GB RAM, 1 TB NVMe + 1 TB SATA, Ubuntu 24.04, ordered 2026-10-23; plus the laptopDocker, the node agent (milestone 14), native Ollama shared by every noggin on the box, shared Whisper and Kokoro services (see Shared speech engines on a GPU host), a Cloudflare Tunnel sidecar per noggin~£130
Hosted brainsOn noggin hosts through the noggin host adapter, behind the hosting flag (milestone 14)——
Bigger GPUOnly if a measured queue appears: an RTX 3090 24 GB (£199) at the same provider; vLLM only then——

Shared speech engines on a GPU host (planned 2026-10-23, built 2026-10-24 in milestone 12)

On a machine that runs several noggins, the host’s native Ollama is already one engine shared by every container (OLLAMA_URL), and it is the only engine the router treats as local. Whisper and Kokoro are different: whisper_local runs the whisper.cpp binary built into the image and kokoro_local imports kokoro_onnx in-process, so each container carries its own copy of each model and has no GPU unless it is handed the card. On gpu-1 they become two shared services instead. Nothing else moves.

flowchart TB
  subgraph platform["allmynoggins platform · pointers, accounts, templates, settings · never a message, memory, voice file or key"]
    direction LR
    site["allmynoggins.com<br/>site"]
    console["console + API<br/>&lt;slug&gt;.allmynoggins.com"]
    pg[("Postgres<br/>pointers only")]
    talkto["talkto<br/>static PWA shell"]
    designer["designer<br/>stateless"]
    ops["ops<br/>staff passkeys"]
  end
  phone["talkto on the owner's phone<br/>holds only: session cookie, brain address, device token"]
  subgraph gpu1["gpu-1 · GigaGPU bare metal · Ubuntu 24.04 · Docker"]
    direction TB
    subgraph n1["noggin container · &lt;name&gt;"]
      t1["cloudflared sidecar<br/>&lt;name&gt;.allmynoggins.net"]
      b1["brain<br/>api → router → privacy gate<br/>llm: ollama · anthropic · openai_compat<br/>speech: whisper_http · kokoro_http ①<br/>host.py probes Ollama and the speech services ②<br/>audit → runs.db before every call"]
    end
    subgraph n2["noggin container · demo &lt;name&gt;"]
      t2["cloudflared sidecar"]
      b2["brain · same image<br/>OLLAMA_URL · WHISPER_URL · KOKORO_URL from the compose file ③"]
    end
    ollama["Ollama · native on the host · 127.0.0.1:11434<br/>already shared · already local · swaps models on demand · unchanged"]
    subgraph engines["Docker network engines · private · no tunnel, no inbound port ③"]
      whisper["Whisper service<br/>Speaches or faster-whisper · GPU<br/>:8001 /v1/audio/transcriptions"]
      kokoro["Kokoro service<br/>kokoro-fastapi · GPU<br/>:8002 /v1/audio/speech"]
    end
    gpu["RTX 5060 Ti 16 GB: 8B general + 3–4B small (Ollama) · Whisper turbo · Kokoro · KV cache"]
  end
  subgraph laptop["noggin on a laptop or Pi · unchanged"]
    bl["brain<br/>whisper_local · kokoro_local in-process<br/>Ollama native on that machine"]
  end
  cloud["cloud model and speech providers<br/>reached only from a brain's adapters/<br/>only where the voice's level allows · logged first"]

  phone -. "sign in · list noggins · pairing token" .-> console
  phone -- "tunnel" --> t1
  phone -- "tunnel" --> bl
  b1 -. "heartbeat 60 s" .-> console
  b1 --> ollama
  b2 --> ollama
  b1 == "new" ==> whisper
  b1 == "new" ==> kokoro
  b2 == "new" ==> whisper
  b2 == "new" ==> kokoro
  b1 -. "web and asks voices only" .-> cloud
  bl -. "web and asks voices only" .-> cloud

The three change points

WhereTodayChange
①brain/noggin/adapters/speech/, brain/noggin/router/speech.py (STT_WHERE, TTS_WHERE)whisper_local is a whisper.cpp subprocess; kokoro_local is in-process; the maps give each backend a locality of noggin, device or cloud, which is what the privacy gate uses for speechTwo new adapters, whisper_http and kokoro_http, speaking the OpenAI-style audio endpoints the services expose, base URLs from config. Both map to noggin (same host), so every privacy level may use them and the talk caption stays true. The in-process adapters remain for laptops and Pis. Both calls open from adapters/, so invariant 5 and its test are unchanged
②brain/noggin/host.pyCapability probes Ollama only and publishes engines/models at /v1/health; the router, the console’s Online state and Install model read that snapshotProbe the configured speech services too and publish them under speech_backends, so plan_speech sees the HTTP backends as ready and a noggin whose services are down falls back to device speech and says so. Ollama probing is untouched
③templates/install/docker-compose.yml, console/api/installer/Every service uses network_mode: host; Ollama is native; the brain gets OLLAMA_URL. Targets are laptop, vps-linux, cloud-vmA gpu-server host preset: a compose file that creates the private engines network, runs the Whisper and Kokoro services once with the GPU, and starts each noggin as a client with WHISPER_URL and KOKORO_URL beside OLLAMA_URL. Tunnel sidecar, port offsets and the single-use pairing token stay as they are. The GPU-server image may omit whisper.cpp and the ONNX model

Unchanged by this: the privacy gate and its tests (Ollama remains the only local LLM engine), audit before send, the egress allow-list, one tunnel per noggin, the mouthpiece, console, ops, designer and site, voice files and the noggin folder, export and import (a noggin moved off gpu-1 falls back to the in-process adapters with no edit to its folder).

Not adopted: vLLM, and with it engine locality in config.py (the ModelEntry validator “only ollama models are local”), keyless readiness in brain.py, and an amortised hardware cost per reply. Ollama with OLLAMA_NUM_PARALLEL serves a handful of concurrent visitors on an 8B model; vLLM pays off only at tens of simultaneous chats on one card and cannot swap models on demand, which Install model relies on. Revisit when a queue is measured.

As built (milestone 12): the gpu-server install target writes templates/install/engines/docker-compose.yml to /srv/engines/ with one .env (ENGINES_DEVICE=cuda|cpu, WHISPER_MODEL) that the engines project and every noggin on the machine read; Compose profiles cpu and cuda pick the images (Speaches 0.8.3-cpu|cuda, Kokoro-FastAPI v0.9.0 CPU or v0.9.0-cu128), and the card is reserved only under cuda. On a CPU the Whisper model is Systran/faster-whisper-small.en (about 3 s for a short utterance on a laptop, against 13–21 s for turbo); on the card deepdml/faster-whisper-large-v3-turbo-ct2. A gpu-server noggin runs on Docker’s networks rather than the host’s (it must join engines), so it reaches Ollama at host.docker.internal: Ollama on such a host listens on 0.0.0.0:11434 with a ufw rule admitting only Docker’s private range 172.16.0.0/12. One image everywhere (owner, 2026-10-24): the in-process adapters stay in the image and NOGGIN_SPEECH_IN_PROCESS=off, set by the compose file and never by config.yaml, turns them off, so a folder moved to a laptop gets them back. The brain probes both services with the host snapshot (5-second TTL), lists them under speech_backends at /v1/health and, by name and state only, in the heartbeat; a talk rechecks before each turn and, when a service fails mid-reply, marks it down and hands the sentence to the next choice (the device’s voice), with a new caption. A voice asking for whisper-local or kokoro-local gets the shared service first.

Rules for the shared services: they listen only on the engines network, never behind a tunnel and never on a host port; every transcription and speech call is still written to runs.db as a SpeechRun before it starts; a noggin off this host never calls these engines (a hop off its own host is neither local nor cloud; a fourth locality is a [decide]).

Demo video uploads (proposed, [decide]): uploads land on the SATA disk at /mnt/spool, size and duration capped, rate-limited per visitor; the audio track is extracted by ffmpeg in a container with no network and no access to any noggin folder; the video is deleted as soon as the audio is out and the audio as soon as the transcript is written; Whisper runs one job at a time with the visitor shown their place in the queue. This is a Read-tier action with no upload endpoint on the brain today, so it is a milestone of its own. [decide] whether it shares the Whisper queue or gets a lower-priority worker.

DNS and edge (Cloudflare Free plan): zone allmynoggins.com with nameservers moved from Porkbun; records @, talkto, ops, designmynoggin, api and the wildcard *, all proxied; Universal SSL covers them. A second zone, allmynoggins.net, is the noggins’ namespace: each self-run brain is reached through its own Cloudflare Tunnel at <name>.allmynoggins.net (proxied CNAME to the tunnel, covered by that zone’s free Universal SSL; two-level names under .com would need a paid certificate). Email Routing provides admin@ and hello@ forwarding; transactional sending through Workers Paid or Resend when the console needs it.

Owner experience for self-run noggins (decided 2026-10-17). Running a noggin on your own machine must need no knowledge of Docker, Ollama, Cloudflare, DNS or tokens. The rules:

  1. One action to install. The console’s Run it myself gives one download per platform; the owner runs one command (or double-clicks on Windows and macOS). It installs or reuses Docker and Ollama, picks and pulls models for the machine’s RAM with visible progress, starts the noggin and its tunnel sidecar, and sets everything to start again after a reboot or login. Ollama stays native on the host (GPU and Apple-silicon acceleration); everything of the noggin’s own is in containers.
  2. The owner never opens Cloudflare. Each noggin’s tunnel, its route and its <name>.allmynoggins.net record are created by the console through the Cloudflare API in the platform’s account, and the tunnel token arrives inside the package. Owners need no Cloudflare account.
  3. Pairing ends the install. The installer finishes by showing a QR code and a link. On a phone the owner installs the app from talkto.allmynoggins.com first, then scans the code with the app’s Scan pairing code (camera) or types the name and code. Install-then-pair is the documented order because iOS keeps an installed web app’s storage apart from the browser.
  4. Re-running repairs. Running the installer again on an installed machine changes nothing if all is well, and puts back whatever is missing (a stopped container, a missing model, a lost start-at-login entry). noggin doctor checks Docker, Ollama, models, the tunnel, disk space, the clock and folder permissions, and prints each problem with the exact fix.
  5. Updates are one action. noggin update takes an encrypted backup first, pulls the newest published image, restarts with the same folder, and rolls back if the new version does not pass its health check. When the console heartbeat is on, the app shows Update available; the brain itself never phones out for this (egress rule).
  6. Backups happen by themselves. An encrypted export runs on a schedule (backup: in config.yaml, daily and keep 7 by default) to a folder the owner chooses.
  7. Missing models are one tap. When a voice is unavailable because a model is missing, the app offers Install llama3.1:8b (4.9 GB); on the owner’s tap the brain asks the host’s Ollama to pull it, with progress.
  8. Leaving is clean. The uninstaller offers an export first, then removes the containers, the start-at-login entries and, if asked, the folder; it never removes Docker or Ollama the owner had before.
  9. Moving is export, start, re-pair. Moving to another machine is an encrypted export, the installer on the new machine with Import, and the same address keeps working because the tunnel token travels in the folder.

Plain-English deployment of a self-run brain: download the install package for the target from the console, run install.sh (or paste cloud-init.yaml into the provider), wait for the console to show online, open the app.

Build order for Claude Code

Each milestone is one Claude Code session or a short series; each ends with something runnable. Prompts are under prompts/.

#MilestoneDone when
0Monorepo scaffold, packages/ui with tokens and components, Storybook in light and darkStories match design/ section 05 pixel for pixel
1Brain core: config, voice loader, SQLite memory, Ollama adapter, /v1/health, /v1/voices, /v1/sessions, streaming messagesChat with a local voice from curl
2Router and audit: rules.yaml, Anthropic and OpenAI-compatible adapters, redaction, asks consent, runs.db, /v1/costs, adviser, schedulerA local voice refuses the cloud; costs appear per reply
3talkto.allmynoggins.com PWA: voice picker, chat, settings, pairing, installableFrom a phone’s browser, talkto.allmynoggins.com pairs with the laptop brain at its .allmynoggins.net address, chats, and installs to the home screen
4Speech: device STT and TTS; Kokoro and Whisper adapters; audio WebSocket; talk screen with all mic statesTalk mode works end to end
5Export and import, personality archives, deletion, encryption, multi-arch image, binaryMove a noggin from the laptop to the server and re-pair
5bEnd-to-end encryption option: pairing by PAKE over the one-time code, encrypted request bodies, streams and audio, safety numberA recording proxy in front of the brain sees no message or reply text
6Console: accounts, passkeys and OIDC, subdomain claim, pointers, pairing tokens, Open, availability<slug>.allmynoggins.com opens the founder’s server noggin
6bInstall package generator for laptop, VPS and cloud VM; console-provisioned tunnels; QR pairingEach target comes online from a downloaded package
7Ops console, provider adapter interface, templates, settings, auditDomain-locked ops sign-in; hosting hidden until a provider is enabled
8Marketing site with real voice cards and build log; designerallmynoggins.com live with the interest list

Milestones 0 to 5 need nothing from platform infrastructure, so the sovereignty story can be demonstrated before any account system exists.

Decision log

See docs/decisions.md (maintained in the repository).

Open questions