22 Import and migration from Discord¶
Status: draft M0. Normative for the import tool, the import interface of the server and the
migration flow. Terms: 00-glossary.md. Tables import_sources and
import_items: 03-data-model.md, section 4.11. Actions import.read
and import.manage: 05-actions.md, section 4.12. Decision on the design:
ADR 0043. Lessons of the
predecessors: REUSE.md.
All limits, windows and intervals are named configuration values. Their default values are only in section 19.
1. Goal and boundaries¶
An organization that uses a Discord server with agent bots today moves to Bothy channel by channel, without loss and without duplicates. Afterwards the old history is in Bothy, with the original time and the original author, and every thread can continue its earlier agent session.
In scope:
- One-time import of the history (categories, text channels, forums, threads, forum posts, messages, attachments, pins, forum tags).
- Read mirror for parallel operation: new Discord messages are pulled into Bothy, only in this direction.
- Switching over per channel and reverting per channel.
- Binding of imported threads to earlier agent sessions.
Out of scope:
- Writing to Discord. The tool calls Discord read-only (section 4).
- Taking over Discord roles and channel permissions as grants. Bothy has no roles (Baseline section 4); the owner assigns grants deliberately (section 5.1).
- Voice and stage channels, reactions with the people who reacted, keeping edits and deletions in sync after copying (sections 6.6 and 12.4).
- Big bang. There is no step that switches over all channels at once.
2. Design¶
The import is a separate process outside the server: the command bothy import discord of the
command-line tool bothy. It reads Discord and writes through the public API of the
server, like any other client. The server never calls Discord.
Reasons (in detail in ADR 0043):
- The server never loads external URLs (Baseline section 4, "Deliberately never": SSRF). Only the tool loads attachments from the Discord CDN.
- Extensions never run in the server process (B6). The Discord token is never held by the server.
- The dry run provably writes nothing, neither in Discord nor in Bothy nor locally.
- Idempotency and progress are nevertheless kept in the server (
import_items,import_sources.cursor). An aborted run resumes on any machine.
Whether bothy is the same Go binary as the server is decided by 02-architecture.md;
only the process separation matters.
3. Flow overview¶
sequenceDiagram
autonumber
participant O as Owner
participant T as bothy import discord
participant D as Discord API and CDN
participant S as Bothy server
O->>T: Dry run (default)
T->>D: GET only: structure, threads, messages
T->>S: GET only: people, agents, spaces, channels
T-->>O: Check report without content, nothing written
O->>S: Create and start the source in the web console (import.manage, step-up)
O->>T: --apply
T->>S: GET only: source of this guild in state running
loop per channel, per thread, in Discord order
T->>D: GET message page after cursor
T->>D: GET attachments from the CDN (host allow-list)
T->>S: File items with origin key
T->>S: Batch with origin keys (import.apply)
S->>S: per item: pending, create target, posted
T->>S: Advance cursor
end
T-->>O: Check report
O->>T: --follow --apply (read mirror)
O->>S: Switch over or revert per channel (section 13)
4. Discord access: read only¶
4.1 Endpoints¶
The tool knows only these calls. The Discord client in the code has no method for any verb other
than GET (test in section 16).
| Purpose | Call | Source |
|---|---|---|
| Check token, own bot ID | GET /users/@me |
[R1] |
| Server name for the report | GET /guilds/{guild.id} |
[R2] |
| Categories and channels (without threads) | GET /guilds/{guild.id}/channels |
[R2] |
| Active threads of the server | GET /guilds/{guild.id}/threads/active |
[R2] |
Archived public threads per channel, page by page with before |
GET /channels/{channel.id}/threads/archived/public |
[R3] |
Archived private threads per channel; needs MANAGE_THREADS, otherwise a note and skip |
GET /channels/{channel.id}/threads/archived/private |
[R3] |
| One channel or thread (check the state in the read mirror) | GET /channels/{channel.id} |
[R3] |
| Messages forward from the cursor | GET /channels/{channel.id}/messages?after=…&limit=… |
[R4] |
| Messages before an ID (settle rule in the read mirror) | GET /channels/{channel.id}/messages?before=…&limit=… |
[R4] |
| Re-read one message (fresh attachment URL) | GET /channels/{channel.id}/messages?around=…&limit=… |
[R4] |
| Attachments | GET on hosts from import.discord.cdn_hosts |
[R5] |
Deliberately not used: GET /guilds/{guild.id}/members, because it requires the privileged intent
GUILD_MEMBERS [R2, verified]. Authors are in every message object; the mapping needs nothing
more. Also not used: the pin endpoint; the field pinned on the
message object is enough [R4, verified].
Archived threads come newest first by archive_timestamp [R3, verified]. The next page
asks with before equal to the archive_timestamp of the last thread; threads are deduplicated
by their ID; a page without a new ID ends the query, so that the same page never comes endlessly.
The base URL is import.discord.api_base. Every request carries a User-Agent in the format that
Discord requires for bots [R6, verified]. The bot token goes only to the API base, never to CDN hosts.
4.2 Bot, permissions, intents¶
The import uses a separate Discord bot account, not the token of a productive agent bot. Reasons: Discord counts rate limits per bot [R7, verified], so an import would otherwise slow down the answers of the running agents; and the import account needs only read permissions.
- Channel permissions:
VIEW_CHANNELandREAD_MESSAGE_HISTORY[R4, verified];MANAGE_THREADSonly if archived private threads are to be taken over [R3, verified]. - Intent:
MESSAGE_CONTENTmust be enabled in the developer portal. Without it, Discord delivers empty values incontent,embeds,attachments,componentsandpoll, also over REST [R8, verified]. For bots below the verification thresholds, the switch in the portal is enough [R8, verified]. - The tool reads the token from the environment (
BOTHY_IMPORT_DISCORD_TOKEN) or from a file (--discord-token-file). It never appears in output, report, error text or log (test in section 16).
4.3 Rate-limit guard¶
Discord reports limits through headers and 429 with retry_after [R7, verified]. The tool:
- Waits
X-RateLimit-Reset-Afterbefore the next call ifX-RateLimit-Remainingequals0. - On
429, waits the time fromretry_after(body) orRetry-After(header) and retries at mostimport.discord.max_429_retriestimes. - Aborts if a requested wait time is greater than
import.discord.max_rate_wait. The state remains; a new run resumes. - Never retries
401and403. A403on a channel ends all further calls for this channel in the run. Reason: Discord temporarily blocks an IP after too many responses with401,403or429[R7, verified]. - Likewise respects
429of the Bothy server (headerRetry-After).
4.4 Preflight check¶
Before every run, including the dry run:
GET /users/@mesucceeds; the token is a bot token.- The server is readable; every category and every mapped channel is visible.
- Intent probe: among the first
import.discord.preflight_samplemessages by other authors in a readable channel, at least one has content, an attachment or an embed. Otherwise abort with the note thatMESSAGE_CONTENTis probably missing. Reason: without this probe, a complete import of empty messages would result. - The Bothy session belongs to an active owner.
- Every target person and every target agent of the mapping file exists.
5. Mapping¶
5.1 Structure¶
| Discord (type number [R3, verified]) | Bothy | Rule |
|---|---|---|
GUILD_CATEGORY (4) |
space (space) |
create a new space or use an existing one (mapping file) |
GUILD_TEXT (0), GUILD_ANNOUNCEMENT (5) |
channel kind = text with main thread |
messages outside of threads go into the main thread |
GUILD_FORUM (15), GUILD_MEDIA (16) |
channel kind = forum |
tags from available_tags become forum_tags |
PUBLIC_THREAD (11), PRIVATE_THREAD (12), ANNOUNCEMENT_THREAD (10) |
thread kind = thread |
see 5.1.1 |
GUILD_VOICE (2), GUILD_STAGE_VOICE (13) |
nothing | counted as skipped in the report |
| Channel without category | space according to defaults.uncategorized_space |
otherwise skipped |
Rules:
- The tool only creates. It never deletes, archives or renames items in Bothy. It uses an existing space or channel only if the mapping file names it explicitly. If a new name collides with an existing name that the import did not create, the run aborts before the first write.
- New channels get no grants. They are thus visible only to owners ("The default is
nothing"). Channels with
permission_overwritesin Discord are marked in the report so that the owner sets grants deliberately. - New channels are read-only in the read mirror (section 12.1). Only the switch-over sets the operator (section 13.3).
- Order: spaces and channels in Discord order (
position), assort_key.
5.1.1 Threads¶
- A thread created from a message in a text channel has the same ID as its starter message
[R3, verified]. In Bothy it becomes a thread with
parent_message_idequal to the imported starter message in the main thread. If that message is missing,parent_message_idstays empty. - A forum post becomes a thread in the forum channel; its start message is the first message.
- The title is the thread name, shortened to
thread.title_max_chars. If the name is a placeholder according to the auto-title rule (REUSE.md, section 5.1),title_source = auto, otherwisemanual. threads.created_atandlast_activity_atcome from the source (time in the snowflake of the thread ID or of the last message, respectively) (03, section 4.11).- Done: a thread becomes
closedifthread_metadata.archivedis exactlytrueand it is not in the list of active threads, or if it carries a tag fromimport.discord.closed_tags.closed_atisarchive_timestamp. Missing metadata,falseor a thread in both lists count as open. Reason: a thread wrongly left open does no harm; a thread wrongly closed hides work. applied_tagsbecomethread_tagsup tothread.max_tags; further tags are dropped, so the tag set of the thread stays editable (M8-11).threads.operator_agent_idcomes fromplanned_operatorof the channel in the mapping file, not from the effective operator (empty in the read mirror) (03,threads.operator_agent_id). Reason: R-DM-12 pins the operator; without this rule every imported thread would stay without an agent forever after the switch-over.
5.2 Mapping file¶
A YAML file of the owner, schema import_source.discord.v1. The server stores it without
secrets in import_sources.config. bothy import discord init-map generates a skeleton from the
Discord structure and the authors that the dry run has seen; the owner edits it.
version: 1
guild_id: "<guild_id>"
defaults:
unmapped_human: placeholder # placeholder | skip
unmapped_bot: placeholder # placeholder | skip
uncategorized_space: skip # skip | <space key from spaces>
spaces:
- discord_category: "<category_id>"
target: {create: {name: "Support"}} # or {existing: "<space_uuid>"} or skip
channels:
- discord_channel: "<channel_id>"
target: {create: {name: "tickets"}} # space follows the category
planned_operator: "agent:support-bot" # or none
lead: discord # discord | bothy (section 12.1)
- discord_channel: "<channel_id>"
target: skip
users:
- discord_user: "<user_id>"
target: "user:alex" # user:<handle> | agent:<handle> | placeholder
- discord_user: "<bot_user_id>"
target: "agent:support-bot"
Channels that are not named are skipped and counted in the report. An error in the file
(unknown field, unknown handle, duplicate Discord ID) ends the run before any call with
exit code 2.
5.3 People and placeholders¶
Every author becomes a principal in Bothy:
| Discord author | Target | Rule |
|---|---|---|
Person with entry user:<handle> |
existing person | original author |
Bot with entry agent:<handle> |
existing agent | original author; the message nevertheless has origin = import |
Without entry or placeholder |
placeholder person | see below |
| Webhook author | placeholder per webhook ID | as without entry |
Placeholder: a principal kind = user, status = disabled, without identity, without grants,
display_name from global_name or username, handle imported-<name> with a counter on
collision. It can never log in and never trigger. Origin: import_items with
item_kind = user; the interface shows it as "imported". Placeholders are personal
data of the Discord users (section 14).
Mentions of people become text (@Name), not rows in mentions. Reason: a
mention creates notifications and counters; an import creates none (section 6.4).
5.4 Agents¶
The tool never creates agents and never sets operators or backend policies. Those are
owner actions with step-up and I1 check (agent.create, channel.operator.set). The
mapping file names only existing agents.
6. Messages¶
6.1 Types¶
The Discord types DEFAULT (0), REPLY (19), CHAT_INPUT_COMMAND (20),
THREAD_STARTER_MESSAGE (21) and CONTEXT_MENU_COMMAND (23) are taken over [R4, verified]. All
others (system messages such as a pin notice or "thread created") are counted and skipped. Type 21
is dropped if its starter message is already in the main thread. Empty messages without attachment,
embed, sticker and poll are counted and skipped.
6.2 Fields¶
Bothy field (messages) |
Value |
|---|---|
author_principal_id |
mapped principal (5.3) |
origin |
always import; never triggers an agent (05-actions.md, section 5) |
kind |
text |
created_at |
timestamp of the source |
edited_at |
edited_timestamp of the source; no revisions |
silent |
true |
trigger_state |
empty |
reply_to_message_id |
for type 19, if the referenced message is imported in the same thread; otherwise a quote line in the content, shortened to import.reply_quote_max_chars |
import_item_id |
row in import_items |
client_msg_id |
generated by the server; idempotency is carried by the origin key (section 7) |
thread_seq |
appended in Discord order (ascending ID) |
The display order comes from thread_seq, never from created_at (04-sync-protocol.md,
section 10). A thread is therefore appended strictly in Discord order.
6.3 Converting content¶
The target is the Markdown dialect content_format = 1. What cannot be mapped stays as
readable text.
| Discord | Bothy |
|---|---|
<@USER_ID> [R6, verified] |
@DisplayName as text |
<@&ROLE_ID> |
@RoleName as text |
<#CHANNEL_ID> |
[#name](/c/<channel_id>) with the in-app path of 15-client-architecture.md section 11 when the channel is posted in this source (channel_id of Bothy), otherwise #name |
<:NAME:ID>, <a:NAME:ID> |
:NAME: |
<t:TIMESTAMP:STYLE> (seconds) |
time as ISO 8601 text in UTC |
| Embeds | quote block with title, link and description; images in embeds are not loaded |
| Sticker | [Sticker: name] |
| Poll | question and answers as a list with the note "Poll" |
Forwarded message (message_snapshots) |
quote block with the content of the snapshot |
Small-text prefix -# at the start of a line |
removed |
The length of a Discord message is far below message.max_content_bytes. A message is
never split; the origin key needs no part index. A converted message that is longer all the same
is counted as a failed message in the report and not imported.
6.4 No notifications, no unread backlog¶
messages.silent = true, nomentions, no push, notrigger_state.- The server writes one
thread.updatedper batch and thread instead of onemessage.createdper message (04, section 15.8). Clients load the thread through REST when they display it. Reason: an import can write a multiple of the normal traffic; clients should not receive every message individually. - The server sets
threads.unread_floor_seqto the end of the batch; imported content thus counts as unread for no person with read permission (03,read_states). Reason: imported content is old; it should not light up as unread. One row per thread instead of one per reader keeps the batch cheap. - Agent adapters get no
agent.invocationand noagent.followupfrom imported messages, becauseorigin = importnever triggers.
6.5 Attachments¶
For every attachment:
- If
sizeis greater thanimport.attachment_max_bytes, it is not loaded. - Otherwise the tool loads
urlwithGET, only if the scheme ishttpsand the host is inimport.discord.cdn_hosts. Without the bot token, without redirects, without a proxy from the environment, with a read limit ofimport.attachment_max_bytesplus one byte, timeoutimport.http_timeout. - CDN URLs of attachments are signed and expire (
ex,is,hm) [R5, verified]. If the CDN answers with403or404, the tool re-reads the message once (fresh URL) and tries once more. - Error texts show only scheme, host and path; query and fragment are masked. The error body of the CDN is never output, because it can contain the signed URL.
- Success: upload through the import interface (section 8) into the target channel; the file goes through the normal virus scan (12-files.md); the author is the author of the message.
- If it fails or the attachment is too large: the message gets a line with file name,
size, reason and jump link
https://discord.com/channels/{guild_id}/{channel_id}/{message_id}. The jump link does not expire [derived: it contains no signature]. The report counts the case.
6.6 Pins, tags, reactions¶
- Pins: messages with
pinned = true[R4, verified] are pinned in Bothy;pinned_byis the importing owner. - Forum tags:
available_tagsbecomeforum_tagsof the channel (matched by name, without duplicates);applied_tagsbecomethread_tags. - Reactions are not taken over. Discord delivers only counters on the message object; who reacted costs one call per emoji and message. Bothy stores reactions per person. Status markers of agents would not be a reaction anyway (Baseline section 4). The report counts reactions that were not taken over.
7. Idempotency and resuming¶
7.1 Origin key¶
(source_id, origin_key) in import_items is unique. The Discord server is fixed by the source
and is not part of the key.
item_kind |
origin_key |
|---|---|
space |
space:<category_id> |
channel |
channel:<channel_id> |
forum_tag |
forum_tag:<channel_id>:<tag_id> (a tag ID is unique only within its forum in the data the tool reads) |
thread |
thread:<thread_id> |
message |
message:<message_id> |
file |
file:<attachment_id> |
user |
user:<user_id> (also bots and webhooks) |
pin |
pin:<message_id> |
Discord IDs are snowflakes and always strings in the HTTP API [R6, verified]; the tool checks that they consist only of digits.
7.2 pending and posted¶
The server executes every item like this (03-data-model.md, import_items):
- If
(source_id, origin_key)exists withposted, it answers with the existing target. No duplicate, no event. - Otherwise it writes
pendingand, in the same transaction, creates the target and setsposted. - Files:
pendingreserves the file ID before the bytes are uploaded; an aborted upload with the same key reuses the same ID.postedfollows when the bytes are complete. content_sha256stores the hash of the converted content. A later run that sees a different hash counts "changed at the source" in the report and changes nothing.
A posted thread applies the changes of section 12.2 step 3; every other posted item answers with
its target and changes nothing. A posted thread item thus renames the thread only while its
title_source is auto, closes it with closed_at or reopens it (clearing closed_at and
closed_by), and moves last_activity_at forward; it never changes the operator, the parent
message or the tags (M8-11).
The answer to such a key carries the stored item with the member source_changed = true
(ImportItem), and the server raises its counter server.source_changed in
import_sources.stats; nothing else changes and no other event is written (M8-03). A key that
exists is locked before it is read, so a concurrent batch with the same key waits for the commit of
the other (section 7.4). A batch whose items all existed already writes no event at all, unless its
posted thread item applies a mirror change (section 12.2 step 3), which writes one thread.updated
and one import.progress.
7.3 Cursor¶
import_sources.cursor holds, per Discord channel and thread, the highest fully taken-over
message ID. The tool advances it only after a successful batch. A new run
starts behind the cursor; 7.2 prevents duplicates at the edge. The tool owns the layout of the
object; the server stores it as sent (setImportCursor replaces cursor and counters). Besides the
progress it holds the run lease of section 7.4 (member run with a run ID and the time of the
last renewal).
7.4 Two runs at the same time¶
Two writing runs of the same source are correct (every item at most once, in order per thread,
because each run processes a thread strictly sequentially and a competing
key waits for the commit of the other), but wasteful. A writing run therefore holds a lease in
the cursor (section 7.3) and renews it at least every import.follow.poll_interval; the tool
does not start if another run's lease is younger than import.lease_stale_after, except with
--takeover, and a run that finds the lease taken over by another run stops writing. The source state running does not mean that a run is active: the owner starts
the source in the web console (section 8), and it stays running between runs.
8. Import interface of the server¶
Binding is docs/api/openapi.yaml (tag Import); this table reflects the state of the spec.
All routes are for owners only.
| Operation | Route | Action | Step-up |
|---|---|---|---|
Create source (kind = discord, mapping) |
POST /api/v1/imports |
import.manage |
yes |
| Start or resume, pause, cancel, complete source | POST /api/v1/imports/{import_id}/start, /pause, /cancel, /complete |
import.manage |
yes |
Change ownership list per channel (lead, also sets the read-only flag) |
PUT /api/v1/imports/{import_id}/leads |
import.manage |
yes |
| Read source, progress, items by state | GET /api/v1/imports, /imports/{import_id}, /imports/{import_id}/items |
import.read |
no |
| Write a batch of items with origin keys (section 7.2) | POST /api/v1/imports/{import_id}/batches |
import.apply |
no |
| Create a file item with origin key; the bytes follow through the upload session (12-files.md) | POST /api/v1/imports/{import_id}/files |
import.apply |
no |
Upload one part of the file of a file item (rules of uploadPart) |
PUT /api/v1/imports/{import_id}/files/{file_id}/parts/{part_number} |
import.apply |
no |
Complete the upload of a file item; the item becomes posted (rules of completeUpload) |
POST /api/v1/imports/{import_id}/files/{file_id}/complete |
import.apply |
no |
| Update cursor and counters | PUT /api/v1/imports/{import_id}/cursor |
import.apply |
no |
There is no dry run on the server: the tool only reads (section 10.2) and creates no source while doing so.
import.apply requires: the principal is an active owner, the source is in state running, every
target lies in a channel of this source. No step-up per batch, because the start requires step-up
and a read mirror runs for days. A batch concerns exactly one thread and has at most
import.batch_max_items items; it is one transaction. Structure items (spaces, channels,
forum tags, people) come in separate batches without a thread. Every batch writes import.progress
and thread.updated (section 6.4). Start, cancellation and completion are recorded in the audit log.
States and answers of the interface (M8-03): startImport moves draft or paused to
running and sets started_at on the first start; pauseImport moves running to paused;
cancelImport moves draft, running or paused to failed with the counter
server.cancelled = true (there is no state cancelled); completeImport moves running or
paused to completed only when every mapped channel whose target is not skip and whose channel
item is posted has lead = bothy (else 409 conflict with reason lead_not_bothy). Repeating
the transition that already happened (start on running, pause on paused) answers the source
unchanged without an event; every other transition is 409 conflict with reason
import_state. setImportLead answers 404 for a channel the mapping does not name or maps to
skip, 409 conflict with reason channel_not_imported while its channel item is not posted
and import_state for a completed or failed source. applyImportBatch and setImportCursor
answer 409 conflict with reason import_not_running for a source that is not running (also
paused); a batch target outside the channels of the source is 409 conflict with reason
target_outside_source, a new space or channel whose name a live object holds 409 conflict with
reason name_collision. Each refusal of import.apply is recorded in the audit log as a denial
with reason import_not_running. The mapping of section 5.2 is the schema
ImportSourceDiscordConfig; the data of the structure items follows ImportSpaceData,
ImportChannelData, ImportForumTagData and ImportUserData (docs/api/openapi.yaml).
Content items (M8-11): a batch with thread_origin_key (thread:<id>, or channel:<id> for the
main thread of a posted text channel; a forum has none, 400 validation_failed) holds the
optional thread item of that key first (ImportThreadData), then message items
(ImportMessageData) and pin items (ImportPinData) in source order; any other item kind there,
and a content item in a batch without thread_origin_key, is 400 validation_failed at its
item_kind. A thread that is neither posted nor created by the batch is 409 conflict with
reason thread_not_imported; a message or file whose author item is skipped or missing is
409 conflict with reason author_not_imported. A file item comes through
createImportFile (ImportFileCreate with sha256), its parts and completion through
uploadImportFilePart and completeImportFile, which authorize import.apply instead of
file.upload: uploadPart and completeUpload are write actions that rule 1.8 refuses in a
read-only mirror channel, while 05 4.12 puts the files of an import under import.apply. setImportCursor replaces cursor and
merges the members of stats into stats.tool; the server's own counters are under
stats.server. getImport and listImports return the stored cursor.
The command line logs in as a device with platform = cli through device pairing
(bothy import login, 08-identity.md sections 5.3 and 6.4). Such a session
can never provide step-up, so every step that needs import.manage (create a source with its
mapping file, start, pause, cancel, complete, ownership list) is done by the owner in the web
console (01-product.md section 3.7, row "Import from Discord"), and so are
channel.operator.set and channel.read_only.set. The tool uses only import.read,
import.apply and read actions.
9. Binding to earlier agent sessions¶
An imported thread can continue the session that it had in the old environment
(example: session ID of Hermes Agent or Claude Code). The flow follows the behavior of a
/resume: take over the reference, bind on the first write, exactly once, with compare-and-swap,
without blocking in the error case.
- Take over the reference.
--sessions <file>provides JSON lines{"discord_thread": "<thread_id>", "agent": "<handle>", "external_session_ref": "<ref>"}. The import creates oneagent_sessionsrow per line:is_current = false,started = true,ended_at= import time,end_reason = import. Ifagentdiffers from theoperator_agent_idof the thread, the row is dropped and counted in the report. The tool checks the whole file before any write (members exactly these and the two of step 7,discord_threaddigits and named once,agentan agent of the server, the reference a non-empty display text of at most the largestagent.session_ref_max_bytes; a violation ends the run with exit2and names the line, never the reference); a line whose thread is not part of the run is ignored and counted in a note, a line whose agent is not theplanned_operatorof the thread's channel is dropped and counted as discarded. An accepted line travels with the thread item asearlier_session(ImportThreadData, with the agent's UUID): the server writes the row in the batch transaction, also for a thread that an earlier run posted (a later run with the file reaches it), and answerssessionon the item:taken_over,already_present(a row of the same thread, agent and reference withend_reason = importexists, so a repeated run or a second file never adds a row) ordropped_late(step 6). A dry run validates and counts without writing;--followsends the line with the thread item of a new thread. - Bind on the first trigger. When a person triggers an agent in the thread for the first time
and the thread has had no current session since the import, the server creates, in the
transaction of the run, a current row with the same
external_session_ref. Auditagent_session.bind, eventagent_session.changed. - Compare-and-swap. The partial index "one current session per thread" lets only one of two simultaneous triggers bind; the other sees the bound row. The partial index "an external session belongs to at most one thread" prevents an old session from hanging on two threads; then step 5 applies.
- Resume in the adapter. The invocation message carries the session with
started = true; the adapter resumes instead of starting fresh. It may resolve the reference (example: Hermes follows a compression chain to the live end) and reports the valid reference withagent_session.reportunder a version check. - Fail open. If the runtime no longer knows the old session or the binding fails,
the adapter starts a new session and reports this. The server ends the bound row
(
end_reason = rebind), creates the new one and writes exactly one system message into the thread: the earlier session no longer exists; the agent starts without the old history. The person's message is never lost. - Never twice. An import row is bound only as long as the thread has had no
current session since the import. A later reset (
agent_session.reset) never binds again. - Cost. Runtimes that report cumulative session costs need the state before the
binding as a baseline, otherwise the first run books the old costs again. For this, the file may
carry
cost_baseline_microsandcurrencyper line (03,agent_sessions;currencyis required when the baseline is above 0). The bound session carries both to the adapter in the session object ofagent.invocation(AgentSession, present when the baseline is above 0), and an adapter that reports differences of session sums starts from it (09-agents.md 4.10 and 18.7).
Who may bind is governed by agent.invoke: the binding arises only from a permitted trigger
(level trigger, I2).
10. Tool¶
10.1 Commands¶
| Command | Effect |
|---|---|
bothy import login --server <url> |
logs the tool in as a cli device through device pairing; the owner approves the pairing code in the app or the web console; writes only the session file of the tool |
bothy import logout |
ends the session on the server and deletes the session file |
bothy import discord --guild <id> --map <file> |
dry run: only GET against Discord and Bothy, writes nothing, not even a local file (exceptions of the login in rule 5 of 10.2); prints the check report |
… --apply |
imports into the source of this guild that the owner created and started in the web console (section 8); without such a running source exit 2 |
… --apply --follow |
read mirror as a loop (section 12); without --apply one reading round per interval with "would copy" |
… --apply --once |
exactly one round of the read mirror; for scheduled calls |
bothy import discord init-map --guild <id> --out <file> |
writes a skeleton of the mapping file (only this file) |
bothy import discord switch --guild <id> --map <file> --channel <id> --discord-locked |
last mirror round and settle check of the switch-over (section 13.3, step 2); --discord-locked is the owner's statement that step 1 is done, without it exit 2 before any call; exit 1 with the counts per reason while the settle rule holds anything back or messages after the cursor were not copied; otherwise prints the owner's steps 3 and 4 for the web console |
bothy import discord revert --guild <id> --channel <id> |
only reads Bothy (no lease, no Discord): prints per Bothy thread of the channel the number of messages written in Bothy (origin other than import, kind other than system, not deleted), the total and the owner's steps of reverting (section 13.4) |
bothy import discord status --guild <id> |
source, state, ownership list (per mapped channel the Discord channel ID, the lead, the Bothy channel ID, its read-only state, and operator ok, operator differs from planned or no operator; a lead that disagrees with the read-only flag is flagged inconsistent), counters; no names of channels, spaces or people |
Further options: --channel <id> (repeatable) narrows the scope; --only-thread <id> (repeatable)
selects exactly these threads; --sessions <file> (section 9); --report text|json;
--takeover (7.4); --discord-token-file <path>; --config <file> (values from section 19);
--session-file <path> (the session file of bothy import login, below).
Session file: bothy import login stores device ID, DPoP key (Ed25519) and token pair in
<user configuration directory>/bothy/import-session.json (os.UserConfigDir); every
bothy import command takes --session-file <path> instead. The file has mode 0600 in a
directory with mode 0700; a file or directory that group or others can read or write is refused
(on Windows the file lives in the user profile and the mode check is skipped). It is written
atomically (temporary file in the same directory, fsync, rename). Every refresh holds an
exclusive lock of <file>.lock and reads the file again after taking it, so two tool processes
never rotate the same refresh token (08-identity.md section 7.4); the new
refresh token is stored before the new access token is used. bothy import logout deletes the
session file under that lock and keeps the lock file. A missing file means "not logged in". No
output, log line or error carries a token, the key or the content of the file.
10.2 Rules¶
- Dry run is the default. A forgotten switch only reads.
- An unknown or non-matching ID in
--channelor--only-threadends the run before any write; valid IDs of the same call are then not processed either. - Contradictory options (
--followwith--once,--only-threadwith--follow) end with exit code2without a call to Discord or Bothy. - Output never contains message content, file names of attachments, tokens or signed URLs.
- The only call of a dry run to Bothy that is not a
GETisrefreshTokenwhen the access token of the session has expired, and the only file it may write is the session file ofbothy import login, to store the rotated refresh token (otherwise the next run would reuse a consumed token and revoke the session, 08-identity.md section 7.4). Neither is import data.
10.3 Exit codes¶
| Code | Meaning |
|---|---|
0 |
done, or dry run without errors |
1 |
run aborted (network, rate limit above the limit, server error); the state remains, a new run resumes |
2 |
invocation, mapping file or preflight check faulty; nothing written |
3 |
another run holds the lease of the source (without --takeover, section 7.4); with --once the normal case for scheduled calls |
11. Check report¶
One row per channel, a total at the end. No content, no names of people. A dry run says "would
take over" where an --apply run says "taken over".
| Column | Meaning |
|---|---|
| Channel | Discord name and target in Bothy |
| Threads | open, done |
| Messages | taken over, already present, skipped by reason (type, empty) |
| Attachments | taken over, too large, failed |
| People | mapped, placeholders |
| Sessions | references taken over, already present, discarded (agent does not match), late (the thread has had a session since the import) |
| Pins, tags | taken over |
| Reactions | not taken over (number) |
| Notes | 403, Discord permissions restricted, changed at the source, written in Discord after the switch-over |
The report is also stored in import_sources.stats and can be retrieved with import.read: the
totals as counters under stats.tool, written with the last setImportCursor of an --apply run
(a dry run writes nothing).
--report json prints the same report as one JSON object: version (1), mode (dry run or
apply), server (the name of the Discord server), channels (one object per channel with
channel, target, counts and notes), total (the counts of all channels),
channels_skipped (channels not taken over by reason) and notes. counts has the members
threads_open, threads_done, messages_taken, messages_present, messages_skipped (by
reason: type, empty, starter_in_main, author), messages_failed, messages_held (held back
by the settle rule of a read mirror round, 12.3; 0 in a full run), attachments_taken,
attachments_too_large, attachments_failed, people_mapped, people_placeholders,
sessions_taken, sessions_present, sessions_discarded, sessions_late, pins_taken,
tags_taken, reactions_not_taken and source_changed. A dry run counts an accepted line of a
thread in scope under sessions_taken ("would take over"); lines of the sessions file whose thread
is not part of the run appear as one note with their count. The server keeps its own counters
server.sessions_taken_over, server.sessions_already_present and server.sessions_dropped_late.
12. Read mirror (parallel operation)¶
12.1 Ownership list¶
Every mapped channel has exactly one leading place in the source, lead:
lead |
Discord | Bothy |
|---|---|---|
discord |
people and agents work there | channel read-only; the mirror copies new messages |
bothy |
read-only (step of the owner in Discord), agent removed there | channel writable, operator set; no mirror |
Read-only in Bothy means: no principal, not even an owner, may create messages,
threads, reactions or interactions in the channel; only import.apply writes (channels.read_only,
03; 05 rule 1.8). The flag is set by import.manage with the ownership list; by hand it is set by
channel.read_only.set, which refuses the imported channels of a source that is neither completed
nor failed, so the ownership list and the flag never disagree. As a result, no agent can be triggered in the mirror, and a
channel never has two leading places.
12.2 Round¶
Every import.follow.poll_interval:
GET /guilds/{guild.id}/channels(containslast_message_id) andGET /guilds/{guild.id}/threads/active.- Only channels with
lead = discordand alast_message_idnewer than the cursor are read. - New threads become threads; renamed threads change the title only with
title_source = auto; archived ones becomeclosed, active-again onesopen(rule from 5.1.1; if a thread is missing from the active list,GET /channels/{channel.id}confirms the state). - Channels with
lead = bothyin which Discord nevertheless shows new messages appear as a warning in the report. Nothing is copied. - Errors of a round (network,
5xx, rate limit above the limit) do not end the mirror; the next round waits with backoff up toimport.follow.max_backoff. Errors of one thread do not hold up other threads.
12.3 Settle rule¶
Agents in Discord edit their answer while they work. A half-finished output must not be copied, because later edits are not kept in sync (12.4). Per thread, the following applies strictly in order:
- A message is busy if it (or, for type 21, its starter message) carries a
reaction from
import.follow.busy_reactionswith a counter greater than zero. - A message is unsettled if the more recent of
timestampandedited_timestamplies less thanimport.follow.settle_afterin the past. - What is copied is the beginning of the new messages up to before the first busy or unsettled message. Types that are not copied do not stop it.
- Because agents sometimes set their marker only after the copy, the round additionally checks
the
import.follow.busy_lookbackmessages before the first new one. If one of them is busy, the whole thread waits.
12.4 What is not kept in sync¶
Edits and deletions in Discord after copying. Reason: that would mean re-reading old messages regularly; the settle rule makes the case rare. The report of a later full run counts "changed at the source" (7.2).
13. Migration flow per channel¶
13.1 States¶
stateDiagram-v2
[*] --> Mapped
Mapped --> Mirror: import applied, lead discord
Mirror --> BothyLeads: switch
BothyLeads --> Mirror: revert
BothyLeads --> Completed: all channels switched over, source completed
Completed --> [*]
note right of Mirror
Discord leads.
Bothy read-only, no operator.
end note
note right of BothyLeads
Bothy leads, operator set.
Discord read-only, agent removed there.
end note
13.2 Pilot and parallel operation¶
- Dry run over the whole server; check the report; correct the mapping file until there are no aborts.
- Pilot: a few channels with
--channel, import applied, read mirror on. - Parallel operation: Discord leads in all channels. Bothy shows the history read-only. Check: counters in the report, spot checks against Discord, attachments, session references.
The owner's guide for the whole flow, with the commands, the console steps and the way back of every step, is discord-migration.md.
13.3 Switching over a channel¶
Order, so that two places never lead at the same time:
- Discord, owner: set the channel read-only; remove the agent from this channel in the Discord configuration of its runtime.
- Tool: one last mirror round for the channel; wait until the settle rule holds nothing back any more.
- Bothy, owner in the web console:
lead = bothy(import.manage), which lifts read-only in the same transaction; set the operator (channel.operator.set, step-up, I1). The operator must beplanned_operator, otherwise the imported threads have a different agent than the channel. - Probe: a message in an imported thread triggers the agent; the session binds (section 9); the answer appears only in Bothy.
switch performs step 2 and ends with exit 1 and the number of held-back messages if
busy or unsettled messages are still in the channel after the last round; otherwise it prints
step 3 for the owner. Step 3 needs step-up, which a cli session cannot provide (section 8).
The tool cannot see step 1 (it never writes to Discord and reads no permissions there), so the
flag --discord-locked stands for it: the owner confirms with it that the Discord channel is
read-only and the agent removed there; without it switch ends with exit 2 before any call.
After the round switch reads the channel once more: messages after the cursor that the settle
rule does not hold but the round did not copy (written after it) also end with exit 1, because
the lock of step 1 is evidently not in place yet.
13.4 Reverting a channel¶
- Bothy, owner in the web console:
lead = discord, which sets read-only and cancels active runs in the channel in the same transaction; remove the operator. - Discord, owner: lift read-only; add the agent there again.
- The mirror resumes at the cursor.
Messages that were created in Bothy while lead = bothy do not go to Discord; the
tool never writes to Discord. revert prints their number per thread, so that the owner deliberately
transfers or discards them.
13.5 Completion¶
When all imported channels (channel item posted) have lead = bothy: end the mirror; source completed (import.manage, by the
owner in the web console). The
mapping import_items remains until the owner has the retention class import deleted
(20-privacy.md). After that a repeated import is no longer idempotent;
the web console warns before this step (in the confirmation of the completion).
14. Security and privacy¶
| Topic | Rule |
|---|---|
| Discord token | only in the tool, from the environment or a file; never to Bothy, never to CDN hosts, never in output or log |
| Server-side loading | none; only the tool loads, only https, only hosts from import.discord.cdn_hosts, without redirects |
| Permissions | import and mirror only by owners; start with step-up; new channels without grants |
| Triggering | origin = import never triggers; channels in the mirror are read-only |
| Placeholders | personal data of third-party Discord users; class account; deletion through a privacy request |
| Origin | import_items (class import) contains Discord IDs; deletable after completion |
| Audit | start, pause, cancellation, completion, ownership list, bindings |
| Telemetry | counters and durations of the tool, no content, no Discord IDs |
15. Later: Zulip import¶
The import interface is source-neutral. A Zulip import would be a further command
bothy import zulip with its own mapping (channel to channel, topic to thread) and needs
import_sources.kind = zulip (additive migration). Not in this scope.
16. Tests that hold this chapter¶
| Test | Proof |
|---|---|
Dry run only GET |
fake Discord and fake Bothy count methods: only GET; no file in the working and temp folders |
| Discord client without a write method | static check: the client exports no method with a verb other than GET |
| No duplicate after a crash | abort between batch and cursor; new run: every item exactly once, order per thread equal to Discord |
| Stored, then error | fake Bothy stores a batch and answers 502; the retry returns the same target |
| Import never triggers | an imported message in a channel with an operator creates no run, no agent.invocation |
| No notification | no push, no mentions, read_states at the batch end |
| CDN limits | foreign host, http, redirect and an oversized response are rejected; token never to the CDN |
| No secret in output | fake responses echo the token and a signed query; output, report and log contain none of them |
| Archive rule | only archived = true and not active becomes closed; doubt stays open |
| Settle rule | a busy or unsettled message stops the thread; a lookback marker stops it |
| Read-only in the mirror | message.create of an owner in the mirror channel is rejected; import.apply succeeds |
| Binding once | two simultaneous first triggers bind exactly once; missing old session: new session, exactly one system message |
| Rate limit | X-RateLimit-Remaining: 0 waits; a wait time above the limit aborts with exit 1, the state remains |
| Intent probe | a channel with only empty messages by other authors aborts with exit 2 |
| Invalid selection | an unknown ID in --only-thread aborts before any write |
17. Decisions of this chapter¶
| No. | Decision | Reason |
|---|---|---|
| R-IM-01 | Import as a separate process through the public API, not as a server job. | No server-side loading of external URLs, Discord token never in the server, B6 (ADR 0043). |
| R-IM-02 | Dry run is the default and writes nowhere. | A forgotten switch must not create anything; the proof is a simple test. |
| R-IM-03 | Separate Discord bot account with read permissions only. | Rate limits per bot; productive agents are not slowed down; least privilege. |
| R-IM-04 | Idempotency through import_items (source_id, origin_key), not through content comparison. |
Exact instead of heuristic; a crash at any point leaves no duplicate. |
| R-IM-05 | All imported messages origin = import, silent, without mentions. |
Never trigger, never notify. |
| R-IM-06 | Unknown authors become disabled placeholder people. | Every message needs a principal; a placeholder can do nothing. |
| R-IM-07 | Channels in the mirror are read-only, also for owners. | A channel never has two leading places; no agent is triggered twice. |
| R-IM-08 | Imported threads carry the planned operator. | Otherwise they stay without an agent after the switch-over because of R-DM-12. |
| R-IM-09 | Reactions and later edits are not taken over. | Cost per emoji and message; the settle rule makes late edits rare. |
| R-IM-10 | Binding of earlier sessions on the first trigger, exactly once, fail open. | Matches /resume; a failure must never silence the person. |
| R-IM-11 | Archived only with archived = true and not in the active list. |
A thread wrongly left open does no harm, a thread wrongly closed hides work. |
| R-IM-12 | Reverting copies nothing to Discord. | The tool never writes to Discord; the owner decides about the few messages. |
18. Alignment with the core contract¶
The additions requested during drafting are decided (integration of 2026-10-10):
| No. | Addition | Decision, location |
|---|---|---|
| K-IM-1 | Action import.apply, RL class rl.import |
adopted, 05 sections 3, 4.12 and 10 |
| K-IM-2 | channels.read_only with an action to set it |
adopted, 03 channels, 05 rule 1.8 and channel.read_only.set (with step-up, because the switch also locks out owners) |
| K-IM-3 | Operator and times of imported threads from the mapping and the source | adopted, 03 section 4.11 |
| K-IM-4 | thread.updated per batch; imported content not unread |
adopted, 04 section 15.8; instead of advancing the read states per reader, the import sets threads.unread_floor_seq (one row per thread) |
| K-IM-5 | Meaning of end_reason = import, binding rule, message "resume failed" |
adopted, 03 agent_sessions, 05 agent_session.report (bind_failed) |
| K-IM-6 | Baseline for cumulative session costs | adopted, 03 agent_sessions.cost_baseline_micros |
| K-IM-7 | I1 targets with threads of the agent, open or done | adopted, 03 section 6.4 |
| K-IM-8 | forum_tag as an item kind; state dry_run is dropped |
adopted, 03 import_items, import_sources |
19. Configuration values of this chapter¶
Values of the tool, not of the server. They follow the naming rule and the formats of the
configuration reference (section 2): the environment of the tool
(BOTHY_IMPORT__FOLLOW__BUSY_REACTIONS) or a separate YAML file with nested keys that --config
names (import: {follow: {busy_reactions: [...]}}). This file is never the file of the server
(BOTHY_CONFIG_FILE): the server does not know import.* and aborts its start on unknown keys.
The rate-limit class rl.import of the server is in 05-actions.md,
section 10.
| Name | Default | Meaning |
|---|---|---|
import.discord.api_base |
https://discord.com/api/v10 |
Discord API base [R6, verified: v10 current] |
import.discord.cdn_hosts |
cdn.discordapp.com, media.discordapp.net |
allowed hosts for attachments |
import.discord.page_size |
100 |
messages per page; Discord allows at most 100 [R4, verified] |
import.discord.max_429_retries |
5 |
retries after 429 per call |
import.discord.max_rate_wait |
60s |
longest wait before the run aborts |
import.discord.preflight_sample |
20 |
messages for the intent probe |
import.discord.closed_tags |
empty | forum tags that additionally mark a thread as done |
import.attachment_max_bytes |
same as file.max_upload_bytes |
largest imported attachment |
import.http_timeout |
60s |
time limit per call to Discord and the CDN; also the time limit of each call of the tool to Bothy |
import.login_poll_interval |
5s |
interval between polls of completePairing during bothy import login; the server limits public auth calls by ratelimit.rl.public_auth.* |
import.batch_max_items |
100 |
items per batch sent to the server |
import.reply_quote_max_chars |
100 |
length of the quote line for replies across threads |
import.follow.poll_interval |
30s |
interval between mirror rounds |
import.follow.settle_after |
60s |
quiet time after the last change |
import.follow.busy_reactions |
👀 |
reactions that mean "agent is still working" |
import.follow.busy_lookback |
10 |
messages before the first new one that are checked for the marker |
import.follow.max_backoff |
5m |
longest interval after failed rounds |
import.lease_stale_after |
90s |
a run lease renewed less than this long ago blocks a second writing run (section 7.4); larger than import.follow.poll_interval |
The values for page size, retries, wait limit, settle rule and backoff are taken over from the predecessor, tested there against fake servers and not measured against the real Discord (REUSE.md).
Sources¶
All retrieved on 2026-10-10.
- [R1] Discord, User (
GET /users/@me, fieldsbot,global_name,username): https://docs.discord.com/developers/resources/user [verified] - [R2] Discord, Guild: https://docs.discord.com/developers/resources/guild [verified]
- [R3] Discord, Channel: https://docs.discord.com/developers/resources/channel [verified]
- [R4] Discord, Message: https://docs.discord.com/developers/resources/message [verified]
- [R5] Discord, Reference (CDN, signed attachment URLs): https://docs.discord.com/developers/reference [verified]
- [R6] Discord, Reference (API base, snowflakes, formatting, User-Agent): https://docs.discord.com/developers/reference [verified]
- [R7] Discord, Rate Limits: https://docs.discord.com/developers/topics/rate-limits [verified]
- [R8] Discord, Gateway (Message Content Intent): https://docs.discord.com/developers/events/gateway [verified]