After Moving Claude Code to Slack, Here Are the Features I Kept Adding

« Previous

Last time I wrote about switching from LINE to Slack so I could run Claude Code from my phone. About a month has passed since then, and I have been adding features little by little while using it every day — taking something that originally “just barely worked” and growing it into something I rely on. This post is that sequel.

The trigger: it stumbled the moment I showed it to someone

I happened to have a reason to head to the Kanto region for a SHISHAMO concert, so I took the chance to meet up with friends from my university seminar. There I showed off the Slack bot for real — “this is what I have been building lately.” You send a message from your phone, Claude Code runs on the server at home, and a reply comes back. I will admit it was a setup I wanted to brag about a little.

But right at the moment of the big reveal, the theme switching did not work properly. My bot lets me switch the working target — between blog work and finance work, for example — and that switch hiccupped right there on the spot. Showing it to someone made friction visible that I never notice when using it alone. In the end the whole episode became a perfect “integration test.” After I got home, the things I wanted to fix and the features I wanted to add all became crystal clear at once.

A quick recap: the bot’s basic structure

Before getting into the features, a quick look at the foundation. The code from last time looked like this: receive a Slack event, verify the signature, return a 200 right away, then run the Claude Code CLI in the background and send the result back to Slack.

app.post('/slack/events', (req, res) => {
  if (req.body.type === 'url_verification') {
    return res.json({ challenge: req.body.challenge });   // (1) URL verification
  }
  if (!verifySlackSignature(req)) return res.sendStatus(403); // (2) signature check
  const event = req.body.event;
  if (!event || event.type !== 'message' || event.bot_id) {
    return res.sendStatus(200);                            // (3) ignore my own messages, etc.
  }
  res.sendStatus(200);                                    // (4) return 200 first (avoid timeout)

  const result = execSync(
    `claude --print "${event.text.replace(/"/g, '\\"')}"`,
    { cwd: '/path/to/repo', timeout: 120000 }
  ).toString();                                           // (5) run Claude Code

  postToSlack(event.channel, result);                     // (6) reply with the result
});

Everything I added from here is mostly about tinkering around that (5) claude --print … line. Think of it as adding branches for “which model,” “which folder,” and “how to show progress along the way.” Below I introduce each one alongside its processing flow.

I wanted to use up my token allowance to the last drop

The first thing I tackled was model switching. Claude Code has models with different performance — Opus, Sonnet, and Haiku — and the smarter ones consume more. The plan I am on has a weekly allowance plus a separate 5-hour allowance, and leaving any of it unused felt like a waste.

So I made it possible to pick a model based on the situation. When the allowance has room, I run Opus without hesitation for smart work; when I want to conserve, I drop to Haiku for something light. Sonnet is the balanced middle option. The whole point is to “use up the allowance cleanly,” and once I could do this, the nervous feeling of watching my remaining quota disappeared.

The processing itself is nothing complicated. The Claude Code CLI has a --model option, so I just grab the model name specified at the start of the Slack message and pass it straight through.

// e.g. specify at the start: "opus: check the logs", "haiku: what does this config say?"
function parseModel(text) {
  const m = text.match(/^(opus|sonnet|haiku)\s*[::]\s*/i);
  if (!m) return { model: 'sonnet', body: text };   // no prefix → default model
  return { model: m[1].toLowerCase(), body: text.slice(m[0].length) };
}

const { model, body } = parseModel(event.text);
const result = execSync(
  `claude --print --model ${model} "${body.replace(/"/g, '\\"')}"`,
  { cwd: currentDir, timeout: 120000 }
).toString();

As a flow, it looks like this.

Receive Slack message
   │  detect a leading "opus:" etc.
   ▼
Decide the model (no prefix → default)
   │
   ▼
Run claude --print --model <model>
   │
   ▼
Reply to Slack with the result

And the theme switching that stumbled at the concert works the same way under the hood. Instead of a model, it switches “which folder (repository) to run in,” simply swapping the cwd of execSync. The working directory differs between blog work and finance work, so a command rewrites a currentDir state before running. The reason it stumbled was that “which folder am I in right now” was hard to tell from the messages; now the bot replies with its current location whenever it switches.

Chatting from a phone burns through tokens faster than I expected

One thing I noticed while using it continuously: running it through Slack consumes tokens faster than running it through the VS Code extension. Because the exchanges pile up in a conversational format, they inevitably balloon.

So I added a few mechanisms to conserve. Keeping a lighter model as the everyday default is one; another is a mechanism that automatically summarizes and compresses the context when a conversation gets long. This area is less about “running smart” and more about “running without waste” — unglamorous, but effective.

Since I use it conversationally, instead of recreating the Claude Code session every time, I remember a session ID per channel and connect them with --continue. When the exchange gets long and heavy, I insert a step that summarizes once and hands off to a new session.

const session = sessions[event.channel] || {};
session.turns = (session.turns || 0) + 1;

// once the exchange exceeds a threshold, summarize and compress the history so far
if (session.turns > THRESHOLD) {
  const summary = execSync(
    `claude --print --model haiku "Summarize the conversation so far in 3 lines"`,
    { cwd: currentDir }
  ).toString();
  session.context = summary;   // drop the heavy history, carry only the summary
  session.turns = 0;
}
sessions[event.channel] = session;

The key point is that the summarizing itself is handed to the lightest model, Haiku. It would defeat the purpose to “eat into the allowance with a process meant to save it,” so cheap jobs go to cheap models. As a flow, it is just one extra branch placed ahead of the main processing: “once the exchange exceeds a threshold → summarize with Haiku → carry only the summary from then on.”

Notifications to fix “I can’t tell if it’s working”

This one was actually pretty stressful: after sending a message, there was a stretch of time where I could not tell whether the bot was actually working. Silence until the reply came, with no way to judge whether it was frozen or thinking.

So I made it show progress on mobile. Just seeing how far along it is on my phone makes the wait feel completely different. Half of what stumbled when I showed it to someone was this “I can’t see the state” problem.

Here the way the code is written changed a notch. Last time it was built with execSync to “wait until it finishes running,” so I could not say anything until it was done. I changed this to spawn so I could receive Claude Code’s output line by line, and stream progress to Slack at key points.

const { spawn } = require('child_process');

postToSlack(event.channel, 'Received. Processing…');  // acknowledgment

const child = spawn('claude', ['--print', '--model', model, body], { cwd: currentDir });
let buffer = '';

child.stdout.on('data', chunk => {
  buffer += chunk.toString();
  // send progress at tool-run or section boundaries
  if (/(running|done|→)/.test(chunk.toString())) {
    postToSlack(event.channel, `…${chunk.toString().trim()}`);
  }
});

child.on('close', () => {
  postToSlack(event.channel, buffer.trim());   // final result
});

As a flow, an “acknowledgment” and “progress updates” now slot into what used to be silence.

Receive message
   ▼
Send "Received. Processing…" immediately   ← added
   ▼
Launch claude via spawn, receive output incrementally
   ▼
Stream progress to Slack at each boundary  ← added
   ▼
Send the final result to Slack when done

What it does is simple, but just changing “kept waiting in silence” into “a live play-by-play streaming in” makes the experience feel like a completely different thing.

Making it possible to throw screenshots at it

Last is attachment support. The trigger was exactly that SHISHAMO setlist. After the concert, I wanted to throw the setlist screenshot straight at the bot and have it process it. Being able to pass images, not just text, expands what you can do all at once. These days, taking a screenshot of my screen and throwing it straight in has become the norm.

As for the mechanism: if a Slack event includes an attachment, I download the image to the home server once and pass the file path to Claude Code. Slack images are authenticated URLs (url_private), so a small gotcha is that you need to attach the Bot token when downloading.

// if event.files has attachments, fetch the images before passing them to Claude
let imagePaths = [];
if (event.files && event.files.length) {
  for (const f of event.files) {
    const resp = await fetch(f.url_private, {
      headers: { Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}` }, // auth required
    });
    const path = `/tmp/${f.id}_${f.name}`;
    fs.writeFileSync(path, Buffer.from(await resp.arrayBuffer()));
    imagePaths.push(path);
  }
}

// pass the image paths along to Claude Code
const args = ['--print', '--model', model, body, ...imagePaths];
const child = spawn('claude', args, { cwd: currentDir });

The flow is this.

Post to Slack with a screenshot
   ▼
Get url_private from event.files
   ▼
Attach the Bot token and download the image to the server
   ▼
Add the saved path to the arguments and run claude
   ▼
Reply to Slack with the result of reading the image

Throw in a setlist screenshot and it reads the song titles and formats them for the blog. The hassle of retyping the text disappears — and unglamorous as it is, this is what I use most in daily life.

From an enthusiast’s toy to an everyday tool

Having added all of this, what I feel is that chatting from my phone via Slack ends up being the fullest way to use it. There is no need to sit down at a PC and fire up an extension. When something pops into my head, I throw it from my phone, and the server at home gets to work on its own.

Somehow it has the same feel as when the internet first arrived at home. Something that started as a hobby for a niche group of enthusiasts has, before I knew it, melted into everyday life. It is close to that feeling.

And one more thing I feel strongly: “as long as you understand the concept, you can have Claude build the implementation.” Most of the features I added this time were not something I wrote line by line; rather, I conveyed the direction I wanted and had them take shape. As long as the ideas and the sequencing are in my own head, the hands-on part can largely be delegated. I keep realizing, while growing my own tool, that this is the era we now live in.

Leave a Comment

Your email address will not be published. Required fields are marked *