diff --git a/code_puppy/api/app.py b/code_puppy/api/app.py
index e75435ec0..8f749eb18 100644
--- a/code_puppy/api/app.py
+++ b/code_puppy/api/app.py
@@ -138,6 +138,9 @@ async def root():
Open Terminal
+
+ Mobile UI
+
API Docs
@@ -162,6 +165,17 @@ async def terminal_page():
status_code=404,
)
+ @app.get("/mobile")
+ async def mobile_page():
+ """Serve the mobile-optimised web UI (chat-style, touch-friendly)."""
+ html_file = templates_dir / "mobile.html"
+ if html_file.exists():
+ return FileResponse(html_file, media_type="text/html")
+ return HTMLResponse(
+ content="
Mobile template not found
",
+ status_code=404,
+ )
+
@app.get("/health")
async def health():
return {"status": "healthy"}
diff --git a/code_puppy/api/templates/mobile.html b/code_puppy/api/templates/mobile.html
new file mode 100644
index 000000000..41d6a4678
--- /dev/null
+++ b/code_puppy/api/templates/mobile.html
@@ -0,0 +1,585 @@
+
+
+
+
+
+
+
+
+
+ Code Puppy
+
+
+
+
+
+
+
+
+
+
+
Connect to the server to start chatting.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code_puppy/integrations/__init__.py b/code_puppy/integrations/__init__.py
new file mode 100644
index 000000000..dfaa17b5d
--- /dev/null
+++ b/code_puppy/integrations/__init__.py
@@ -0,0 +1 @@
+"""Optional integrations for Code Puppy (Slack, Teams, etc.)."""
diff --git a/code_puppy/integrations/slack_bot.py b/code_puppy/integrations/slack_bot.py
new file mode 100644
index 000000000..47c1d3e0c
--- /dev/null
+++ b/code_puppy/integrations/slack_bot.py
@@ -0,0 +1,340 @@
+"""Slack bot integration for Code Puppy.
+
+Bridges Slack messages/slash commands to the Code Puppy backend via
+the existing WebSocket terminal API. Each Slack thread maps to one
+PTY session so conversations have context.
+
+Setup
+-----
+1. Install extras: pip install "code-puppy[slack]"
+2. Create a Slack app at https://api.slack.com/apps
+ - Enable Socket Mode (Settings → Socket Mode)
+ - Add Bot Token Scopes: app_mentions:read, chat:write,
+ channels:history, groups:history, im:history, commands
+ - Add Slash Command: /pup → any request URL (Socket Mode ignores it)
+ - Install to workspace → copy Bot Token + App Token
+3. Set env vars:
+ SLACK_BOT_TOKEN=xoxb-...
+ SLACK_APP_TOKEN=xapp-...
+ CODE_PUPPY_WS_URL=ws://localhost:8765/ws/terminal (default)
+4. Run:
+ python -m code_puppy.integrations.slack_bot
+
+Usage in Slack
+--------------
+- Mention the bot: @CodePuppy fix the login bug in auth.py
+- Slash command: /pup explain the retry logic in circuit_breaker.py
+- Reply in thread: just reply — same session resumes
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import logging
+import os
+import re
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# ANSI / terminal noise stripping
+# ---------------------------------------------------------------------------
+
+# Covers CSI sequences (colours, cursor moves, etc.) and OSC sequences
+_ANSI_RE = re.compile(
+ r"\x1b(?:"
+ r"\[[0-9;?]*[A-Za-z]" # CSI
+ r"|\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC
+ r"|[@-_][0-9;]*[A-Za-z]?" # other Fe sequences
+ r"|\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]" # param + final
+ r")"
+)
+# Carriage returns, backspaces, and other control characters except \n and \t
+_CTRL_RE = re.compile(r"[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]")
+# Collapse duplicate blank lines
+_BLANK_LINES_RE = re.compile(r"\n{3,}")
+
+
+def strip_ansi(raw: bytes) -> str:
+ """Decode bytes, strip ANSI escape sequences and control chars."""
+ text = raw.decode("utf-8", errors="replace")
+ text = _ANSI_RE.sub("", text)
+ text = _CTRL_RE.sub("", text)
+ text = _BLANK_LINES_RE.sub("\n\n", text)
+ return text.strip()
+
+
+# ---------------------------------------------------------------------------
+# PTY session client (wraps /ws/terminal)
+# ---------------------------------------------------------------------------
+
+# How long to wait after the last output chunk before declaring the
+# response "done" and returning it to Slack.
+_OUTPUT_IDLE_TIMEOUT = 3.0
+# Hard upper limit per message
+_OUTPUT_HARD_TIMEOUT = 120.0
+# Maximum chars of output to post back (Slack block limit ~3000)
+_MAX_OUTPUT_CHARS = 2800
+
+
+class PtySession:
+ """Manages a single code_puppy PTY WebSocket session."""
+
+ def __init__(self, ws_url: str) -> None:
+ self._ws_url = ws_url
+ self._ws: Any = None
+ self._session_id: str | None = None
+
+ async def connect(self) -> None:
+ try:
+ import websockets # type: ignore[import]
+ except ImportError as exc:
+ raise RuntimeError(
+ "websockets package is required: pip install websockets"
+ ) from exc
+
+ self._ws = await websockets.connect(self._ws_url)
+ # First message is always the session handshake
+ msg = await self._ws.recv()
+ import json
+
+ data = json.loads(msg)
+ if data.get("type") == "session":
+ self._session_id = data["id"]
+ logger.info("PTY session connected: %s", self._session_id)
+
+ async def send_prompt(self, text: str) -> str:
+ """Send *text* to the PTY and collect output until idle."""
+ import json
+
+ if self._ws is None:
+ raise RuntimeError("Not connected — call connect() first")
+
+ # Send input + newline
+ await self._ws.send(json.dumps({"type": "input", "data": text + "\n"}))
+
+ chunks: list[str] = []
+ deadline = asyncio.get_event_loop().time() + _OUTPUT_HARD_TIMEOUT
+
+ while True:
+ remaining = deadline - asyncio.get_event_loop().time()
+ if remaining <= 0:
+ break
+ try:
+ raw_msg = await asyncio.wait_for(
+ self._ws.recv(), timeout=min(_OUTPUT_IDLE_TIMEOUT, remaining)
+ )
+ msg = json.loads(raw_msg)
+ if msg.get("type") == "output":
+ raw_bytes = base64.b64decode(msg["data"])
+ chunks.append(strip_ansi(raw_bytes))
+ except asyncio.TimeoutError:
+ # No new output for _OUTPUT_IDLE_TIMEOUT → response is done
+ break
+
+ output = "\n".join(chunks).strip()
+ # Truncate if too long
+ if len(output) > _MAX_OUTPUT_CHARS:
+ output = output[:_MAX_OUTPUT_CHARS] + "\n…(truncated)"
+ return output
+
+ async def close(self) -> None:
+ if self._ws:
+ await self._ws.close()
+ self._ws = None
+
+
+# ---------------------------------------------------------------------------
+# Session registry (thread_ts → PtySession)
+# ---------------------------------------------------------------------------
+
+
+class SessionRegistry:
+ """Maps Slack thread timestamps to persistent PTY sessions."""
+
+ def __init__(self, ws_url: str) -> None:
+ self._ws_url = ws_url
+ self._sessions: dict[str, PtySession] = {}
+
+ async def get_or_create(self, thread_ts: str) -> PtySession:
+ if thread_ts not in self._sessions:
+ session = PtySession(self._ws_url)
+ await session.connect()
+ self._sessions[thread_ts] = session
+ logger.info("New PTY session for thread %s", thread_ts)
+ return self._sessions[thread_ts]
+
+ async def close_all(self) -> None:
+ for session in self._sessions.values():
+ await session.close()
+ self._sessions.clear()
+
+
+# ---------------------------------------------------------------------------
+# Slack app
+# ---------------------------------------------------------------------------
+
+
+def _format_response(text: str) -> list[dict]:
+ """Wrap response in Slack Block Kit blocks."""
+ if not text:
+ text = "_(no output)_"
+
+ # Split into code and prose blocks heuristically
+ blocks: list[dict] = []
+ code_re = re.compile(r"(```[\s\S]*?```)", re.MULTILINE)
+ parts = code_re.split(text)
+
+ for part in parts:
+ part = part.strip()
+ if not part:
+ continue
+ if part.startswith("```") and part.endswith("```"):
+ inner = part[3:-3].strip()
+ blocks.append(
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_preformatted",
+ "elements": [{"type": "text", "text": inner}],
+ }
+ ],
+ }
+ )
+ else:
+ blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": part}})
+
+ if not blocks:
+ blocks.append(
+ {"type": "section", "text": {"type": "mrkdwn", "text": "_(no output)_"}}
+ )
+ return blocks
+
+
+def create_slack_app(ws_url: str) -> Any:
+ """Build and return a configured Slack Bolt App."""
+ try:
+ from slack_bolt import App # type: ignore[import]
+ from slack_bolt.adapter.socket_mode import SocketModeHandler # noqa: F401
+ except ImportError as exc:
+ raise RuntimeError(
+ "slack-bolt is required: pip install 'code-puppy[slack]'"
+ ) from exc
+
+ bot_token = os.environ.get("SLACK_BOT_TOKEN")
+ app_token = os.environ.get("SLACK_APP_TOKEN")
+
+ if not bot_token or not app_token:
+ raise RuntimeError(
+ "Set SLACK_BOT_TOKEN and SLACK_APP_TOKEN environment variables."
+ )
+
+ app = App(token=bot_token)
+ registry = SessionRegistry(ws_url)
+
+ async def _handle(text: str, thread_ts: str, say: Any) -> None:
+ """Core handler: route text to PTY, post response."""
+ try:
+ session = await registry.get_or_create(thread_ts)
+ thinking = await say(
+ text=":hourglass_flowing_sand: Working on it…",
+ thread_ts=thread_ts,
+ )
+ output = await session.send_prompt(text)
+ blocks = _format_response(output)
+ await say(
+ blocks=blocks,
+ text=output[:200], # fallback text for notifications
+ thread_ts=thread_ts,
+ )
+ # Delete the "thinking" placeholder
+ try:
+ await app.client.chat_delete(
+ channel=thinking["channel"],
+ ts=thinking["ts"],
+ )
+ except Exception:
+ pass
+ except Exception as exc:
+ logger.exception("Error handling Slack message")
+ await say(
+ text=f":x: Error: {exc}",
+ thread_ts=thread_ts,
+ )
+
+ # --- app_mention handler ---
+ @app.event("app_mention")
+ def handle_mention(event: dict, say: Any) -> None:
+ text = re.sub(r"<@[A-Z0-9]+>", "", event.get("text", "")).strip()
+ thread_ts = event.get("thread_ts") or event["ts"]
+ asyncio.run(_handle(text, thread_ts, say))
+
+ # --- direct message handler ---
+ @app.event("message")
+ def handle_dm(event: dict, say: Any) -> None:
+ # Ignore bot messages and subtypes (edits, deletes, etc.)
+ if event.get("bot_id") or event.get("subtype"):
+ return
+ # Only handle DMs (channel_type == "im") or thread replies
+ channel_type = event.get("channel_type", "")
+ if channel_type not in ("im",) and not event.get("thread_ts"):
+ return
+ text = event.get("text", "").strip()
+ thread_ts = event.get("thread_ts") or event["ts"]
+ asyncio.run(_handle(text, thread_ts, say))
+
+ # --- /pup slash command ---
+ @app.command("/pup")
+ def handle_slash_pup(ack: Any, respond: Any, command: dict) -> None:
+ ack()
+ text = command.get("text", "").strip()
+ if not text:
+ respond(text="Usage: `/pup `")
+ return
+ # Use command ts as thread key (each slash command is its own thread)
+ thread_ts = str(command.get("trigger_id", "global"))
+
+ async def _run() -> None:
+ try:
+ session = await registry.get_or_create(thread_ts)
+ output = await session.send_prompt(text)
+ blocks = _format_response(output)
+ respond(blocks=blocks, text=output[:200])
+ except Exception as exc:
+ logger.exception("Error handling /pup command")
+ respond(text=f":x: Error: {exc}")
+
+ asyncio.run(_run())
+
+ return app, app_token, registry
+
+
+def main() -> None:
+ """Entry point: start the Slack bot in Socket Mode."""
+ logging.basicConfig(level=logging.INFO)
+ ws_url = os.environ.get(
+ "CODE_PUPPY_WS_URL", "ws://localhost:8765/ws/terminal"
+ )
+
+ try:
+ from slack_bolt.adapter.socket_mode import SocketModeHandler
+ except ImportError as exc:
+ raise SystemExit(
+ "Install slack extras first: pip install 'code-puppy[slack]'"
+ ) from exc
+
+ app, app_token, registry = create_slack_app(ws_url)
+
+ logger.info("🐶 Code Puppy Slack bot starting (Socket Mode)…")
+ handler = SocketModeHandler(app, app_token)
+ try:
+ handler.start()
+ finally:
+ asyncio.run(registry.close_all())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/integrations/slack.md b/docs/integrations/slack.md
new file mode 100644
index 000000000..28f20d4d6
--- /dev/null
+++ b/docs/integrations/slack.md
@@ -0,0 +1,87 @@
+# Slack Integration
+
+Connect Code Puppy to a Slack workspace so anyone can run prompts from any channel or DM — on desktop or mobile.
+
+## How it works
+
+```
+Slack message / /pup command
+ ↓
+Slack Bolt (Socket Mode)
+ ↓
+WebSocket → /ws/terminal (existing PTY backend)
+ ↓
+Code Puppy agent runs
+ ↓
+Output streamed back → posted to Slack thread
+```
+
+Each **Slack thread** maps to one persistent PTY session, so follow-up replies in the same thread have full context.
+
+---
+
+## Setup
+
+### 1. Create a Slack App
+
+1. Go to https://api.slack.com/apps → **Create New App** → **From scratch**
+2. Under **Settings → Socket Mode** → enable it → generate an **App-Level Token** with `connections:write` scope. Save as `SLACK_APP_TOKEN`.
+3. Under **OAuth & Permissions → Bot Token Scopes**, add:
+ - `app_mentions:read`
+ - `chat:write`
+ - `channels:history`
+ - `groups:history`
+ - `im:history`
+ - `commands`
+4. Under **Slash Commands** → **Create New Command**:
+ - Command: `/pup`
+ - Request URL: `https://example.com` (ignored in Socket Mode)
+ - Short description: `Ask Code Puppy`
+5. Under **Event Subscriptions** → enable, then **Subscribe to bot events**: add `app_mention` and `message.im`
+6. **Install to Workspace** → copy the **Bot User OAuth Token**. Save as `SLACK_BOT_TOKEN`.
+
+### 2. Install the Slack extra
+
+```bash
+pip install "code-puppy[slack]"
+```
+
+### 3. Set environment variables
+
+```bash
+export SLACK_BOT_TOKEN=xoxb-...
+export SLACK_APP_TOKEN=xapp-...
+export CODE_PUPPY_WS_URL=ws://localhost:8765/ws/terminal # default
+```
+
+### 4. Start the Code Puppy API server
+
+```bash
+pup --api # or however you start the FastAPI server
+```
+
+### 5. Start the Slack bot
+
+```bash
+python -m code_puppy.integrations.slack_bot
+```
+
+---
+
+## Usage
+
+| How | What to do |
+|-----|------------|
+| **Mention** | `@CodePuppy fix the login bug in auth.py` |
+| **DM** | Send any message directly to the bot |
+| **Slash command** | `/pup explain the retry logic in circuit_breaker.py` |
+| **Thread reply** | Reply in the same thread — session is preserved |
+
+---
+
+## Notes
+
+- Responses are collected for up to **3 seconds of idle output**, then posted. For long-running tasks the bot will post partial output if the hard timeout (120 s) is reached.
+- Output longer than ~2 800 characters is truncated with a notice.
+- Code fences (` ``` `) in the output are rendered as Slack code blocks.
+- The bot ignores its own messages and edited/deleted message events to avoid loops.
diff --git a/pyproject.toml b/pyproject.toml
index bc5ec38dc..9e1291b45 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,9 @@ dependencies = [
"Pillow>=10.0.0",
"anthropic==0.79.0"
]
+[project.optional-dependencies]
+slack = ["slack-bolt>=1.18.0"]
+
dev-dependencies = [
"pytest>=8.3.4",
"pytest-cov>=6.1.1",