Interesting artifacts and learnings must be written back to this document.
Automatically create Discord threads on messages in channels with [autothread] in their topic/description. Uses high-frequency polling (every 2-5 seconds) since Val Town cannot use Discord WebSockets. Thread creation is immediate upon detecting new messages. AI uses recent message history (last ~4 messages or 5 minutes) purely for contextual thread naming.
Key constraints:
- Val Town environment (Deno, SQLite, cron triggers)
- No WebSocket support β high-frequency polling via
vtcron (2-5 second interval) valtown-watchhandles auto-sync- Use
vt tailfor log observation during development
Timing model:
- Cron polls every 2-5 seconds
- New messages are threaded immediately upon detection
- Recent message context (last ~4 messages or 5 minutes) gathered only for AI summarization
- No intentional delay before threading
Extend the existing DiscordService with new endpoints required for auto-threading.
- Add Discord API methods to
backend/discord.ts - Add 429 rate limit handling with retry logic
- Existing
backend/discord.tsinfrastructure
| Task | Acceptance Criteria |
|---|---|
Add listGuildChannels() method | Returns array of {id, name, type, topic?} from GET /guilds/{guild_id}/channels |
Add getMessages(channelId, afterSnowflake, limit) method | Returns messages from GET /channels/{channel_id}/messages with pagination support |
Add startThreadFromMessage(channelId, messageId, name) method | Creates thread via POST /channels/{channel_id}/messages/{message_id}/threads |
Add 429 rate limit handling in request() | On 429 response: parse retry_after, wait, retry (max 2 retries) |
| Add TypeScript types for Discord message/channel objects | Types match Discord API v10 response shapes |
Test scenarios:
listGuildChannels()returns channels with correct shape includingtopicfieldgetMessages()respectsaftersnowflake parameter andlimitstartThreadFromMessage()creates thread and returns thread ID- Rate limit handling: mock 429 response triggers retry after delay
- NoopDiscordService implements all new methods with appropriate logging
Pass/fail criteria:
- All methods return expected types
- Rate limit retry logic executes correctly (verified via logs in test environment)
- NoopDiscordService mirrors RealDiscordService interface
Test organization:
- Unit tests:
backend/__tests__/discord.test.ts - Integration tests (manual via
vt tail):backend/__tests__/discord.integration.ts
Create tracking tables and a dry-run cron job that identifies candidate messages without creating threads.
- New SQLite tables for channel tracking and message deduplication
- New cron file
backend/autothread.cron.ts - Environment variables for configuration
- Gate 0 complete
| Task | Acceptance Criteria |
|---|---|
Create autothread_channels table | Stores channel_id, topic, last_seen_at |
Create autothread_processed table | Stores channel_id, message_id, thread_id, status, error, processed_at with PK on (channel_id, message_id) |
Create autothread.cron.ts entry point | Exports default async function for cron trigger |
| Implement channel discovery logic | Fetches guild channels, filters by [autothread] in topic |
| Implement message scanning logic | Fetches messages 4-10 minutes old using snowflake math |
| Implement dry-run logging | Logs "would create thread" for each candidate, inserts status='dry_run' |
Add AUTOTHREAD_DRY_RUN env var | When true, skips actual thread creation |
Add AUTOTHREAD_CHANNEL_ALLOWLIST env var | Comma-separated channel IDs; only process listed channels when set |
| Add per-run caps | MAX_CHANNELS_PER_RUN=3, MAX_THREADS_PER_RUN=5 |
Test scenarios:
- Channel discovery correctly filters by
[autothread]in topic (case-insensitive) - Channel allowlist restricts processing to listed channels only
- Snowflake calculation correctly identifies 4-10 minute window
- Messages outside window are ignored
- Already-processed messages (existing in
autothread_processed) are skipped - Per-run caps prevent runaway processing
- Dry-run mode logs candidates but does not call
startThreadFromMessage
Pass/fail criteria:
- Cron runs without error (verified via
vt tail) - Correct messages identified in logs
- Database rows created with
status='dry_run' - No actual threads created when
AUTOTHREAD_DRY_RUN=true
Test organization:
- Unit tests:
backend/__tests__/autothread.test.ts(snowflake math, filtering logic) - Integration tests:
backend/__tests__/autothread.integration.ts(end-to-end dry run) - Manual verification:
vt tailduring live dry-run
Actually create threads on identified messages using deterministic naming.
- Implement thread creation logic
- Deterministic thread naming from message content
- Idempotent processing via optimistic DB insert
- Gate 1 complete
| Task | Acceptance Criteria |
|---|---|
| Implement deterministic thread naming | First 60 chars of message content, sanitized; fallback to Discussion from {author} @ {HH:MM} |
| Implement optimistic insert pattern | Insert status='processing' before API call; skip on PK conflict |
Call startThreadFromMessage() | Creates thread with deterministic name |
| Update database on success | Set status='created', store thread_id |
| Update database on failure | Set status='error', store error message |
| Ignore bot messages | Skip messages where author.bot === true |
| Ignore very short messages | Skip messages with content < 10 characters |
Test scenarios:
- Thread created with correct name (first 60 chars of message)
- Fallback naming used when message content is unsuitable
- Bot messages are skipped
- Short messages (< 10 chars) are skipped
- PK conflict in DB correctly prevents duplicate thread creation
- API error results in
status='error'row, not crash - Rate limits handled gracefully with retry
Pass/fail criteria:
- Threads appear in Discord with expected names
- Database accurately reflects thread creation status
- No duplicate threads created for same message
- Errors logged and captured in DB, cron continues
Test organization:
- Unit tests:
backend/__tests__/autothread.test.ts(naming logic, filtering) - Integration tests: Run cron on test channel, verify threads created
- Manual verification: Check Discord test channel
Use OpenAI to generate contextual thread names and summaries from recent messages.
- Gather message context (5 before, 2 after target message)
- Call OpenAI for thread name + summary
- Post summary as first message in created thread
- Graceful fallback to deterministic naming on AI failure
- Gate 2 complete
OPENAI_API_KEYenvironment variable
| Task | Acceptance Criteria |
|---|---|
Add AUTOTHREAD_ENABLE_AI env var | When false or unset, use deterministic naming |
| Implement context gathering | Collect up to 5 messages before + 2 after target from fetched slice |
| Create AI prompt for thread naming | Returns thread_name (β€100 chars) and summary (2-5 bullets) |
| Call OpenAI API | Use gpt-4o-mini, limit tokens appropriately |
| Create thread with AI name | Use AI-generated name, fallback to deterministic on failure |
| Post summary in thread | Send AI summary as first message in new thread |
| Implement token/cost limits | Limit context to ~1000 tokens input |
| Fallback on AI error | Log error, proceed with deterministic name, skip summary |
Test scenarios:
- AI generates appropriate thread name from context
- Thread name respects 100 character Discord limit
- Summary is posted as first message in thread
- AI failure results in fallback to deterministic name (not crash)
- Context gathering correctly selects nearby messages
AUTOTHREAD_ENABLE_AI=falseskips AI entirely
Pass/fail criteria:
- AI-named threads are contextually relevant
- Summary provides useful context for thread participants
- No failures when AI is unavailable/errors
- Token usage stays within limits
Test organization:
- Unit tests:
backend/__tests__/autothread-ai.test.ts(context gathering, prompt construction) - Integration tests: Run cron with AI enabled on test channel
- Manual verification: Review AI-generated names and summaries in Discord
Production-ready safeguards and operational improvements.
- Per-channel cooldowns
- Ignore rules for commands and special messages
- Monitoring and alerting
- Documentation
- Gate 3 complete
| Task | Acceptance Criteria |
|---|---|
| Add per-channel cooldown | No more than 3 threads created per channel per 10 minutes |
| Add command prefix ignore list | Skip messages starting with !, /, . |
| Add content filter | Skip messages that are only emoji, links, or mentions |
Add autothread_stats table | Track threads created per channel per day for monitoring |
| Implement health check endpoint | /api/autothread/health returns last run status, errors |
| Add README documentation | Document env vars, behavior, and operational considerations |
| Add channel-specific config | [autothread:quiet] disables AI summary posting |
Test scenarios:
- Per-channel cooldown prevents burst thread creation
- Command messages (starting with
!,/,.) are skipped - Emoji-only and link-only messages are skipped
- Stats table accurately tracks daily thread counts
- Health endpoint returns meaningful status
[autothread:quiet]mode creates threads without summary messages
Pass/fail criteria:
- System operates safely under burst message conditions
- Operational visibility via health endpoint
- Documentation is accurate and complete
Test organization:
- Unit tests:
backend/__tests__/autothread-hardening.test.ts - Load tests: Simulate burst of messages, verify caps/cooldowns
- Manual verification: Review stats and health endpoint
| Variable | Gate | Required | Description |
|---|---|---|---|
DISCORD_BOT_TOKEN | 0 | Yes | Bot authentication |
DISCORD_GUILD_ID | 0 | Yes | Guild to monitor |
AUTOTHREAD_DRY_RUN | 1 | No | Skip actual thread creation when true |
AUTOTHREAD_CHANNEL_ALLOWLIST | 1 | No | Comma-separated channel IDs |
AUTOTHREAD_ENABLE_AI | 3 | No | Enable AI naming when true |
OPENAI_API_KEY | 3 | For AI | OpenAI API key (Val Town std lib) |
| Endpoint | Purpose | Gate |
|---|---|---|
GET /guilds/{guild_id}/channels | List channels with topics | 0 |
GET /channels/{channel_id}/messages | Fetch recent messages | 0 |
POST /channels/{channel_id}/messages/{message_id}/threads | Create thread | 0 |
POST /channels/{thread_id}/messages | Post summary (existing) | 3 |
Discord snowflakes encode timestamps. To create a snowflake for "N minutes ago":
const DISCORD_EPOCH = 1420070400000n; // 2015-01-01T00:00:00.000Z
const now = BigInt(Date.now());
const targetMs = now - BigInt(minutesAgo * 60 * 1000);
const snowflake = ((targetMs - DISCORD_EPOCH) << 22n).toString();
Record discoveries, gotchas, and useful patterns here during implementation.
- Discord
topicfield is the channel description - Thread starter messages have
threadproperty when thread exists - Rate limits: process channels sequentially to avoid 429s