Skip to content

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

  1. 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).
  2. 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.
  3. 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.id stays the same when moving; agent_sessions per 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_sessions row stays current, the next run continues the same external session.

L02 Uncertain delivery creates duplicates (V1, twice)

  • Problem: On a timeout, 500, 502 or 504 the 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.invoke rule 1.
  • Test: Messages with origin agent, app, import and system in a channel with free_response create 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_bindings in 03-data-model.md; strict schema in 06-api.md.
  • Test: channel.behavior.update with an unknown field yields 400.

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_bytes stays 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: open and closed as 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; [] and null mean 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 failed with state_reason = stale (03-data-model.md); 09-agents.md.
  • Test: Adapter restart during a run: run failed with stale; a second start with the same invocation token yields 409 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 with busy_input_mode steer or queue (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: stop and status must 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: stop during a run: immediately command.invoked, no follow-up, run cancelled.

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_ref and started; 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-thread aborts 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; pending before creating, posted in 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, and read_states is 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.
  • 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 as auto.
  • Implementation: threads.title_source, thread.title.update (05-actions.md); code piece 5.1.
  • Test: TestIsPlaceholder_ProtectedShortNames in internal/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.create with the same client_msg_id of 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 code 0 and 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 TMPDIR and 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 in setUp and 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 check with its own TMPDIR.

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 throwaway HERMES_HOME and its own TMPDIR; the plugin lies as a link under HERMES_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 compares git status --porcelain --ignored and git diff HEAD of 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 source auto or manual).
  • 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; only https (V1 still allowed http in 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 of content-length; 429 with 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.