The collapse of map #3 — 41 resolved tickets and
18 ADRs — into one buildable plan. Every decision below is settled and recorded; nothing here is
open. Where a decision has a record, the record is the authority and this spec is its index.
Problem Statement
The Owner runs their life across several systems on a Mac mini — a media server, an academic
pipeline (academic-os + ntulearn), and a large document corpus — and reaching any of them means
opening the right app, remembering the right invocation, or being at the right machine. There is no
single conversational surface that answers fast, knows which domain a question belongs to, and can
drive each system's own tooling — and no way to have one without either paying per token or leaking
private life into a training set. Free provider tiers exist but are a minefield: limits are
misdocumented or unpublished, models vanish without notice, and a naive integration goes dark the
first time a quota runs out.
Solution
Syrax: a single-user, Telegram-first chat system resident on the mini, 24/7. One bot, locked to
the Owner's Telegram ID and failing closed, carrying four chats as topics in the bot's private
chat — General, Academic, Media, System — where a chat is a capability boundary:
the chat determines the reachable tool layer, the retrieval scope and the session state.
Replies ride free tokens exclusively, through two lanes of ordered provider rungs: a fast
front lane that owns the conversation, and an unmetered worker lane reached only as a sub-agent the
front lane calls as a tool — so a fast acknowledgement and a slow answer are the same turn, and the
system is never dark, only slow. A rationed escape hatch exists for explicit requests only.
The pinned OpenClaw runtime does all routing; Syrax builds only what the runtime cannot express,
and none of it sits on the request path.
Three capabilities at v1: the media request desk (tool layer owned by media-server), the academic
pair (refresh-then-read, holding no credentials), and natural-language file search over a locally
built and locally embedded index. Syrax builds no capability functionality of its own.
User Stories
Conversing
- As the Owner, I want any question answered in about a second in conversational language, so that chatting with Syrax feels like messaging a person, not operating a tool.
- As the Owner, I want replies terse by default and expanded when my wording asks for depth, so that the request's wording is the authority on length.
- As the Owner, I want a typing indicator during an ordinary turn, so that I know the system heard me without it costing a message.
- As the Owner, I want a slow turn (one that delegates to the worker lane) to post a single progress message that is edited as work proceeds, with the answer arriving beneath it, so that the thread keeps a record of what happened.
- As the Owner, I want the front lane to admit what it does not know rather than invent details, so that a fast answer is never a fabricated one.
- As the Owner, I want a message typed at the chat root (no topic) answered as General and never dropped, so that the most natural gesture on the surface always lands.
- As the Owner, I want a question asked in the wrong chat redirected to the chat that owns it — never answered by reaching across — so that each chat's context stays small and its boundary real.
- As the Owner, I want only my Telegram ID answered, everything else failing closed, so that a single-user system is actually single-user.
General chat — broad search
- As the Owner, I want to describe a document in my own words and receive the file itself by default, so that finding a file is one message, not a conversation.
- As the Owner, I want a close call to offer three tappable candidates plus a "none of these" tap, so that disambiguation is one tap, not a typed clarification.
- As the Owner, I want an expired shortlist to say it has expired rather than guess, so that a stale tap never sends the wrong file.
- As the Owner, I want a search that finds nothing to say nothing here instead of offering the least-bad match, so that a confident tone tracks confident retrieval.
- As the Owner, I want a document inside the index allowlist but outside the extraction scope still findable by name, with the reply saying content search did not reach it, so that partial coverage is legible.
- As the Owner, I want General able to read a file the Academic chat owns without calling Academic's tools, so that the capability boundary sits on the tool layer, not the corpus.
Academic chat
- As the Owner, I want "what's due" answered from the calendar, so that the answer reflects the source
academic-os treats as authoritative.
- As the Owner, I want scoped search over everything under my modules root, so that course questions search course material only, fast.
- As the Owner, I want to trigger
ntulearn sync from the chat with an in-chat confirmation, so that a refresh is one message and never accidental.
- As the Owner, I want calendar Promotion from the chat with an in-chat confirmation, so that the two sanctioned writes are reachable where I already am.
- As the Owner, I want a 07:00 daily brief — the day ahead, what arrived overnight, how the overnight jobs went — posted even when empty, so that its absence is itself a signal something is wrong.
Media chat
- As the Owner, I want to request a film or show in plain language and have it land in the media server's request pipeline, so that the chat is a request desk.
- As the Owner, I want to ask what is downloading, stuck or recently added, so that the chat is an ops console for the media stack.
- As the Owner, I want availability announcements to appear in the Media chat when a request completes, so that the loop closes where it started — even though it is Seerr's own agent posting on Syrax's bot token, not Syrax.
System chat
- As the Owner, I want a usage report on demand stating each lane's headroom, each provider's own telemetry beneath it, and a timestamp where nothing recent was observed, so that I can see whether the system can still talk and still think.
- As the Owner, I want the usage report posted unprompted only when something moved — a stand down, a lane switch, a rationed call, a rung rotted or recovered — so that I never learn to ignore it.
- As the Owner, I want to pin a provider into service or stand one down from the System chat conversationally, so that both overrides are one message.
- As the Owner, I want a rotted rung reported once per transition with the provider's own message verbatim and an inline-keyboard tap to remove it, so that removal is my decision and one tap — never automatic.
- As the Owner, I want a retrieval report when a benchmark number moved or a run failed, stating what the confident floor would be if re-fitted and never applying it, so that retuning stays a person's act.
- As the Owner, I want a genuine chat recreation announced in System with its new carrier id, so that I know when a capability chat came back empty and what to re-point.
Lanes and quota
- As the Owner, I want the front lane falling through its chain on failure without my noticing anything but a slightly slower reply, so that a provider outage is not my problem.
- As the Owner, I want the worker lane to fall to an unmetered floor, so that background work can always run — slow, but never dark.
- As the Owner, I want the escape hatch reached only when I ask for it in so many words, refusing before it spends when a rung's counter says the day is gone, so that the rationed lane is never spent by accident.
- As the Owner, I want a stood-down rung to return only when something writes it back at its stated reset, so that a stand down is owned rather than awaited.
- As the Owner, I want stand downs re-derived from counters at startup — never inherited from a config file a redeploy might revert — so that a live stand down survives a deploy.
- As the Owner, I want a mid-stream lane death restarted silently on the fast path and continued below the persisted partial on the slow path — the partial passed to the second model as context, never as a prefix to complete — so that a died turn never ends as silence or a corrupted splice.
Search feedback
- As the Owner, I want to mark a wrong search result by replying to the result itself in plain language, so that capture costs one message and nothing watches my tone.
- As the Owner, I want each miss to record which of the five failures it was, so that the fix lands in the right place — floor, ranking, or chunking.
- As the Owner, I want live captures appended to the same benchmark set as the fixtures, marked apart, so that there is never a question about which set is the bar.
Operations
- As the Owner, I want every unit to survive a crash, a panic and a power cut unattended, so that the system's availability does not depend on my presence.
- As the Owner, I want every credential in one secrets store, refused if left world-readable, with rotation a store-write plus a unit restart via the re-runnable wizards, so that a leak has one place to happen and one place to fix.
- As the Owner, I want proactive messages only from schedules I set, and confirmed writes only with in-chat confirmation, so that Syrax does nothing more and nothing less than configured.
- As the Owner, I want a deleted topic recreated on the write path the moment a send fails on it, so that clearing a topic in my client never kills a chat.
- As the Owner, I want everything Syrax stores living on the RAID volume, so that the internal disk carries nothing of mine that a compliant configuration could place elsewhere.
Implementation Decisions
The runtime and its contract (ADR-0003, ADR-0011, ADR-0016)
- The runtime adapter wraps OpenClaw, pinned to an exact
extended-stable version. The adapter is a configuration contract, not a wrapper layer; the runtime owns routing, sessions, channels and tool-calling. The pin is a tracked lockfile installed outside the checkout.
- Four agents, one per chat. General is the default agent;
requireTopic stays unset, so a thread-less root message binds to General. Per-topic agent routing carries the capability boundary. Carrier ids are named, never indexed; the domain→carrier map lives in private runtime state and its loss is recovered by the write path, never by name-matching.
- The front agent's standing configuration: skills empty (both catalogues off), workspace pinned to the runtime root, block streaming off, typing mode instant — each stated explicitly, since every default is wrong here. The workspace carries the front lane's standing instruction including the anti-fabrication line (ADR-0016).
- The front lane does not stream (ADR-0008). Fast path: typing indicator only. Slow turn: defined by delegation, not elapsed time; the progress message is triggered by the delegation itself. Every edit that must keep an inline keyboard re-passes it; callback acknowledgement is the handler's first act.
Lanes (ADR-0006, ADR-0009, ADR-0016)
- Two chains in configuration, not three: front on the default model, worker on the sub-agent override. Composition at v1 (ADR-0016): front
Gemini 3.5 Flash Lite → Mistral ministral-3b → Groq gpt-oss-120b; worker Gemini 3.1 Flash Lite → Mistral ministral-8b → Groq gpt-oss-20b → Z.AI glm-4.5-flash. No two lanes share a quota bucket. Speed chooses the shortlist; failure rate chooses the rung.
- There is no router process and no self-imposed token budget. Provider quotas are the only meter; the provider is the authority wherever it speaks, and Syrax counts only for the one provider that says nothing.
- The worker's sub-agent output passes through verbatim. Sub-agent and per-provider timeouts are set explicitly — the zero-default means the worker lane can otherwise hang forever. The idle watchdog is per provider; death is declared on time since last token plus a whole-turn ceiling, at two attempts.
- Walls are cleared in chain composition and configuration (
maxTokens reservation against streaming charges), never by a per-request filter. Lanes stay reactive; pre-emptive switching is the hatch's alone.
- The context ceiling trims oldest-first, counted in tokens, per turn. No summarisation at v1 — that is the thin end of the memory fog, deliberately unentered.
The lane monitor (ADR-0006, ADR-0009, ADR-0012)
- One unit holds what Syrax knows and the runtime does not: the hatch's five per-rung counters, the counter for the silent provider, which rungs have rotted, and when each source was last read. It observes and refuses; it never chooses a model; it sits on no reply path.
- The escape hatch is an MCP tool that refuses before it spends. The Flash-ladder rungs it guards are excluded from the chains.
- Stand down is a config write, hot-applied, no restart (ADR-0009) — asymmetric with pin, which is the runtime's native selection override. The write lands with a startup reconciliation rule: stand downs are re-derived from counters, never inherited.
- A rotted rung is reported, never repaired (ADR-0012): the daily sweep sends a real completion through chain rungs only; the passive read of the runtime's fallback-decision log catches the serving rung; nothing skips a dead rung silently. The log reader keys its offset on inode and size, and the report states the window it actually covered.
- The usage report's second audience is a file under the runtime root. Both reports (usage, retrieval) are exceptions-only plus on-demand; neither is a daily brief.
File search (ADR-0004, ADR-0007)
- Syrax builds the index: one SQLite file, FTS5 and vector arms over the same extracted text, ~512-token overlapping windows, embedded locally by the pinned EmbeddingGemma
model_q4 ONNX export behind one resident MCP server process. Filenames are indexed on the keyword arm.
- The search tool returns a verdict, not raw results; the confident floor is fitted against the benchmark, and
empty says so. Scope is bound per chat in configuration, never model-passed. Two tools: search ranks, read returns a named document's text, bounded by the blocklist outside the indexed roots.
- Three lists with three jobs: the index allowlist is a compute scope, the extraction scope is additive, the blocklist is the security boundary — never indexed, never read, never extracted, anywhere. The indexer follows no symlinks. Rejects are enforced at index time, never at query time.
- The feedback loop captures, scores and reports; it never retunes (ADR-0007). Capture is explicit only — a reply to the result, or the fourth "none of these" tap. The benchmark is one set, fixture and live entries marked apart; re-fitting is computed and stated, never applied. Re-embed on the three-day schedule plus on demand, poked into the search unit — no new unit for it.
The chat surface
- One bot, threaded mode on, user topic creation off, group joining off — each asserted from the API, not remembered from a menu. The update filter is asserted at provisioning: it is persistent bot state, and filtered updates are dropped, not buffered.
- The four topics are created by the wizard; General carries a created topic like the others — the root is deliberately not a carrier (no view, no badge, no scrollable history). The root remains the never-dropped fallback answered as General.
- A chat's existence is Syrax's (ADR-0013). Verification is a write-path concern: a send failing
message thread not found recreates and retries on the spot; a genuine recreation is announced in System, and re-pointing Seerr at the Media chat's new carrier is a wizard stage, since the capability's own product owns its configuration. There is no read-only liveness probe on the platform — validation and the resurrection-causing service message are the same event — so nothing is built that pretends otherwise.
Secrets and scratch (ADR-0010, ADR-0014, ADR-0015, ADR-0017)
- One secrets store, file-backed refs, refused at insecure modes; generated configuration carries the credential marker and no key. Rotation is a store write plus a chosen unit restart. The wrapper carries no credentials.
- The runtime logs to a fixed basename and rotates itself; rotation ownership is split with the launchd captures; log directories are created private. The scratch root stays where the runtime hardcodes it with its mode asserted; the gateway lock directory is pre-created private, ensure-then-assert.
Supervision (ADR-0005)
- launchd LaunchAgents on the house shape, launched through a wrapper whose pre-flight audits the secrets store (refusing to start on an unresolved ref) and asserts the scratch modes.
KeepAlive for a crash, macOS restart for a panic, pmset autorestart for a power cut; auto-login on, FileVault off — verified preconditions, with cold-boot recovery still to be observed once.
- launchd owns every wall-clock schedule, poking loopback endpoints with the gateway auth token pinned — so "what can message me unprompted" has one answer. The academic brief at 07:00 is such a schedule; Syrax composes and posts it itself.
- Nothing new watches Syrax: the brief is the daily heartbeat, and a chat surface's death shows within one message.
- The two provisioning wizards are the rotation paths and assert what they provision; their probes authenticate with an id-free call first and pass provider sentences through verbatim.
Capability chats
- Media: a request desk and ops console over the tool layer
media-server owns; Syrax builds no completion pathway and no indexer search. Academic: refresh-then-read — Syrax triggers each product's own refresh and reads its output, holding no credentials; the two confirmed writes are ntulearn sync and calendar Promotion; "what's due" reads the calendar.
Testing Decisions
A good test observes external behavior at a seam — what crossed a wire or landed in a file —
never an implementation detail. Three seams, all already proven by the map's verification tickets;
no new seam is introduced.
- The Telegram wire: the gateway is pointed at a local stub standing in for the Bot API, updates are injected and outbound calls asserted. This is where routing (topic → agent, root → General), capability-boundary redirects, progress-message editing, keyboard re-passing and write-path recreation are all tested. Prior art: the map's own runtime-verification tickets drove the pinned gateway exactly this way.
- The provider wire: each rung's base URL points at a local OpenAI-compatible stub scripted with the measured failure shapes (429 with a code in the body, 413 walls, empty catalogues). This is where chain walking, failover, stand-down reconciliation, watchdog deadlines and the lane monitor's counters are tested — without spending quota. Prior art: the chain re-verification and failover-classification tickets.
- The search unit's MCP tools:
search, read and capture called directly against a synthetic fixture corpus — verdict shapes, scope binding, blocklist refusal, symlink refusal, capture recording. The retrieval benchmark never runs in CI and is not a test: real queries against real private paths, scored on the mini only, reported not asserted.
The lane monitor is tested at its file boundaries (the runtime's state database, the fallback-decision log) feeding the two wires. The wizards are deliberately untested by automation: they are interactive rotation paths with a human present. CI remains the skeleton checks; the suite runs on the mini.
Out of Scope
Everything the map ruled out, carried forward verbatim as boundaries of this spec:
- Mirroring Seerr's notifications into the runtime's context (v1.1; needs an inbound listener the mini does not have).
- General reaching down into the capability chats' tools — v1 General is broad search and plain answering; the intent router that "every capability on demand" needs is its own future effort.
- Per-request selection over a skill index — at zero skills there is no catalogue to search.
- Curator operations on the media library — deliberately no tool exists.
- Encrypting the RAID volume — machine-wide posture, not this effort's.
- Decommissioning a chat — the four chats are the system's shape; the config-and-restart path exists for free.
- A memory system — not v1; summarisation is its thin end and the context ceiling deliberately trims instead.
- The volume-present-but-unreadable response — open fog; the secrets pre-flight already refuses a cold start on it, and the running-process case waits for the response to be decidable.
Further Notes
- Model ids rot faster than the decisions naming them. The chains' composition is configuration and expected to change; the mechanisms (two chains, the monitor, report-never-repair) are the durable part. A session implementing this must re-verify any model id against the live provider before pinning it into configuration.
- The free-token constraint is the point of the system, not a preference. A purchased balance is a paid lane with a delay in front of it; reversing this changes the project's premise and is not a session's call.
- Two fog patches remain on the map (memory; unreadable volume) and neither blocks v1. If implementation surfaces either — trimming that loses things the Owner misses, or the volume wall recurring — it graduates on the map, not here.
- The 18 ADRs are the reasoning; this spec is their index and adds no decision of its own. Where this spec and a record disagree, the record wins and this spec has a bug.
Assisted-by: Claude Fable 5
Co-authored-by: Claude Fable 5 noreply@anthropic.com
The collapse of map #3 — 41 resolved tickets and
18 ADRs — into one buildable plan. Every decision below is settled and recorded; nothing here is
open. Where a decision has a record, the record is the authority and this spec is its index.
Problem Statement
The Owner runs their life across several systems on a Mac mini — a media server, an academic
pipeline (
academic-os+ntulearn), and a large document corpus — and reaching any of them meansopening the right app, remembering the right invocation, or being at the right machine. There is no
single conversational surface that answers fast, knows which domain a question belongs to, and can
drive each system's own tooling — and no way to have one without either paying per token or leaking
private life into a training set. Free provider tiers exist but are a minefield: limits are
misdocumented or unpublished, models vanish without notice, and a naive integration goes dark the
first time a quota runs out.
Solution
Syrax: a single-user, Telegram-first chat system resident on the mini, 24/7. One bot, locked to
the Owner's Telegram ID and failing closed, carrying four chats as topics in the bot's private
chat — General, Academic, Media, System — where a chat is a capability boundary:
the chat determines the reachable tool layer, the retrieval scope and the session state.
Replies ride free tokens exclusively, through two lanes of ordered provider rungs: a fast
front lane that owns the conversation, and an unmetered worker lane reached only as a sub-agent the
front lane calls as a tool — so a fast acknowledgement and a slow answer are the same turn, and the
system is never dark, only slow. A rationed escape hatch exists for explicit requests only.
The pinned OpenClaw runtime does all routing; Syrax builds only what the runtime cannot express,
and none of it sits on the request path.
Three capabilities at v1: the media request desk (tool layer owned by
media-server), the academicpair (refresh-then-read, holding no credentials), and natural-language file search over a locally
built and locally embedded index. Syrax builds no capability functionality of its own.
User Stories
Conversing
General chat — broad search
Academic chat
academic-ostreats as authoritative.ntulearn syncfrom the chat with an in-chat confirmation, so that a refresh is one message and never accidental.Media chat
System chat
Lanes and quota
Search feedback
Operations
Implementation Decisions
The runtime and its contract (ADR-0003, ADR-0011, ADR-0016)
extended-stableversion. The adapter is a configuration contract, not a wrapper layer; the runtime owns routing, sessions, channels and tool-calling. The pin is a tracked lockfile installed outside the checkout.requireTopicstays unset, so a thread-less root message binds to General. Per-topic agent routing carries the capability boundary. Carrier ids are named, never indexed; the domain→carrier map lives in private runtime state and its loss is recovered by the write path, never by name-matching.Lanes (ADR-0006, ADR-0009, ADR-0016)
Gemini 3.5 Flash Lite → Mistral ministral-3b → Groq gpt-oss-120b; workerGemini 3.1 Flash Lite → Mistral ministral-8b → Groq gpt-oss-20b → Z.AI glm-4.5-flash. No two lanes share a quota bucket. Speed chooses the shortlist; failure rate chooses the rung.maxTokensreservation against streaming charges), never by a per-request filter. Lanes stay reactive; pre-emptive switching is the hatch's alone.The lane monitor (ADR-0006, ADR-0009, ADR-0012)
File search (ADR-0004, ADR-0007)
model_q4ONNX export behind one resident MCP server process. Filenames are indexed on the keyword arm.emptysays so. Scope is bound per chat in configuration, never model-passed. Two tools:searchranks,readreturns a named document's text, bounded by the blocklist outside the indexed roots.The chat surface
message thread not foundrecreates and retries on the spot; a genuine recreation is announced in System, and re-pointing Seerr at the Media chat's new carrier is a wizard stage, since the capability's own product owns its configuration. There is no read-only liveness probe on the platform — validation and the resurrection-causing service message are the same event — so nothing is built that pretends otherwise.Secrets and scratch (ADR-0010, ADR-0014, ADR-0015, ADR-0017)
Supervision (ADR-0005)
KeepAlivefor a crash, macOS restart for a panic,pmset autorestartfor a power cut; auto-login on, FileVault off — verified preconditions, with cold-boot recovery still to be observed once.Capability chats
media-serverowns; Syrax builds no completion pathway and no indexer search. Academic: refresh-then-read — Syrax triggers each product's own refresh and reads its output, holding no credentials; the two confirmed writes arentulearn syncand calendar Promotion; "what's due" reads the calendar.Testing Decisions
A good test observes external behavior at a seam — what crossed a wire or landed in a file —
never an implementation detail. Three seams, all already proven by the map's verification tickets;
no new seam is introduced.
search,readand capture called directly against a synthetic fixture corpus — verdict shapes, scope binding, blocklist refusal, symlink refusal, capture recording. The retrieval benchmark never runs in CI and is not a test: real queries against real private paths, scored on the mini only, reported not asserted.The lane monitor is tested at its file boundaries (the runtime's state database, the fallback-decision log) feeding the two wires. The wizards are deliberately untested by automation: they are interactive rotation paths with a human present. CI remains the skeleton checks; the suite runs on the mini.
Out of Scope
Everything the map ruled out, carried forward verbatim as boundaries of this spec:
Further Notes
Assisted-by: Claude Fable 5
Co-authored-by: Claude Fable 5 noreply@anthropic.com