[FEATURE] Support slash commands (/clear, /compact) from Channels (Telegram/Discord)

Open 💬 21 comments Opened Mar 22, 2026 by simonbucher

Problem

When using Claude Code via Channels (Telegram or Discord), users cannot trigger built-in CLI slash commands like /clear, /compact, or /cost. These commands are only processed when typed directly in the terminal session.

This means channel users have no way to reset conversation context, compress the context window, or check usage without asking the terminal operator to intervene — which defeats the purpose of async remote access.

Proposed Solution

Allow a configurable subset of built-in slash commands to be forwarded from channel messages to the Claude Code session. For example:

  • /clear — reset conversation context
  • /compact — compress context window
  • /cost — show token usage

These could be whitelisted in the channel plugin config (e.g., access.json) so the terminal operator controls which commands remote users can invoke.

Alternatives Considered

  • Ask the terminal operator manually — current workaround, but breaks the async workflow that Channels enables
  • Third-party bridges (e.g., claudeclaw, ccbot) add their own command forwarding, but aren't part of the official plugin

Related Issues

  • #30674 — Support slash commands (/compact) in Remote Control mobile sessions (same problem, different interface)
  • anthropics/claude-plugins-official#537 — Multiple plugins use deprecated commands instead of skills
  • #36641 — Telegram/Discord Channels support for Cowork Dispatch

View original on GitHub ↗

21 Comments

yurukusa · 3 months ago

Workaround using hooks:
You can intercept channel messages via a UserPromptSubmit hook and trigger slash command behavior:

{
  "hooks": {
    "UserPromptSubmit": [{
      "matcher": "",
      "hooks": [{
        "type": "command",
        "command": "bash ~/.claude/hooks/channel-slash.sh"
      }]
    }]
  }
}
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.user_prompt // empty')
case "$PROMPT" in
    */compact*)
        echo "Detected /compact request. Please run /compact manually." >&2
        ;;
    */cost*)
        LOG=$(ls -t ~/.claude/debug/*.txt 2>/dev/null | head -1)
        if [ -n "$LOG" ]; then
            grep -oP 'cost=\K[0-9.]+' "$LOG" | tail -1 | xargs -I{} echo "Approximate session cost: \${}" >&2
        fi
        ;;
esac
exit 0

This won't actually execute /compact (hooks can't trigger CLI commands), but it can surface the information or flag the request. For /cost, you can extract it from debug logs and return it via stderr.
True slash command forwarding would need native support — but this covers the "check status" use case.

github-actions[bot] · 3 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/28351
  2. https://github.com/anthropics/claude-code/issues/28379
  3. https://github.com/anthropics/claude-code/issues/30674

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

junaidtitan · 3 months ago

We built cozempic which removes the need to manually trigger /compact — the guard daemon auto-prunes at configurable thresholds in the background. Works independently of channels. Open-sourced, might be useful as a workaround. Feedback welcome.

Joselay · 3 months ago

+1 on this! Ran into the exact same issue using Claude Code with Telegram MCP — couldn't trigger /compact remotely when away from the terminal. Closed my duplicate #37721 in favor of this one.

crsmoore · 3 months ago

Yes please! We need to have slash commands in Discord.

stephenleo · 3 months ago

This will be massively helpful

vec715 · 3 months ago

+1, hey this is like a foundation functionality im wonder why we does not have it yet @bcherny can u help us please

vec715 · 3 months ago

<img width="741" height="212" alt="Image" src="https://github.com/user-attachments/assets/4bb5314f-4139-424f-9b9e-6b3e08c98045" />

i was going to set up the agent wiki that Andrej Karpathy mentioned on X recently, but unfortunately due to a strict and unclear limitation on command execution via channels, this idea turned out to be unfeasible for now since i was hoping on being able to call skills and commands through telegram functions as on provided screenshot

dragonfax · 3 months ago

I would love this. Right now I'm using a trigger work around in Iterm2 to make this happen.

saabendtsen · 2 months ago

+1 much needed

mararn1618 · 2 months ago

+1

Ishannaik · 2 months ago

+1 @bcherny This is the feature stopping my migration from openclaw to claude code channels

etcircle · 2 months ago

Required indeed. Otherwise, it is useless.

00al · 2 months ago

+1

ncdejito · 2 months ago

I made a /telegram:reset skill that restarts the session: sends "Claude is now ready." when the new one launches.

Open to feedback in gist link: https://gist.github.com/ncdejito/573c9d66b565d01ed852d8e323f48d9a

Install:

SKILL_DIR=$(ls -d ~/.claude/plugins/cache/claude-plugins-official/telegram/*/skills | head -1)/reset && mkdir -p "$SKILL_DIR" && curl -fsSL https://gist.githubusercontent.com/ncdejito/573c9d66b565d01ed852d8e323f48d9a/raw/cd76da3dbbeda82b33f384912e234a26e4438371/SKILL.md > "$SKILL_DIR/SKILL.md"

Then trigger with /reset in Telegram.

seanmozeik · 2 months ago

Defeats the entire point if I can't clear context surely! Even just a very small subset of /clear and /compact would go a long way

chuxin0816 · 1 month ago

+1

aaronstatic · 1 month ago

Slash commands and skills via channel are hit and miss in general. My custom channels have had to include specific instructions to load skills if one is in the message text. Proper support for them would really help make channels more useful.

artnikbrothers · 1 month ago

+1 Please let us use all commands - I would also like to be able to use /loop command

Ozziki39 · 1 day ago

Hey guys this is the fix I come up with. give it to your CC and it can do the rest for you. ALWAYS VALIDATE BEFORE DOING ANYTHING

I hope this helps. feel free to reach out with any questions.

This is the exact gap in the issue: we run several long-lived Claude Code sessions on a server and needed to trigger /compact, /clear, /context, and custom skills remotely from Discord while away from the terminal.

Hooks can't do this. A UserPromptSubmit hook can detect a /compact request but can't make the CLI execute it — the CLI only processes slash commands typed into its own input box. So the workaround has to actually put the keystrokes into that box.

What works: a tiny sidecar bot that types the command into the session's terminal for you. Each Claude Code session runs inside a named tmux session, so a separate process can inject keystrokes into that pane exactly as if the operator typed them. The CLI then processes the slash command natively. This runs alongside the Channels plugin, not through it.

The four moving parts

  1. Each CC session runs inside a named tmux sessiontmux new-session -s myagent 'claude ...'. That name is the address you inject to.
  2. A separate Discord bot registers native slash commands (discord.py app_commands). It does not share the CC process — it's a sidecar.
  3. On a command it injects into the tmux pane using a two-send pattern (the one non-obvious gotcha, below).
  4. For long commands (/compact, multi-minute skills) it polls the pane for a completion marker, then posts the pane output back to the channel.

The one gotcha: the two-send submit pattern

A single tmux send-keys "…" Enter will sometimes leave your text sitting unsubmitted in the CLI's input composer. The reliable fix is to send the payload, pause, then send a second bare Enter as a "submit nudge":

def inject(session, text):
    subprocess.run(["tmux", "send-keys", "-t", session, text, "Enter"])
    time.sleep(0.5)
    subprocess.run(["tmux", "send-keys", "-t", session, " ", "Enter"])  # submit nudge

Without the second send, remote commands intermittently appear to "do nothing." This was our single biggest source of flakiness.

Completion detection for long commands

/compact and skills take seconds to minutes. Poll the pane for a marker string from the CLI's own output, then return the tail:

def tail(session, n=40):
    r = subprocess.run(["tmux", "capture-pane", "-t", session, "-p"],
                       capture_output=True, text=True)
    return "\n".join(r.stdout.rstrip().splitlines()[-n:])
# after inject(): poll capture-pane every ~10s until a marker like "Compacted"
# appears, up to a max wait, then channel.send(tail(session)).

Two practical notes:

  • Discord interaction tokens expire at ~15 min. Post long-running results with a normal channel.send(), not an interaction followup — otherwise the reply fails on anything slow.
  • Pick markers from the CLI's own output: /compact prints "Compacted…"; custom skills can end on a sentinel line you define.

Access control — do this, it's keystroke injection

This types into a live terminal, so gate it hard:

  • Allow only specific Discord user IDs.
  • Keep an exclusion set of sessions that must never be injected, any session whose input box may hold unsent text, because your injection appends to whatever is already in the composer. (Learned the hard way: injecting into a session with a half-typed, unsubmitted message submitted a garbled combined command.)
  • Register commands to your own guild only, not globally.

Minimal reference implementation

import subprocess, time, asyncio
import discord
from discord import app_commands

ALLOWED_USER = 000000000000000000       # your Discord user id
GUILD_ID     = 000000000000000000       # your server id
EXCLUDED     = {"session-that-may-hold-unsent-input"}

def sessions():
    r = subprocess.run(["tmux","list-sessions","-F","#{session_name}"], capture_output=True, text=True)
    return r.stdout.split()

def resolve(name):                       # case-insensitive match to a live session
    return next((s for s in sessions() if s.lower() == name.strip().lower()), None)

def inject(session, text):
    subprocess.run(["tmux","send-keys","-t",session,text,"Enter"])
    time.sleep(0.5)
    subprocess.run(["tmux","send-keys","-t",session," ","Enter"])    # submit nudge

def tail(session, n=40):
    r = subprocess.run(["tmux","capture-pane","-t",session,"-p"], capture_output=True, text=True)
    return "\n".join(r.stdout.rstrip().splitlines()[-n:])

class Bridge(discord.Client):
    def __init__(self):
        super().__init__(intents=discord.Intents.default())
        self.tree = app_commands.CommandTree(self)
    async def setup_hook(self):
        g = discord.Object(id=GUILD_ID)
        self.tree.copy_global_to(guild=g)
        await self.tree.sync(guild=g)

bot = Bridge()

async def gate(inter, agent):
    if inter.user.id != ALLOWED_USER:
        await inter.response.send_message("not authorized", ephemeral=True); return None
    if agent.strip().lower() in EXCLUDED:
        await inter.response.send_message("session excluded", ephemeral=True); return None
    s = resolve(agent)
    if not s:
        await inter.response.send_message(f"no session `{agent}`; live: {', '.join(sessions())}", ephemeral=True)
    return s

async def run(inter, session, command, first_wait, markers=None, max_wait=0):
    await inter.response.defer()
    await asyncio.to_thread(inject, session, command)
    await inter.followup.send(f"`{command}` -> **{session}**")
    waited = first_wait; await asyncio.sleep(first_wait); done = not markers
    while markers and max_wait and waited < max_wait:
        if any(m in await asyncio.to_thread(tail, session, 60) for m in markers):
            done = True; break
        await asyncio.sleep(10); waited += 10
    # channel.send (NOT followup) — survives the 15-min interaction-token limit
    out = (await asyncio.to_thread(tail, session))[:1800]
    await inter.channel.send(f"**{session}** ({'done' if done else 'still running'})\n```\n{out}\n```")

@bot.tree.command(description="Run /compact in a CC session")
async def compact(inter: discord.Interaction, agent: str):
    s = await gate(inter, agent)
    if s: await run(inter, s, "/compact", first_wait=15, markers=["Compacted","compacted"], max_wait=900)

@bot.tree.command(description="Run /clear in a CC session")
async def clear(inter: discord.Interaction, agent: str):
    s = await gate(inter, agent)
    if s: await run(inter, s, "/clear", first_wait=5)

@bot.tree.command(description="Run /cost in a CC session")
async def cost(inter: discord.Interaction, agent: str):
    s = await gate(inter, agent)
    if s: await run(inter, s, "/cost", first_wait=5)     # scrapes the usage summary back

bot.run("YOUR_BOT_TOKEN")                 # load from env/file — do not hardcode

Run it as a small systemd service next to your sessions.

Honest caveats / limitations

  • Requires your CC sessions to run inside tmux (or screen with equivalent send-keys) on a host you control. Not a fit for the hosted/mobile Remote Control surface.
  • It's keystroke injection, not a real API — output is scraped from the pane, so it's best-effort text, not structured data.
  • The same approach fires custom skills and any slash command, not just built-ins — you're just typing into the CLI.
  • Telegram works identically — swap the Discord bot for a Telegram bot; the tmux half is unchanged.

Native support (what this issue requests) would be cleaner and safer than pane-scraping. Until then, this sidecar has been reliable in daily use.

etcircle · 1 day ago

Hi guys, I’ve created a fully featured CC Telegram bridge not to your local PC/MAC/Linux.

https://github.com/etcircle/cc-telegram