Driving Claude Code from LINE — Carrying My AI Workspace in My Pocket

« Previous

The idea started with a simple want: to talk to Claude Code from my phone without sitting down at a PC. Being able to toss “check the server logs” into LINE while out and have my home machine handle it — that sounded worth building.

LINE was an obvious fit. It is already on my phone, notifications just work, and typing in Japanese is fast. No separate app to open, no SSH client to wrestle with. I can ask a quick question in the middle of a conversation with someone and pick up the answer a minute later.

Overall Architecture

LINE app
   │ (Messaging API)
   ▼
LINE Platform ── Webhook ──▶ Home server (Node.js)
                                  │
                                  ▼
                            Claude Code CLI
                                  │
                                  ▼
                            Reply back to LINE

The key idea is to invoke Claude Code as a CLI, not by calling the Anthropic API directly. That way the message lands inside a real working directory — file reads and tool use just work. Ask “what does this config file say?” and it actually reads the file before answering.

Build Steps

1. Create a Messaging API channel in LINE Developers

Head to the LINE Developers console, create a provider, then add a Messaging API channel. You will need two values from there:

  • Channel access token — Messaging API settings tab → Issue
  • Channel secret — Channel basic settings tab

Turn off the default auto-reply and greeting messages. Leave them on and the bot will double-respond to every message.

2. Stand up a webhook server

Node.js + Express, minimal setup:

import express from 'express';
import { spawn } from 'child_process';
import crypto from 'crypto';

const app = express();
app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf; }
}));

app.post('/webhook', async (req, res) => {
  if (!verifySignature(req)) return res.sendStatus(401);

  const event = req.body.events?.[0];
  if (!event || event.type !== 'message') return res.sendStatus(200);

  // Send 200 immediately, then process (timeout workaround — see below)
  res.sendStatus(200);

  const reply = await runClaudeCode(event.message.text);
  await pushMessage(event.source.userId, reply);
});

function runClaudeCode(prompt) {
  return new Promise((resolve, reject) => {
    const proc = spawn('claude', ['--print', prompt], {
      cwd: '/path/to/your/project',
    });
    let output = '';
    proc.stdout.on('data', (d) => output += d.toString());
    proc.on('close', () => resolve(output.trim()));
    proc.on('error', reject);
    setTimeout(() => { proc.kill(); reject(new Error('timeout')); }, 60000);
  });
}

app.listen(3000);

Notice that res.sendStatus(200) fires before the Claude Code call. That is intentional — explained in the next section.

3. Always verify the signature

Your webhook URL is public, so you need to reject anything that did not come from LINE Platform. Check the x-line-signature header with HMAC-SHA256:

function verifySignature(req) {
  const signature = req.headers['x-line-signature'];
  if (!signature) return false;

  const hmac = crypto.createHmac('sha256', process.env.LINE_CHANNEL_SECRET);
  hmac.update(req.rawBody);
  const digest = hmac.digest('base64');

  return crypto.timingSafeEqual(
    Buffer.from(digest),
    Buffer.from(signature)
  );
}

Use crypto.timingSafeEqual rather than a plain string comparison — it prevents timing attacks. Skip signature verification entirely and your endpoint becomes an open relay the moment the URL leaks.

4. Expose it over HTTPS

On a home server, Let’s Encrypt with Nginx as a reverse proxy is the easiest path:

server {
  listen 443 ssl;
  server_name your-domain.example.com;

  ssl_certificate     /etc/letsencrypt/live/your-domain.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/your-domain.example.com/privkey.pem;

  location /webhook {
    proxy_pass http://localhost:3000;
  }
}

Register the webhook URL in the LINE Developers console and hit Verify. If it comes back green, you are connected.

Where Things Get Tricky

The 30-second reply-token timeout

LINE’s Reply Message API requires you to respond within 30 seconds of receiving the webhook, or the reply token expires. Claude Code on a long prompt blows past that easily.

The fix is to switch to the Push Message API. Store the user ID, fire back 200 immediately, let Claude Code run as long as it needs, then push the answer when it is ready. That is why the code above calls res.sendStatus(200) before doing anything else.

The 5,000-character message cap

LINE caps a single message at 5,000 characters. Code-heavy responses from Claude Code routinely exceed that. Two options: split the output into chunks and send them as separate messages, or add a “be concise” instruction to the prompt so the answer stays short to begin with.

Session management — the hard part

This one is still not fully solved.

At first I thought a simple “receive message → pass to Claude Code → send reply” loop would be enough. But once you start using it daily, you want to switch between projects mid-conversation. Something like: ask Claude to tweak a WordPress post, then pivot to checking server config, then come back to the post.

So I built a project-switching command — something like /switch server — that changes the working directory Claude Code runs in. It works, but not cleanly. The old conversation context leaks across the switch. I had a session where I was deep in WordPress plugin discussion, switched topics to server config, and got an answer that was half about WordPress for no reason — because the previous exchange was still influencing the output.

Claude Code’s --print mode is stateless, so context management is entirely on my side. The real questions are:

  • How much history do you pass in each call? Too much and old context bleeds in; too little and the model loses track of the current thread.
  • When exactly do you clear history on a project switch?
  • How do you detect an implicit topic change — one the user did not signal with a command?

Right now I am running a brute-force workaround: a /reset command that wipes the history. It works, but it is easy to forget, and when you do forget the context gets messy. A smarter solution is on the list.

How It Feels in Practice

The biggest thing I notice is just how low the friction is. No opening a laptop, no SSH login — just type a question into LINE and wait. “How much disk space is left on the server?” is the kind of thing I would not have bothered checking before because the setup cost was too high. Now I check it casually.

Within a single project the setup works well enough to be genuinely useful. The session management rough edges show up when I switch contexts carelessly, but a mindful /reset keeps things on track for now.

When I work out a better approach to the context problem I will write that up separately.

Further Reading

Leave a Comment

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