Skip to content

Dallas

A Claude agent that works in my team’s Discord.

  • Claude Agent SDK
  • TypeScript
  • Discord
  • OpenAI Realtime API
  • Linear
  • SQLite

A lot of my team's work starts in chat. Someone posts a bug, someone asks if a fix shipped, someone wants a reminder. Each of those needs a person to leave Discord, open the tracker or the code, and come back.

An AI teammate could do that part, but only if people can trust it. A model with tools will sometimes say "created the ticket" and never call the tool, and in a team chat, people believe the message and move on.

Dallas is a Claude agent in my team's Discord. It files tickets, reads the code, sets reminders and joins voice. Two rules shaped it. Its replies have to read like a person in a chat, and it must never say it did something it didn't.

You talk to it like a person. "Join vc." "Every weekday at 9, send me my open PRs." "In 8 hours, remind me to drink water."

Dallas lives in a private server, so every chat on this page is a recreation. The people, PRs and tickets are made up. The timing, tags and thread names come from the repo.

Typing like a person

Dallas shows that it's typing right away, and keeps the indicator alive through a long investigation. In a thread, it reads the recent messages plus the one that started the thread, because that's usually the bug report.

A Discord DM. Riley asks which PRs are open, and the message gets an eyes reaction. Dallas posts 'checking your PRs now'. After typing dots and 1.5 seconds, it posts the three PR numbers. After another 1.5 seconds, it confirms the weekday reminder.

Dallas writes the whole reply first. Then every part after the first waits behind a typing indicator and a 1.5-second pause.

That pause isn't waiting on anything. The text already exists. So a three-part answer lands three seconds later than it could, and I made that trade on purpose. Three short messages read like someone typing, and one big block arriving at once reads like a bot.

dallas this line was ready 1.5 seconds ago

Tags for Discord actions

Discord has reactions, threads, DMs, scheduled events and files. A model can only write text. So Dallas writes text with small tags in it, and a formatter turns each tag into a Discord action. It's basically a tiny programming language, with one regex per tag.

Three tags and the Discord result of each. [react:eyes] becomes an eyes reaction. Three dashes become a new message. [trigger:create:0 9 * * 1-5|dm||…] becomes a saved job for weekdays at 9:00.

Others send a DM, schedule an event or post a GIF. The formatter pulls out the tags, runs them and sends the text that's left. Long replies get split under Discord's length limit, at a blank line when there is one, so each part reads as its own message.1

Checking its own work

Dallas checks every write it makes. After it files or edits a ticket, it fetches the issue back and compares it with what it meant to write. If they don't match, it says "that didn't stick, let me try again". A real error is better than fake success.

It's also not shy. When someone shares a bug, Dallas starts digging, makes a guess and checks it, and cites file paths and line numbers as it goes. The chat runs on Haiku. PR reviews, debugging and "how does X work" go to a subagent called code-analyst, on Opus, which is slower and costs more, so it gets only those jobs.

Reading code, never changing it

The easiest way to stop a bot from claiming it changed the code is to take away its ways to change it. The edit and write tools are off, for Dallas and for the subagent.

That left the shell, and a shell can do anything. So every Bash command goes through a gate, built on one rule: Dallas may read the codebase but never change it.

The gate doesn't try to understand the shell. It refuses anything with command substitution or a redirect first. Then it splits the rest at pipes and semicolons, and every piece has to start with a read command on its list, like git log, cat, grep or rg.

Two commands go through the gate. The first splits into rg and head. Both are on the list, so the gate allows it. The second splits into ls and git push. git push is not on the list, so the gate refuses it.
Two cases from the gate's own tests

ls; git push gets just as far as rg -n "useAuth" src | head -20, but its second piece is git push, so the whole command is refused. The gate is 25 lines.

The refusal ends with "do not retry with a workaround." Tell a model no, and it goes looking for another way to do the same thing.

A recreated Discord channel. Jun asks dallas to push the upload resume fix to a branch. Dallas says that it cannot push because it is read-only. It pastes the one-line diff and says that theo owns the issue. Jun replies 'fair'.

Reminders that run fresh

A reminder could fake an answer too, by sending yesterday's again. So a reminder stores the question, not the answer. SQLite keeps who asked, the schedule and the request. When it fires, the full agent runs the request fresh, with the current date and time.

I announced reminders like a game update, in a team post called NEW DALLAS DROP. It promised that "your triggers survive dallas reboots."

A recreated Discord DM on two mornings. On October 15 at 9:00 AM, dallas lists two open PRs and their state. On October 16 at 9:00 AM, it lists one open PR and says that the other PR merged the day before. Riley replies 'ty'.

Voice and roles

Say "join vc" and Dallas joins your voice channel. Voice runs on OpenAI's Realtime API, not Claude. Leaving isn't up to the model. Dallas checks what you say against a short list of goodbyes, like "bye dallas" and "that's all dallas", and leaves two seconds after its reply. It also leaves when it's alone.

A team chat isn't only engineers, either. Dallas knows each person's role. Engineers get banter, and leadership gets straight answers. Support gets plain language and an offer to file the issue for them.

A recreated Discord channel. Jun, an engineer, asks if the upload fix shipped and gets a teasing answer about the PR that waits on maya's review. Dana, from leadership, asks the same question and gets a plain status. Sam, from support, asks about a customer's stuck upload. Sam gets a simple explanation, a workaround and an offer to add the report to the ticket.

From bug to ticket

In the bug channel, every report gets its own thread, named after the report. Dallas talks only inside the thread, so the channel stays a list of reports.

The thread shows up two seconds after the reaction

Why two seconds? Another bot in our server also opens threads. So Dallas waits, fetches the message again, and uses the other bot's thread if one has appeared.

Tickets go through Linear's MCP server, not code I wrote, and every issue has the same three parts. So when we moved from ClickUp to Linear in February, Dallas switched servers and kept filing the same tickets.

Inside the recreated thread. Dallas explains the cause with two file paths, line numbers and the PR that caused it. It files ENG-412 with a Linear link. Then it says that the first read-back had no estimate, so it set the estimate again and confirmed it. Theo replies 'grabbing it'.

Here the read-back came back with no estimate. Dallas said so, set it again and checked a second time. Nobody had to ask.

  1. Discord caps a message at 2,000 characters, so Dallas aims for 1,950. It looks for the last blank line in the second half of that space, then falls back to a line break, the end of a sentence, a space and last a hard cut. Only the last part can come out short. Anything over 8,000 characters goes up as response.md. ↩