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:
vt cron (2-5 second interval)valtown-watch handles auto-syncvt tail for log observation during developmentTiming model:
Idempotency is critical because:
Extend the existing DiscordService with new endpoints required for auto-threading.
backend/discord.tsbackend/discord.ts infrastructure| Task | Acceptance Criteria |
|---|---|
Add listGuildChannels() method | Returns array of {id, name, type, topic?} from GET /guilds/{guild_id}/channels |
Add getMessages(channelId, options?) method | Returns messages from GET /channels/{channel_id}/messages with {after?: string, limit?: number} options, limit clamped to 1-100 |
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 including topic fieldgetMessages() respects after snowflake parameter and limitstartThreadFromMessage() creates thread and returns thread IDPass/fail criteria:
Test organization:
backend/__tests__/discord.test.tsvt tail): backend/__tests__/discord.integration.tsCreate tracking tables and a dry-run cron job that identifies candidate messages without creating threads.
backend/autothread.cron.ts| Task | Acceptance Criteria |
|---|---|
Create autothread_channels table | Stores channel_id, topic, last_seen_at, last_message_id |
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; runs internal loop polling every ~5 seconds for ~55 seconds |
| Implement channel discovery logic | Fetches guild channels, filters by [autothread] in topic |
| Implement message scanning logic | Fetches last ~20 messages, identifies any not yet processed |
| Track last-processed message per channel | Store last_message_id in autothread_channels for efficient polling |
| 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:
[autothread] in topic (case-insensitive)autothread_processed) are identified immediatelyautothread_processed) are skippedlast_message_id tracking enables efficient incremental pollingstartThreadFromMessagePass/fail criteria:
vt tail)status='dry_run'AUTOTHREAD_DRY_RUN=trueTest organization:
backend/__tests__/autothread.test.ts (snowflake math, filtering logic)backend/__tests__/autothread.integration.ts (end-to-end dry run)vt tail during live dry-runActually create threads on identified messages using deterministic naming.
| 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 (critical for idempotency across poll iterations and overlapping cron runs) |
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:
status='error' row, not crashPass/fail criteria:
Test organization:
backend/__tests__/autothread.test.ts (naming logic, filtering)Use OpenAI to generate contextual thread names and summaries from recent messages.
OPENAI_API_KEY environment variable| Task | Acceptance Criteria |
|---|---|
Add AUTOTHREAD_ENABLE_AI env var | When false or unset, use deterministic naming |
| Implement context gathering | Collect last ~4 messages before target (or up to 5 min of history) for context |
| 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:
AUTOTHREAD_ENABLE_AI=false skips AI entirelyPass/fail criteria:
Test organization:
backend/__tests__/autothread-ai.test.ts (context gathering, prompt construction)Production-ready safeguards and operational improvements.
| 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:
!, /, .) are skipped[autothread:quiet] mode creates threads without summary messagesPass/fail criteria:
Test organization:
backend/__tests__/autothread-hardening.test.ts| 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 |
Internal loop pattern (Val Town cron minimum is 1 minute):
export default async function() {
const POLL_INTERVAL_MS = 5000;
const RUN_DURATION_MS = 55000; // Leave 5s buffer before next cron
const startTime = Date.now();
while (Date.now() - startTime < RUN_DURATION_MS) {
await pollAndProcessMessages();
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
}
}
Per-iteration logic:
[autothread] channelautothread_processed table to find new messageslast_message_id in autothread_channels for efficient after parameterSnowflake math (for after parameter optimization):
const DISCORD_EPOCH = 1420070400000n; // 2015-01-01T00:00:00.000Z
const snowflake = ((BigInt(Date.now()) - DISCORD_EPOCH) << 22n).toString();
Record discoveries, gotchas, and useful patterns here during implementation.
topic field is the channel descriptionthread property when thread existsgetMessages() returns newest-first; Gate 1 must sort by ID for chronological processinggetMessages to 100 messages max per callretry_after may be missing/malformed - added defensive parsinglast_message_id cursor past messages fully handled to avoid message loss on capsstatus='dry_run' rows - switching to live requires clearing/bumping tablesallMessages (incl. before cursor), not just newMessages[autothread:quiet] tag regex must match variants like [autothread], [autothread:quiet]<https://...> formatautothread_runs table to track actual cron run statusImplemented a comprehensive debug console for testing in Val Town's deploy-to-prod environment.
plan (no writes), dry_run (DB only), live (full)sandbox (isolated testing) vs prod (real data)backend/autothread/ - Refactored module structure
types.ts - Type definitions and config defaultsstore.ts - Database operations with namespace supportlogic.ts - Core processing logicrunner.ts - High-level runnerREADME.md - Documentationbackend/autothread-debug.http.ts - Debug HTTP endpointsPOST /api/autothread/debug/run - Trigger debug runGET /api/autothread/debug/state - Inspect DB stateGET /api/autothread/debug/runs - List run historyPOST /api/autothread/debug/eval-message - Test gate evaluationPOST /api/autothread/debug/generate-ai-name - Test AI namingSee backend/autothread/README.md for full documentation.
The debug console is currently on the auto-threading branch. To test it:
https://rustnyc-talks.val.run/api/autothread/debug/https://rustnyc-talks.val.run/api/autothread/healthSet these in Val Town project settings:
ADMIN_TOKEN - Bearer token for debug endpointsENABLE_TEST_API=true - Enable debug endpointsDISCORD_BOT_TOKEN - Discord bot authenticationDISCORD_GUILD_ID - Guild to monitorOPENAI_API_KEY - For AI naming (optional)Fixed export in backend/autothread-debug.http.ts:
export default app.fetch to export default appapp.route() in Hono expects a Hono app instance, not a fetch function