Discord¶
Discord capture has two independent halves: a live bot that polls chosen server channels going forward, and a backfill that imports Discord's own data export for your side of every conversation — including your direct messages, which no bot can read. Use either on its own, or both together.
Live bot (kind=discord)¶
What it does¶
A bot polls GET /channels/{id}/messages for each channel you tick in
Settings › Connectors › Discord channels, paging forward from a
per-channel Discord snowflake cursor (like the IMAP connector's UID cursor,
but one cursor per channel). New messages are normalized the same way as
everything else and written to cap_items; every author becomes a person in
People.
Every 30 minutes the scheduler also runs capture_agent.discord_discover,
which lists the servers the bot is a member of and their text and
announcement channels into cap_discord_channels. That list is what the
picker shows — the app itself never talks to Discord and never sees the bot
token.
Turn on the Message Content intent first
Discord's privileged Message Content intent gates the content,
attachments and embeds fields of every message a bot reads — over the
REST API as well as the Gateway. Without it the poll still sees authors
and timestamps but every body is blank, and the app shows empty bubbles.
Enable it in the Developer Portal:
your application → Bot → Privileged Gateway Intents → Message
Content Intent → Save. A bot in a handful of servers can switch it on
without a verification review. The channels themselves still only need
the ordinary View Channel and Read Message History permissions.
The connector refuses to file a page of blank messages: the row's
last_error reads "Message Content intent is off for this bot …" and the
cursor stays put until the intent is on.
Prereqs¶
- A Discord bot application with a bot token, invited to the server(s) you want to capture, with View Channel + Read Message History on the channels you'll tick, and the Message Content Intent switched on (see the warning above).
- Runs in the container scheduler — no host access needed.
- DMs are out of scope for the bot: a bot cannot read a user's direct messages, and Jarvis never uses your own account token (a self-bot breaks Discord's terms and risks the account). Your side of your DMs comes from the backfill below.
Env / secret_ref¶
The bot token is a single secret key, TOKEN, under secret_ref DISCORD,
giving the env var declared in module.yaml:
JARVIS_DISCORD_TOKEN=your-bot-token
Set it in .env on the box (/opt/jarvis/.env in the reference deploy) and
restart the stack — compose.yaml forwards it into the scheduler, and a repo
check (tools/checks/env_contract.py::unforwarded_manifest_secrets) fails CI
if any module manifest secret is ever left out of that block again.
Turn it on¶
- Set
JARVIS_DISCORD_TOKENin.env;./jarvis downthen./jarvis up(the reference deploy does this on every merge). - Wait for the next discovery run — within 30 minutes Settings › Connectors › Discord channels lists the bot's servers. Until then it reads "No servers yet"; Refresh re-reads the list.
- Tick the channels to capture. Each tick writes the connector row at once (the controls grey out until the row reloads); the first tick creates the row, later ticks update it.
- Set Capture since — the date the first poll of each newly ticked
channel starts from (default
2026-01-01). Channels already polled keep their place; moving the date never rewinds a channel. - Paste Your Discord user id and Save, so your own messages count as
sent (
direction="out"). Copy it from Discord: Settings › Advanced › Developer Mode, then right-click your name → Copy User ID.
The scheduler's capture_agent.poll cron (every 10 minutes) picks the row up
on its next run and drains each channel forward, 200 messages per run.
The cap_connectors row (what the picker writes)¶
{
"kind": "discord",
"enabled": true,
"secret_ref": "DISCORD",
"config": {
"channels": ["channel-id-1", "channel-id-2"],
"since": "2026-01-01"
},
"owner_addresses": ["your-discord-user-id"]
}
config.channels is an explicit allowlist across all servers — only the
ids listed are polled. enabled is true exactly when the list is non-empty.
config.since (YYYY-MM-DD) is where a channel with no cursor yet starts;
a missing or unparsable value polls from the channel's first message. You
can still create rows by hand in the PocketBase admin
UI — older per-server rows keep polling; the
picker edits the newest row and says how many others exist. To re-read a
channel from an earlier date, delete its key from the row's cursor in the
admin UI.
Verify¶
Settings › Connectors shows the Discord row as "N channels" with its last poll time. Then:
./jarvis logs scheduler
A missing token is not an error in the logs — look for discord discover:
{'skipped': 'no token', …}; a healthy run prints created/updated/
deleted counts instead.
In the PocketBase admin, cap_items grows with new Discord messages, the
row's last_poll_at updates each cron run, and last_error is empty.
Errors¶
- Missing credentials — same as every connector:
last_errorbecomesmissing credentials (set the secret_ref env vars)and the row is skipped until the token is set and reachable. Discovery skips quietly. - Message Content intent off —
last_errorreadsMessage Content intent is off for this bot: channel … returned N messages with empty content …, nothing is filed and the cursor does not advance. Turn the intent on (warning above); the next poll resumes from the same cursor. If blank messages were filed before the guard existed, delete the empty Discord items and clear the row'scursor({}) in the PocketBase admin; the next poll re-drains every ticked channel from the since date with content, and dedupe keeps re-runs safe. - Per-channel rate limiting or a transient error — that channel is skipped for this run with its cursor left unadvanced (other channels still poll normally) and retried on the next cron run.
- A channel that answers 403 or 404 (deleted, or the bot lost access) is
retried on the next two runs too. From its third failure in a row it is
retried once a day, with one log line per retry:
discord channel <id> unreachable (HTTP 404, Unknown Channel) …then… still unreachable …. Discord's reason tells a deleted channel (Unknown Channel) from a permission problem (Missing Access). The state sits in the row'scursorunder_unreachable; the first successful poll clears it, and so does unticking the channel. Delete the key in the admin UI to retry at once. - A channel the bot can no longer see stays in your allowlist and is listed under "No longer visible to the bot" in the picker; untick it there.
- Discovery errors never touch the snapshot: a failed server list writes nothing, a single failing server keeps its previous channels.
Backfill (data export) — your DMs and your own history¶
What it does¶
A one-shot import of Discord's own "Request all my Data" export package.
The export contains every message you sent — in servers and in direct
messages — so every imported item is tagged direction="out" and resolves
to a single owner entity. There's no cap_connectors row involved; it's a
CLI command you run once, then again with a later --since every few months
to keep your DM side current.
Prereqs¶
- Request your data export from Discord (User Settings → Privacy & Safety → Request all of my Data), wait for the mail, and unzip the package on the box.
- Optionally, your Discord user id, if the export package doesn't include
account/user.json(some trimmed exports omit it).
Run it¶
python -m capture_agent.backfill discord --package /path/to/discord-export --since 2026-01-01
If the package has no account/user.json, pass your id explicitly:
python -m capture_agent.backfill discord --package /path/to/discord-export --since 2026-01-01 --owner your-discord-user-id
--since filters to messages on or after that date (YYYY-MM-DD).
Re-import cadence¶
Request a fresh export every few months and re-run the command with --since
set to your previous import's date. Re-runs are safe: cap_items dedupes by
external_id (the Discord message id), so messages already imported — and
messages the live bot already captured from a ticked channel — are left
alone; only new ones land.
Verify¶
The command prints a summary when it finishes, e.g. capture discord
backfill: {'ingested': N, 'skipped_dupe': 0, 'unrouted': N,
'entities_created': 1, 'errors': 0}. Check cap_items in the PocketBase
admin for the new rows.
Errors¶
Per-message parsing failures are logged and skipped so one bad row never aborts the whole import.