The official Slack bot of the Pixl YSWS program, built by Gabin. Part entertainer, part support system, part AI with too much personality.
Pixorpheus handles the full help/ticket workflow for Pixl, talks to people in threads, remembers things about everyone, roasts people on demand, and generally acts like a teenager.
- Architecture
- Slash Commands
- Inline Commands (pixo:)
- Thread Controls
- AI System
- Smart FAQ
- Auto-Close
- Help & Ticket System
- Style Listening System
- Training Mode
- Dashboard
- Database
- Environment Variables
- Deployment
Pixorpheus is a TypeScript Bun project (no build step - bun run src/index.ts), split by feature under src/:
| Path | Role |
|---|---|
src/index.ts |
Bootstrap - starts the Bolt app, loads memory/polls, wires up the auto-close cron |
src/slack/ |
Shared Bolt App/ExpressReceiver instances, bot identity, #pixl-logs logging |
src/tickets/ |
Full help/ticket workflow - repo, blocks, service, event/action/view handlers |
src/chat/ |
Message handling - thread state, the main AI chat handler, welcome messages, polls, :pixl-delete: |
src/ai/ |
OpenRouter client (buffered + streaming), the Pixorpheus persona/system prompt, emoji handling, web search, and docs answering (docs.ts fetch+cache, answerFromDocs.ts answer + pixo_qa_cache) |
src/memory/ |
Per-user facts/personality, server-wide facts, speaking-style notes |
src/commands/ |
Remaining slash commands (fun/utility, social, helpers, memory, ai, ship) |
src/pixelate/ |
/pixl avatar pixelation command |
src/github/ |
GitHub webhook -> Slack notifications |
src/external/ |
API used by apps/dashboard to resolve tickets through this bot |
models.json |
OpenRouter model list |
There is no separate dashboard process anymore - the standalone helper dashboard (dashboard.js) was removed; ticket moderation now lives in apps/dashboard (the Next.js admin dashboard), which calls back into this bot via src/external/ticketApi.ts. The bot runs on Slack's Bolt v4 framework.
| Command | Description |
|---|---|
/pixl-ping |
Check bot latency |
/pixl-help |
List all available commands |
/pixl-joke |
Get a random joke (via JokeAPI) |
/pixl-coinflip |
Flip a coin |
/pixl-fact |
Get a random surprising fact (AI-generated) |
/pixl-urban [word] |
Urban Dictionary definition - AI-filtered to remove the worst ones |
/pixl-ask [question] |
Ask Pixorpheus anything publicly |
/pixl-roast [@user] |
Roast someone (or yourself) - pulls from memory for extra precision |
/pixl-remind [time] [message] |
Set a reminder - supports s, min, h (e.g. /pixl-remind 10min grab lunch) - max 24h |
/pixl-countdown [time] [label] |
Countdown timer that posts to channel when it hits zero |
/pixl-poll Question; Option1, Option2 [, 10min] |
Create a poll with emoji reactions - add a time at the end to auto-close it |
/pixl-ship [description] |
Announce a project you shipped |
/pixl-stats |
Bot activity stats (pixelizations, AI replies, roasts, reminders - since last restart) |
| Command | Description |
|---|---|
/pixl [@user] [size] |
Pixelate a Slack profile picture - only works in the Pixl channels. Optional pixel size 2–64 (default 8). Reacts with :pixl-delete: to remove. |
/pixl-lastship [github_username] |
Show the last approved Hack Club Ship for a GitHub user (defaults to yours if known) |
| Command | Description |
|---|---|
/pixl-mymemory [@user] |
See what Pixorpheus remembers about you (ephemeral) - or mention someone to show their profile publicly |
/pixl-helpstats |
Ticket stats - total, open, resolved counts |
These commands require being a helper, an admin (SLACK_ADMIN_USER_IDS), or a member of the ticket channel.
| Command | Description |
|---|---|
/pixl-addhelper @user |
Add someone to the helpers list |
/pixl-removehelper @user |
Remove someone from the helpers list |
/pixl-helpers |
List all current helpers |
/pixl-remember [fact] |
Teach Pixorpheus a fact about the server - injected into every AI reply (Gabin can also use this) |
/pixl-forget [number] |
Remove a stored memory entry by number |
/pixl-memories |
List all stored server memories |
These are typed directly in a message (not slash commands). They only work in channels where Pixorpheus has access - private channels or channels it's been added to.
| Command | Where | Description |
|---|---|---|
pixo:kawaii |
Any channel Pixorpheus is in | Start listening mode - Pixorpheus begins collecting messages in that channel to learn the writing style |
pixo:notkawaii |
Same channel | Stop listening mode - processes the collected messages and saves the speaking style |
pixo:kawaii? |
Anywhere | Check if listening mode is active - shows the channel and how many messages have been collected (ephemeral) |
pixo:recap |
Any channel | Summarize the last 6 hours of messages in the channel, shown only to you (ephemeral). Use pixo:recap today to summarize since midnight, pixo:recap 2h for a custom timeframe (supports min, h, d). In a thread, it summarizes the thread instead. |
pixo:compact (also pixo /compact, @Pixorpheus /compact) |
Any channel | Compact the day: summarizes everything since local midnight in that channel, posted as a real threaded reply (visible to everyone, not ephemeral). Use pixo:compact yesterday for the previous day's compact, pulled from the stored daily compact if one exists, computed live otherwise. Every day, right after midnight, Pixorpheus also silently generates and stores a compact of #pixl for the day that just ended (nothing is posted); this is what pixo:compact yesterday reads back. |
Only one listening session can be active at a time. Starting a new one in a different channel resets the previous one.
There's also a special delete feature: react with :pixl-delete: to any Pixorpheus message and it will delete itself.
Type these anywhere in a thread to control Pixorpheus's behavior:
| Command | Effect |
|---|---|
PIXOSTOP |
Mute Pixorpheus in the current thread - it will stop replying unless directly mentioned |
PIXOSTART |
Unmute Pixorpheus in the current thread |
Pixorpheus replies to messages when:
- Someone mentions it by name (
pixorpheus,pixo,pix) - Someone directly @mentions it (
@pixorpheus) - It decides to jump in uninvited if there's a genuinely good opening (chime mode - ~45% chance it acts on it)
- Someone DMs it
Messages are batched for 1.5 seconds (if mentioned) or 8 seconds (if chiming) to avoid replying to every single message in a fast conversation.
| Use case | Model |
|---|---|
| Main channel replies | claude-sonnet-4-5 via OpenRouter |
| DMs | claude-haiku-4-5 via Anthropic SDK (with web search) |
| Utility tasks (chime decision, memory extraction, search query) | deepseek/deepseek-v4-pro via OpenRouter |
| Urban Dictionary filtering | deepseek/deepseek-v4-pro via OpenRouter |
Pixorpheus automatically learns about people over time:
- Facts - extracted from every conversation (name, projects, skills, interests, etc.). Stored per user in PostgreSQL. Up to 100 facts per person.
- Personality traits - extracted 20% of the time, capturing communication style (blunt, enthusiastic, chaotic, etc.)
- Server memory - facts about the Pixl program added via
/pixl-remember- injected into every AI reply - Style notes - learned from the listening/training system (see below) - also injected into every reply
All of this is fed into the system prompt before every reply, so Pixorpheus always has context on who it's talking to.
Pixorpheus automatically searches the web (via Brave Search API) when a message seems to need up-to-date info - current events, news, prices, recent releases, etc. It decides whether to search before replying.
Pixorpheus has a full list of custom Slack emojis it knows about and uses in messages when contextually appropriate:
:wiltedrose: :yay: :loll: :sad-pf: :skulk: :noooovanish: :angy: :yesyes: :blobhaj_party: :shocked: :upvote: :lets-fucking-gooo: :huh3d: :thumbs-up: :3c: :byee: :hii: :nono: :hehehe: :awww: :alibaba-admire: :alibaba-grin: :cryign: :heavysob: :brokenheart: :nyan: :cat-gun: :isob: :sob-pray: :agadance: :cat-woah: :cat-heart: :communist: :eyes_wtf: :eyes_shaking: :eyes-out-of-head: :orpheus-love: :orpheus-baguette: :orphanage: :orpheus-explode: :hyper-dino-wave: :pepedyingoflaughter: :pet-gabin: :pet-ridit: :pet-maxx: :yapa: :yay-gay: :wagay: :gay-flag: :bhjflag_gay: :spinny_cat_gay: :1984:
It can also react to messages with these emojis (the AI decides when it's appropriate).
- Orpheus bot - automatically replies "thx orphan" immediately whenever Orpheus posts in the same channel
- New members - posts a random welcome message when someone joins the Pixl channel (
#pixl) and pings Gabin in the thread - Short replies - the bot is trained to reply like someone actually texting: 2–8 words most of the time
Pixorpheus can answer questions straight from the Pixl docs and the landing FAQ, and remembers what it has already answered so repeat questions are instant.
- Docs are fetched, never hardcoded into the prompt. On demand, pixo fetches the docs pages (
https://pixl.hackclub.com/docs/*) and the landing FAQ, strips them to text, and caches that corpus in memory (6h TTL). The docs text is only ever passed to a dedicated "answer from docs" model call, it is never added to the main chat system prompt, so ordinary messages don't pay the doc token cost. Override the sources withPIXL_DOCS_URL/PIXL_LANDING_URL. - Answered-questions cache (
pixo_qa_cachetable). Before fetching anything, pixo checks the questions it has already answered (exact + token-overlap match). A hit is returned instantly with no docs fetch and no model call. Fresh answers are stored so the next similar question is a cache hit. - If the docs don't cover it, the answer step returns nothing, the signal to fall back to a human helper.
When a new question is posted, pixo posts a quick placeholder, then edits it in place:
- Docs have the answer → the answer is posted (with a link to the docs), and a helper can still follow up.
- Docs don't cover it → "just wait for a helper to respond to this one :D", and pixo also surfaces a similar previously-resolved ticket if there is one (last 60 closed tickets, semantic match, the older Smart FAQ behavior, now a fallback).
The ticket is still created normally either way.
- Language: English only (the bot reminds users to post in English if needed)
- Threshold: Only high-confidence similar-ticket matches are surfaced - vague similarity is ignored
Tickets that have been open for more than 5 days with no activity are automatically closed.
- A ticket qualifies if: it has been open for 5+ days AND the last message in the thread is also 5+ days old
- At closure, Pixorpheus posts a message in the thread explaining the ticket was auto-closed due to inactivity, and tells the user to open a new ticket if the issue is still relevant
- The ticket channel message is updated to show the resolved status
Auto-close runs once at startup and once every 24 hours.
This is the core support system for the Pixl program.
-
User posts in the help channel →
- Pixorpheus adds a 🤔 reaction to the message
- Posts a placeholder thread reply, then edits it into either a docs answer or "just wait for a helper to respond to this one :D" (see Docs Answers & Smart FAQ) + a "Mark as resolved" button
- Sends the user an ephemeral message asking them to set a title for their ticket ("Set title" / "Skip" buttons)
-
Title modal (optional) →
- If the user clicks "Set title", a modal opens with a text input (max 100 chars)
- Submit → ticket created with the title
- Close/Skip → ticket created without a title
- If the user ignores the ephemeral for 3 minutes, the ticket is created automatically without a title
-
Ticket appears in the private ticket channel with:
- Status line:
🔴 Open - not claimed/🟡 Claimed by @X/✅ Resolved by @X - Buttons: Claim (or Unclaim) + Mark Resolved - or Reopen if closed
- The ticket title (or the first 80 characters of the message if no title)
- Author mention
- Quoted description
- View in Slack button (direct link to the thread)
- Ticket number at the bottom
- Status line:
| Where | Action | Who can do it |
|---|---|---|
| Help channel thread | Mark as resolved (button) | Ticket author, helpers, support team |
| Help channel thread | ?resolve or ?close macro |
Helpers only |
| Help channel thread | ?faq macro |
Helpers only - posts FAQ link and resolves |
| Help channel thread | ?reopen macro |
Helpers only |
| Ticket channel | Claim / Unclaim | Helpers and support team |
| Ticket channel | Mark Resolved | Helpers and support team |
| Ticket channel | Reopen | Helpers and support team |
| Dashboard | Reply to thread | Helpers (appears as their name) |
| Dashboard | Mark Resolved | Helpers |
Thread macros are typed as the first word in a thread reply (e.g. ?resolve - the message is automatically deleted after running).
When a ticket is resolved or reopened, the message in the ticket channel is automatically updated with the new status and buttons. The help channel thread always gets a notification message.
Reactions on the original message: 🤔 = open, ✅ = resolved.
⚠️ Only works in channels where Pixorpheus has been added (private channels or channels it's a member of).
This system lets you train Pixorpheus's speaking style from real conversations.
- Type
pixo:kawaiiin a channel - Pixorpheus confirms it's watching - Talk normally in that channel - it collects all messages
- Type
pixo:notkawaiiwhen done - Pixorpheus processes the messages and saves the style - From now on, the style notes are injected into every AI reply
Only one listening session can be active at a time. Minimum 5 messages needed to process.
Type pixo:kawaii? anywhere - you'll get an ephemeral showing whether listening mode is on, which channel, and how many messages collected.
A more explicit style training flow, available only in the designated training channel (TRAINING_CHANNEL env var, hardcoded as C0BD7JSTQNM).
| Command | Effect |
|---|---|
pixo:child labor training |
Start training mode - Pixorpheus watches every message in the channel |
pixo:stop child labor training |
Stop training - processes all collected messages and saves the style |
Requires at least 5 messages. The extracted style overwrites the previous style notes (same DB table as the listening system).
Helper/admin ticket moderation now lives in apps/dashboard (the Next.js admin dashboard elsewhere in this monorepo), not in this app. It talks to the same Supabase database directly and calls POST /api/external/tickets/:ts/resolve on this bot (see src/external/ticketApi.ts) to resolve tickets through Slack, since this bot is reliably a member of the help channel.
Tables (tickets, helpers, user_memory, user_personality, program_memory, polls, style_memory) are created via Supabase migrations, not at runtime.
| Table | Purpose |
|---|---|
user_memory |
Per-user fact arrays (JSONB) - up to 100 facts per person |
user_personality |
Per-user personality trait arrays (JSONB) |
program_memory |
Server-wide facts injected into every AI reply |
polls |
Active timed polls |
style_memory |
Speaking style notes (one active row) |
helpers |
Slack user IDs of support team members |
tickets |
All ticket records |
| Column | Type | Description |
|---|---|---|
msg_ts |
TEXT (PK) | Slack timestamp of the original help message |
ticket_msg_ts |
TEXT | Slack timestamp of the ticket channel message |
description |
TEXT | Full text of the original message |
title |
TEXT | Optional title set by the user |
status |
TEXT | open or closed |
opened_by_slack_id |
TEXT | Author of the original message |
claimed_by_slack_id |
TEXT | Helper who claimed the ticket |
closed_by_slack_id |
TEXT | Who resolved it |
closed_at |
TIMESTAMP | When it was resolved |
last_msg_at |
TIMESTAMP | Last activity in the thread |
permalink |
TEXT | Direct Slack link to the original message |
ticket_number |
INTEGER | Auto-incremented ticket number |
| Variable | Description |
|---|---|
SLACK_BOT_TOKEN |
Slack bot OAuth token (xoxb-...) |
SLACK_SIGNING_SECRET |
Slack app signing secret |
SLACK_HELP_CHANNEL |
Channel ID of the help channel where users post questions |
SLACK_TICKET_CHANNEL |
Channel ID of the private ticket channel for the support team |
SLACK_FAQ_URL |
URL to the FAQ (linked in the "Someone will be here soon!" message) |
SLACK_ADMIN_USER_IDS |
Comma-separated Slack user IDs of admins (bypass helper checks) |
SLACK_USER_TOKEN |
User token (xoxp-...) for deleting macro messages in threads |
SUPABASE_URL / SUPABASE_SERVICE_KEY |
Supabase project URL + service key (shared with the other apps in this monorepo) |
OPENROUTER_API_KEY |
OpenRouter API key (main AI + utility models) |
PIXO_MODEL |
Overrides the default OpenRouter model (google/gemini-3.1-flash-lite:nitro) |
BRAVE_SEARCH_KEY |
Brave Search API key (auto web search in replies) |
PIXL_DOCS_URL |
Base URL of the Pixl docs pixo answers from (default https://pixl.hackclub.com/docs) |
PIXL_LANDING_URL |
Landing URL pixo pulls the FAQ text from (default https://pixl.hackclub.com) |
PIXL_LOGS_CHANNEL_ID |
Skips the #pixl-logs channel-name scan (see src/slack/logs.ts) |
GITHUB_WEBHOOK_SECRET |
HMAC secret for the /webhooks/github route. Required - without it the route returns 503 and processes nothing |
GITHUB_NOTIFY_CHANNEL |
Channel ID to post GitHub push/merge notifications to |
SHOP_WEBHOOK_SECRET |
Shared secret for the /webhooks/shop route, sent by Supabase as the x-shop-webhook-secret header. Required - without it the route returns 503 and processes nothing |
SHOP_NOTIFY_CHANNEL |
Channel ID to post shop item change notifications to (defaults to #shop-changes) |
EXTERNAL_API_KEY |
Shared key apps/dashboard and apps/server use for Pixorpheus external APIs |
PORT |
Port for the Bolt HTTP receiver (default 3000) |
Pixorpheus runs as a single Bun process (bun run src/index.ts / bun run start), no build step. It's deployed on Railway, sharing its Supabase database with the rest of the monorepo, and auto-deploys from GitHub pushes to main.
The Pixl server calls POST /api/external/pixl-channel/join with x-api-key and { "slackId": "U..." } after a successful Hack Club login. The endpoint uses Pixorpheus's bot token to add the Slack member to #pixl; the caller's login remains valid if the bot is unavailable and retries next login.
To enroll game accounts that existed before this feature, run bun run backfill:pixl-channel first to inspect the counts, then run bun run backfill:pixl-channel --apply. The backfill only invites Slack IDs absent from #pixl, sends at most one 100-user batch every 1.25 seconds, and prints its result as JSON.
Pixorpheus must be a member of #pixl and its bot token needs channels:read and channels:write.invites for this feature. Reinstall the Slack app after adding the scopes.
The Slack app must have the following event subscriptions enabled:
message.channelsmessage.groupsmessage.immessage.mpimreaction_addedmember_joined_channel
And the following slash commands registered pointing to the bot's URL.