Reuse: lessons from three predecessors¶
Status: draft M0. This document records what Bothy adopts from three predecessor projects: concepts, test patterns and at most three small code pieces to rewrite. It adopts no code by copying. The predecessors, their files and evidence are only in the private ops repo; here they are called neutrally V1, V2 and V3.
1. Rules¶
- A concept is adopted if it solves a problem that Bothy has as well. If a concept only solves a quirk of the platform of that time, it is not adopted (section 6).
- Every lesson names: problem, lesson, where Bothy implements it (chapter or ADR) and the test that holds it. A test listed here is a build brief; it only counts as present once it runs in the repo.
- Code pieces are rewritten, not copied (section 5, license note).
2. The predecessors¶
| Short name | What it was | What of it counts |
|---|---|---|
| V1 | Platform adapter for a third-party chat server: a plugin for the agent framework Hermes Agent, plus a structure mirror, history import and live sync from Discord | Delivery and duplicates, catch-up, channel behavior, import tool, settle rule, CDN fetch, test harness |
| V2 | Dev forum runner: one Claude Code session in the project folder per forum post, with a live box and follow-ups | Session contract, process contract, follow-ups, control commands, error paths, costs |
| V3 | Chat and import plugin: new chats from a handoff channel, auto title, import of old threads with binding to earlier sessions | Auto title, session binding like /resume, import rules, temp guard |
All three are plugins for Hermes Agent (github.com/NousResearch/hermes-agent). V2 and V3 work in Discord, V1 reads from Discord. They were tested mainly against fake servers.
3. Lessons¶
L01 Identity tied to a name (V1)¶
- Problem: The third-party chat server knew conversations only by their name. Renaming and moving broke the mapping to the agent session; a separate register patched that, and renames while the adapter was stopped were lost.
- Lesson: Conversation and session hang on a fixed ID, never on a name.
- Implementation:
threads.idstays the same when moving;agent_sessionsper thread (03-data-model.md); session key of the Hermes adapter from the thread ID (09-agents.md). - Test: Rename and move a thread with an active session; the same
agent_sessionsrow stays current, the next run continues the same external session.
L02 Uncertain delivery creates duplicates (V1, twice)¶
- Problem: On a timeout,
500,502or504the server had sometimes already stored the write. Guessing led to duplicates or loss. The same class of error came twice; the solution was a re-query by content comparison. - Lesson: Every write request carries a key generated by the client, which the server enforces as unique; a retry returns the original.
- Implementation:
client_msg_id,client_interaction_id,Idempotency-Key(04-sync-protocol.md, section 5); ADR 0015. - Test: Test pattern 4.1 with "stored, then 502"; the retry yields exactly one message
and
Idempotent-Replay: true.
L03 Catch-up after an outage, built three times (V1, V2, V3)¶
- Problem: Every predecessor built its own catch-up with its own limits (pages, age, "never before activation"). V1 additionally needed a gap model, held-back live events and notices about late messages.
- Lesson: An event log with a commit-ordered sequence and a durable cursor per consumer. "Never before activation" is the starting point of the cursor.
- Implementation: 04-sync-protocol.md, sections 3, 7 and 15.1; ADR 0009.
- Test: Crash of an adapter in the middle of catch-up, restart: no event processed twice
(dedupe by event ID), none missing; live events only after
catchup.complete.
L04 Reply in the wrong channel (V1, concept study)¶
- Problem: Grants were scattered across several lists per platform (allowed, ignored, free channels). An agent replied in a channel that did not belong to it.
- Lesson: Exactly one operator per channel; without an operator nobody replies; one single decision point.
- Implementation: operator in
channels(03-data-model.md),agent.invoke(05-actions.md, section 5); ADR 0017. - Test: In a channel without an operator no message creates a run; the route test finds no route without an action.
L05 Loops in dual operation (V1)¶
- Problem: Copies and agent replies looked like input to the adapter. The loop protection depended on header line patterns in the text.
- Lesson: Origin is a data field; only people trigger.
- Implementation:
messages.origin;agent.invokerule 1. - Test: Messages with
originagent,app,importandsystemin a channel withfree_responsecreate no run.
L06 Channel behavior as loose keys (V1)¶
- Problem: Behavior per channel came from untyped extra keys of the framework configuration. A typo silently stayed unread and could only be noticed live.
- Lesson: Channel behavior as typed fields with a schema; anything unknown is rejected.
- Implementation:
require_mention,thread_require_mention,free_response,auto_thread,history_backfill_limit,prompt,channel_skill_bindingsin 03-data-model.md; strict schema in 06-api.md. - Test:
channel.behavior.updatewith an unknown field yields400.
L07 Platform limits shape the progress display (V1, V2)¶
- Problem: An edit deadline for bot messages, a length limit and rate limits on editing forced the live box into condensing, throttling, splitting and deleting before the reply.
- Lesson: The run is an object of its own with patches; no deadline, no splitting.
- Implementation:
runs,run_steps,run.updated(04-sync-protocol.md, section 11); ADR 0025. - Test: A run with many patches over a long time: every patch is accepted, the document is
complete at the end; a reply up to
message.max_content_bytesstays one message.
L08 "Done" as a word, tag or prefix (V1, V2, V3)¶
- Problem: Done was, depending on the predecessor, a word, a tag, an archive marker or a name prefix. Every bot message brought an archived thread back.
- Lesson:
openandclosedas a field; a new message reopens; the session stays. - Implementation:
threads.state;thread.close,thread.reopen. - Test: A message in a done thread: state
open, the same current session.
L09 Foreign configuration changed by text pattern (V1, V3)¶
- Problem: An installer changed the YAML configuration of the agent framework by a text patch.
The framework writes lists in block form; the same class of error occurred in three places;
[]andnullmean different things there. - Lesson: Never guess foreign configuration by text. Read and compare it before and after with the real parser of the framework; abort on an unclear state.
- Implementation: installation helper of the Hermes reference adapter (09-agents.md, 11-extensions.md).
- Test: Installer against fixtures in block and inline form; afterwards the parser of the framework reads exactly the old configuration plus the new section.
L10 Secrets slip through a denylist (V2, twice)¶
- Problem: The runner gave the child process its environment without the blocked keys. Twice, secrets of the agent framework still got through.
- Lesson: The environment of child processes is an allowlist.
- Implementation: dev runner adapter (09-agents.md, baseline ruling on the Claude Code runner); 21-dev-isolation.md.
- Test: A canary variable in the environment of the adapter; the started fake process reports its environment; it contains only keys of the allowlist, the canary is missing.
L11 Automatically retrying aborted turns (V2)¶
- Problem: Restarting the turn after a crash would have executed side effects twice.
- Lesson: Never retry automatically; report as interrupted; the person decides.
- Implementation: run
failedwithstate_reason = stale(03-data-model.md); 09-agents.md. - Test: Adapter restart during a run: run
failedwithstale; a second start with the same invocation token yields409 already_started.
L12 Follow-ups during work (V2, busy mode of the framework)¶
- Problem: Messages during a turn, depending on the path, were lost, interrupted the turn or became a turn of their own. This was only visible through reactions.
- Lesson: A follow-up has a state of its own (waiting, handed over, taken up, deferred). Always set the mode of the framework explicitly, never inherit the default.
- Implementation:
run_followups,trigger_state = followup_delivered(04-sync-protocol.md, section 15.4); Hermes adapter withbusy_input_modesteerorqueue(baseline ruling on the Hermes adapter). - Test: A follow-up during a run goes through the states in order; follow-ups not taken up at the end of the run become a new run.
L13 Control commands as a prompt (V2)¶
- Problem:
stopandstatusmust never go to the model and must not wait behind a turn. - Lesson: Control commands are a type of their own.
- Implementation:
commands.control; 04-sync-protocol.md, section 15.4. - Test:
stopduring a run: immediatelycommand.invoked, no follow-up, runcancelled.
L14 Session ID known only after the start (V2)¶
- Problem: Without an ID assigned in advance, a run could not be mapped to any session after an early crash.
- Lesson: The session ID is assigned and stored before the first run; the first run creates, every further one continues.
- Implementation:
agent_sessions.external_session_refandstarted; dev runner with a session ID assigned in advance (baseline ruling on the Claude Code runner). - Test: Crash before the first reply; the next run continues the same ID and does not create a second one.
L15 Costs are cumulative per session (V2)¶
- Problem: The runtime reports costs only at the end of a turn and as the sum of the whole session. Adding them up counts twice.
- Lesson: Compute the delta yourself and mark it as an estimate; taken-over sessions need a baseline.
- Implementation:
cost_entries.is_estimate; 10-backend-policy.md; 22-import-migration.md, section 9. - Test: Two turns of the same session with cumulative values: the sum of the entries equals the last value minus the baseline.
L16 Continuing an old session after an import (V3)¶
- Problem: Imported threads should keep their old history. Switching without a message was not possible, switching twice would have shifted history, and a failure must not mute the person.
- Lesson: Store the reference at import, bind it on the first write like
/resume, exactly once with compare-and-swap, fail open with exactly one notice. If the old session is missing, a new one begins. - Implementation: 22-import-migration.md, section 9.
- Test: Two simultaneous first triggers bind exactly once; with a missing old session a new one is created, there is exactly one system message, and the message of the person reaches the agent.
L17 Tool writes when a flag is forgotten (V1, V3)¶
- Problem: V3 required one of two flags, V1 made the dry run the default. Both wanted to prevent an accidental invocation from creating anything.
- Lesson: The dry run is the default; contradictory options and an invalid selection abort before any call.
- Implementation: 22-import-migration.md, section 10; ADR 0043.
- Test: Test pattern 4.7; an unknown ID in
--only-threadaborts before any write.
L18 Idempotency through the target, not through a local file (V1, V3)¶
- Problem: A local state does not protect if it gets lost or a run aborts between creating and recording. V1 queried uncertain sends again by content comparison.
- Lesson: The origin key is unique in the target system;
pendingbefore creating,postedin the same transaction as the target. - Implementation:
import_items; 22-import-migration.md, section 7; ADR 0043. - Test: Abort between batch and cursor, restart on a different host without a local file: every item exactly once.
L19 Archived only with an explicit marker (V3)¶
- Problem: A thread archived between two fetches was in both lists; missing metadata was ambiguous.
- Lesson: Doubt means open. A thread that is wrongly open does no harm, a wrongly closed one hides work.
- Implementation: 22-import-migration.md, section 5.1.1.
- Test: A thread in the active and the archived list, a thread without metadata: both
open.
L20 Import without notification (V3)¶
- Problem: An import of old threads would have set off a flood of mentions and notifications.
- Lesson: Imported content is silent, without mention rows, and counts as read.
- Implementation: 22-import-migration.md, section 6.4.
- Test: After an import there is no push, no row in
mentions, andread_statesis set to the end of every imported thread.
L21 Half-finished agent output in parallel operation (V1)¶
- Problem: Agents edit their reply while they work. A message copied too early stays half-finished, because later edits are not carried over.
- Lesson: Settle rule: do not copy while a work marker is present or the last change is too recent; strictly in order per thread, stopping at the first unsettled message.
- Implementation: 22-import-migration.md, section 12.3; code piece 5.3.
- Test: A table of message sequences with marker, edit time and type; the expected result is the copyable beginning.
L22 Expiring CDN links and leaks in error texts (V1)¶
- Problem: Attachment URLs are signed and expire; error texts and error bodies could contain the signed URL or the token.
- Lesson: Only allowed hosts, only
GET, no token to the CDN, limit the size before reading, error texts only with scheme, host and path, on expiry read again once, otherwise a jump link. - Implementation: 22-import-migration.md, section 6.5; code piece 5.2.
- Test: Test pattern 4.3 against a fake CDN that mirrors query and token in error bodies.
L23 Auto title overwrites real titles (V3)¶
- Problem: The agent should give conversations a title, but never overwrite a title that a person chose. Short real names ("AI", "M365") looked like placeholders.
- Lesson: Title source as a field; automatic titles only with
auto; a placeholder rule with protected short names decides whether a human title starts asauto. - Implementation:
threads.title_source,thread.title.update(05-actions.md); code piece 5.1. - Test:
TestIsPlaceholder_ProtectedShortNamesininternal/chat/domain/title(a table test with placeholders and protected short names).
L24 New chat through a detour channel (V3)¶
- Problem: The platform had no "New chat" button. A plugin created a post from a message in a handoff channel and needed a state machine of its own against duplicates and orphans.
- Lesson: A new thread is a server function; no detour.
- Implementation:
thread.create; 01-product.md. - Test:
thread.createwith the sameclient_msg_idof the first message does not create a second thread on a retry.
L25 Proven only against fakes (V1, V2, V3)¶
- Problem: The input of a person on the third-party platform could not be checked live; several behaviors stayed "only checked against fakes".
- Lesson: On Bothy's own server, end-to-end tests run with test people and test agents; the coupling to the agent framework is checked against its real loader (test pattern 4.8).
- Implementation: 23-quality.md.
- Test: The E2E suite creates a test person and a test agent, sends, checks the run, live box and reply.
L26 Blocking connection on stop (V1)¶
- Problem: A blocking fetch held the process during shutdown longer than the service manager allowed.
- Lesson: Every connection of an adapter can be aborted; stopping ends within a fixed deadline.
- Implementation: adapter contract (09-agents.md); SDKs (ADR 0026).
- Test: A stop signal with an open connection; the process ends within the configured deadline.
4. Test patterns¶
4.1 Fake server with fault injection (V1, V3)¶
- Setup: An HTTP fake in the test process, only on
127.0.0.1, port assigned by the system, with a request log and targeted faults: return a status; store, then respond with a status or cut the connection ("stored, then 502"); store only after the response ("stored late"); rate limit until a point in time; delay. - Proves: Retries after uncertain delivery create no duplicate and no loss.
- Use: fake Bothy for SDKs, adapters and the import tool; fake Discord and fake CDN for the
import. In Go with
httptest, in Python with the standard library.
4.2 Scenario register with exactly one RESULT line (V1)¶
- Setup: Scenarios are registered functions. A test starts a scenario as a child process
with a clean environment; the scenario prints exactly one line
RESULT <json>. The test requires exit code0and exactly one such line and checks the JSON. Working rule: every behavior change gets a scenario that was red before. - Proves: Behavior in the real runtime context (foreign interpreter venv, real loaders) without test code in that context.
- Use: tests of the Hermes reference adapter and of the dev runner.
4.3 Secret leak test (V1, V3)¶
- Setup: The fake mirrors token, key and signed query in error bodies and error messages. The test captures all log lines at debug level, standard output, error output and reports and searches for the value and derived forms (Basic Auth encoding, prefixes).
- Proves: No secret appears in a log, output or error text, even in the error path.
- Use: server logs, SDKs, adapters, import tool.
4.4 Temp folder guard (V3, V1)¶
- Setup: A test starts all test modules in a child process with a fresh, empty
TMPDIRand requires that the folder is empty afterwards. It reads the module list from the directory, so that new modules are included without upkeep. A deliberately failing module (one failure, errors insetUpand in the test) proves the cleanup in the error path. - Proves: Tests leave no leftovers, even when they fail.
- Use: Python suites (SDK, adapters, tools); for Go tests the same guard through
make checkwith its ownTMPDIR.
4.5 Doc number guard (V1, V2)¶
- Setup: Numbers in the README and decisions come from named constants; a test compares them and finds bare numbers outside of code spans, dates and versions; backticks must come in pairs, otherwise the check reads code as prose.
- Proves: Docs and code name the same limits.
- Use: Bothy keeps limits only as configuration values with a default table per chapter and a configuration reference; the check (23-quality.md) requires that every named key is in the reference.
4.6 Dash guard (V1, V2, V3)¶
- Setup: All tracked text files are checked for the characters U+2013 and U+2014.
- Proves: The house rule holds without manual work.
- Use: pre-commit and
make check(23-quality.md).
4.7 Dry-run GET-only proof (V1, V3)¶
- Setup: The fakes count all methods; the expected result is exactly the set
{GET}. No registration or write endpoint appears in the log. A state folder that was missing before is not created; an existing one stays empty. - Proves: The dry run writes nowhere.
- Use:
bothy import discord(22-import-migration.md, section 16) and every other tool with a dry run.
4.8 Test against the real loading of the Hermes plugin (V1, V3)¶
- Setup: A child process in the Python venv of Hermes Agent with a throwaway
HOME, a throwawayHERMES_HOMEand its ownTMPDIR; the plugin lies as a link underHERMES_HOME/plugins; the configuration enables it by the manifest name; Hermes finds it through its real plugin discovery. Before and after the class, the test comparesgit status --porcelain --ignoredandgit diff HEADof the Hermes checkout. - Proves: The plugin loads in the real framework, and the tests do not change the framework.
- Use: Hermes reference adapter, against the Hermes version pinned in CI (09-agents.md, 23-quality.md).
5. Code worth porting¶
License note: The three pieces are the owner's own code from private predecessor repos without a third-party license; they use only the Python standard library. Adopting means rewriting in Go under the license of the product (Apache-2.0, DCO, ADR 0003), with newly written tests based on the old test cases. The old source code is not copied. Where the pieces are is recorded only in the ops repo.
5.1 Auto title placeholder rule (V3)¶
- Target: Go, module
chat; used when a person creates a thread and at import (title sourceautoormanual). - Rule: empty is a placeholder; only punctuation up to a small fixed length is a placeholder, emoji and symbols are not; after Unicode NFKC and case folding, a word from the placeholder list is a placeholder; some of them also with an appended number ("Test 2"). Short real names stay real ("AI", "M365", "C#", "PC 2").
- Adaptation: The word lists become data per language (German and English), not code; the fixed length becomes a named constant.
5.2 CDN fetch with a host list (V1)¶
- Target: Go, import tool.
- Rule: only
GET; one transport per allowed host; onlyhttps(V1 still allowedhttpin one check, this is fixed in the port); no redirects; no proxy from the environment; no token to the CDN; read limit of the maximum plus one byte and a check ofcontent-length;429with a wait limit; error texts only with scheme, host and path; the error body of the CDN is never shown. - Adaptation: Reading the message again on an expired signature (22-import-migration.md, section 6.5).
5.3 Settle rule (V1)¶
- Target: Go, read mirror of the import tool.
- Rule: A message is busy if it or its source message carries a work marker; unsettled if its last change is more recent than the settle time; the copyable part is the beginning up to just before the first busy or unsettled message; types that are not copied do not stop; a lookback on earlier messages catches late markers.
- Adaptation: Markers, settle time and lookback are configuration values (22-import-migration.md, section 19).
6. Explicitly not adopted¶
| Concept | Reason |
|---|---|
| Register for renaming and moving conversations | Bothy has fixed thread IDs (L01). |
| Edit window with logical message IDs | No edit deadline for agents; the live box is a run (L07). |
| Gap model, holding back and notices about late messages | Commit-ordered sequence and cursor (L03). |
| Name folding in the style of the third-party server | No identity by name (L01). |
| Re-querying uncertain sends by content comparison | Idempotency key and origin key (L02, L18). |
| Header line "name and time" in copied messages | Bothy stores the original author and original time; origin is origin. |
| Splitting long messages and condensing the live box | Large message limit; run document instead of a text bubble. |
| Status reactions as signs | trigger_state with icons; a status is not a reaction. |
| File inbox with folders for waiting, in progress and seen | Event log, consumer cursor and ack. |
| Intercepting platform slash commands in the plugin | Commands are server objects (commands). |
| Handoff channel for new chats | thread.create (L24). |
| Own Discord REST layer for replies | Bothy never writes to Discord; replies go through its own API. |
| Copy of the conversion from a mirror tool | The import converts Discord formatting itself (22, section 6.3). |
7. Third-party data¶
| Data | Where | Source and licence |
|---|---|---|
Truncated SHA-256 values of common passwords (common_passwords.txt, M1-39) |
services/server/internal/identity/adapters/passwordpolicy |
SecLists Passwords/Common-Credentials/10k-most-common.txt at commit 3a5f62f0e18a1b8a36b25399aff0077f3706e5af, MIT licence, Copyright (c) 2018 Daniel Miessler (https://github.com/danielmiessler/SecLists). The repository holds only the first 16 hex characters of the SHA-256 of each normalised entry, no word list; source, checksum and generation command are in the adapter README. |