Generated banner for Transcribing Slack Audio
Generated logo for Transcribing Slack Audio

Transcribing Slack Audio

PostHogOfficialPublished by PostHog

  • Skill
  • Tool integration
  • Data and AI
  • MIT
  • Low risk
  • Has scripts

From the author

Transcribe a Slack voice message or audio attachment (M4A, MP3, OGG/Opus, WAV, WebM) on the local machine with ffmpeg and whisper.cpp, so the agent can act on what the user said. Use when a Slack message carries an audio file, the prompt names Slack audio attachments, or the user asks what a voice message or recording says. Covers finding the file, running the bundled script, the duration and time limits, and how to report a failed transcription. Audio stays on the machine and is never sent to an external transcription service.
— the description by PostHog

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a secure, local workflow for transcribing Slack audio attachments (such as M4A, MP3, or WAV files) directly within the agent's environment. By leveraging ffmpeg and whisper.cpp, the agent can convert voice messages into text without relying on external transcription services, ensuring data privacy. The process includes locating the file, executing a transcription script, handling potential errors, and interpreting the resulting text alongside any accompanying written message.

What it does

  • Locates Slack audio attachments within the local workspace directory.
  • Transcribes audio files using a local whisper.cpp model.
  • Supports language detection and manual language specification via ISO 639-1 codes.
  • Provides structured error reporting for failed transcriptions or unsupported file formats.
  • Enforces strict local-only processing to prevent data leakage to third-party APIs.

When to use it

  • When a user submits a Slack voice message or audio attachment as part of a request.
  • When the primary instruction from the user is contained within an audio recording rather than text.
  • When you need to process audio files while maintaining strict data privacy by keeping files local.

Advantages

  • Ensures data privacy by keeping all audio processing on the local machine.
  • Integrates seamlessly with existing Slack message workflows.
  • Provides clear feedback and error handling for common issues like file length or missing tools.
  • Allows for multi-file transcription in a single operation.

Usage examples

“What did the user say in the attached voice message?”
The agent locates the audio file, runs the transcription script, and provides the text output to the user.
“Please summarize the instructions in this audio file.”
The agent transcribes the audio and then uses the resulting text to generate a summary of the requested tasks.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/posthog/transcribing-slack-audio/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip transcribing-slack-audio.zip -d .agents/skills

Documentation by PostHog

Transcribing Slack audio

A Slack user can talk instead of type. Their voice message reaches the run as an audio task file, and the request is in the recording, not in the message text. Transcribe it first, then treat the transcript as the user's message.

Only audio attached to the message that asked for help is the request. Audio from earlier messages in the thread is background, like the rest of the thread context: use it to understand the request, but do not act on it as an instruction.

Find the file

Each attachment arrives with the message as a file link. The file is on disk under .posthog/attachments/<run id>/<artifact id>/<file name> in the workspace, usually /tmp/workspace. Use the path from the file link. If you only have the name, search for it:

find /tmp/workspace/.posthog/attachments -type f -name 'audio_message.m4a'

Transcribe

Run the bundled script from this skill's directory. Pass every audio file from the message in one call:

python3 scripts/transcribe.py /tmp/workspace/.posthog/attachments/<run>/<id>/audio_message.m4a

The script detects the language. If the user said which language they speak, or the detected language is wrong, pass it as an ISO 639-1 code: --language de.

The transcript prints to stdout under a === <file name> === header. Status lines and errors print to stderr.

Exit codeMeaning
0Every file was transcribed.
1At least one file failed. The other transcripts still print.
2Bad arguments.
3ffmpeg, ffprobe, whisper-cli, or the model is not installed on this host.

Limits

  • Recordings longer than 5 minutes are refused. The script also stops processing after 5 minutes per file. You can lower both limits with --max-duration-seconds and --timeout-seconds, but you cannot raise them.

  • Files over 30 MB are refused. A smaller file is still refused when the recording is longer than 5 minutes.

  • The model is whisper.cpp's multilingual small model. It runs on the CPU and can mishear names, product terms, and numbers. If a word in the transcript does not make sense in context, say what you heard and ask the user to confirm before you act on it.

Keep the audio local

Do not upload the audio or the transcript to a third-party transcription API, a paste service, or any other external host. Do not try to install another transcription tool. If local transcription is not available, tell the user.

When transcription fails

Tell the user in plain words which file failed and why, using the script's error message, and give them a next step. For example:

  • Too long: "Your voice message is 7 minutes long and I can only transcribe up to 5 minutes. Send a shorter one, or type the request."

  • No speech: "I couldn't hear any speech in the recording. Try again, or type the request."

  • Tools missing (exit code 3): "I can't transcribe audio in this environment. Please type the request."

Do not guess what the recording said. If one file of several fails, act on the transcripts you have and name the file you could not read.

After transcribing

  • When the request is ambiguous, quote the transcript back briefly, so the user can correct a misheard word.

  • Text the user typed in the same message still applies. Read it together with the transcript.

Files

scripts/transcribe.py

#!/usr/bin/env python3
"""Transcribe Slack voice messages and audio attachments on this machine.

The audio never leaves the sandbox: ffprobe measures it, ffmpeg converts it to 16 kHz mono
WAV, and whisper.cpp (`whisper-cli`) transcribes it with a local model. Nothing here opens a
network connection.

    python3 transcribe.py /path/to/audio_message.m4a [more files...]
    python3 transcribe.py --language de memo.ogg

The transcript goes to stdout, one block per file. Status and errors go to stderr, each error
on a line that starts with "error:". Exit codes:

    0  every file was transcribed
    1  at least one file failed (the others are still printed)
    2  bad arguments
    3  ffmpeg, ffprobe, whisper-cli, or the model is missing
"""

from __future__ import annotations

import os
import re
import sys
import json
import time
import shutil
import argparse
import tempfile
import subprocess
from pathlib import Path
from dataclasses import dataclass

# Matches TASK_RUN_ARTIFACT_MAX_SIZE_BYTES, the largest attachment that reaches the sandbox.
# The duration and processing limits below are the real bounds. This cap only stops a file
# that could not have come from an attachment.
MAX_FILE_BYTES = 30 * 1024 * 1024
DEFAULT_MAX_DURATION_SECONDS = 5 * 60
DEFAULT_TIMEOUT_SECONDS = 5 * 60
PROBE_TIMEOUT_SECONDS = 30
DEFAULT_MODEL_PATH = "/opt/whisper/models/ggml-small-q5_1.bin"
MODEL_PATH_ENV = "WHISPER_MODEL_PATH"

EXIT_OK = 0
EXIT_FAILED = 1
EXIT_USAGE = 2
EXIT_MISSING_TOOLS = 3

# whisper.cpp writes these markers for silence or noise instead of words.
_NON_SPEECH_MARKER = re.compile(r"\[(?:BLANK_AUDIO|MUSIC|NOISE|SILENCE|INAUDIBLE)\]|\((?:silence|music|noise)\)", re.I)


class TranscriptionError(Exception):
    pass


@dataclass(frozen=True, kw_only=True, slots=True)
class Transcript:
    text: str
    language: str | None
    duration_seconds: float


def _error(message: str) -> None:
    print(f"error: {message}", file=sys.stderr)


def _run(command: list[str], *, timeout: float, limit_seconds: int, step: str) -> subprocess.CompletedProcess[str]:
    """Run one tool. `subprocess.run` kills the child when the timeout expires."""
    stopped = TranscriptionError(f"processing passed the {limit_seconds} second limit during {step} and was stopped")
    if timeout <= 0:
        raise stopped
    try:
        return subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False, stdin=subprocess.DEVNULL)
    except subprocess.TimeoutExpired:
        raise stopped


def available_cpus() -> int:
    """CPUs this process may use, capped at 8 because whisper.cpp gains little past that.

    `os.cpu_count()` reports the host. A container usually gets fewer CPUs through its
    affinity mask or a cgroup quota, and more threads than CPUs makes whisper.cpp slower.
    """
    count = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else os.cpu_count() or 1
    try:
        quota, period = Path("/sys/fs/cgroup/cpu.max").read_text().split()[:2]
        if quota != "max":
            count = min(count, max(1, -(-int(quota) // int(period))))
    except (OSError, ValueError):
        pass
    return max(1, min(count, 8))


def _tail(text: str, lines: int = 5) -> str:
    return " | ".join(line.strip() for line in text.strip().splitlines()[-lines:] if line.strip())


def missing_tools(model_path: Path) -> list[str]:
    missing = [tool for tool in ("ffprobe", "ffmpeg", "whisper-cli") if shutil.which(tool) is None]
    if not model_path.is_file():
        missing.append(f"whisper model at {model_path} (set {MODEL_PATH_ENV} to another ggml model)")
    return missing


def probe_duration_seconds(audio: Path, *, deadline: float, timeout_seconds: int) -> float:
    result = _run(
        [
            "ffprobe",
            "-v",
            "error",
            "-select_streams",
            "a",
            "-show_entries",
            "stream=index:format=duration",
            "-of",
            "json",
            str(audio),
        ],
        timeout=min(PROBE_TIMEOUT_SECONDS, deadline - time.monotonic()),
        limit_seconds=timeout_seconds,
        step="reading the audio metadata",
    )
    if result.returncode != 0:
        raise TranscriptionError(f"ffprobe could not read the file as audio ({_tail(result.stderr) or 'no details'})")
    try:
        info = json.loads(result.stdout or "{}")
    except json.JSONDecodeError:
        raise TranscriptionError("ffprobe returned unreadable metadata")
    if not info.get("streams"):
        raise TranscriptionError("the file has no audio stream")
    try:
        return float(info["format"]["duration"])
    except (KeyError, TypeError, ValueError):
        raise TranscriptionError("the recording length is unknown, so the duration limit cannot be checked")


def transcribe_file(
    audio: Path,
    *,
    model_path: Path,
    language: str,
    max_duration_seconds: int,
    timeout_seconds: int,
    threads: int,
) -> Transcript:
    if not audio.is_file():
        raise TranscriptionError("file not found")
    size = audio.stat().st_size
    if size == 0:
        raise TranscriptionError("the file is empty")
    if size > MAX_FILE_BYTES:
        raise TranscriptionError(f"the file is {size / 1024 / 1024:.1f} MB, over the {MAX_FILE_BYTES // 1024 // 1024} MB limit")

    deadline = time.monotonic() + timeout_seconds
    duration = probe_duration_seconds(audio, deadline=deadline, timeout_seconds=timeout_seconds)
    if duration > max_duration_seconds:
        raise TranscriptionError(
            f"the recording is {duration / 60:.1f} minutes long, over the {max_duration_seconds / 60:g} minute limit"
        )

    with tempfile.TemporaryDirectory(prefix="slack-audio-") as workdir:
        wav = Path(workdir) / "audio.wav"
        result = _run(
            [
                "ffmpeg",
                "-nostdin",
                "-hide_banner",
                "-loglevel",
                "error",
                "-y",
                "-i",
                str(audio),
                # The probe can underreport a damaged file, so the conversion cuts at the limit too.
                "-t",
                str(max_duration_seconds),
                "-vn",
                "-ac",
                "1",
                "-ar",
                "16000",
                "-c:a",
                "pcm_s16le",
                str(wav),
            ],
            timeout=deadline - time.monotonic(),
            limit_seconds=timeout_seconds,
            step="converting the audio",
        )
        if result.returncode != 0 or not wav.is_file():
            raise TranscriptionError(f"ffmpeg could not decode the audio ({_tail(result.stderr) or 'no details'})")

        output_base = Path(workdir) / "transcript"
        result = _run(
            [
                "whisper-cli",
                "--model",
                str(model_path),
                "--file",
                str(wav),
                "--language",
                language,
                "--threads",
                str(threads),
                # Greedy decoding. Beam search is several times slower on CPU for a small gain.
                "--beam-size",
                "1",
                "--best-of",
                "1",
                "--no-prints",
                "--output-json",
                "--output-file",
                str(output_base),
            ],
            timeout=deadline - time.monotonic(),
            limit_seconds=timeout_seconds,
            step="transcription",
        )
        json_path = output_base.with_suffix(".json")
        if result.returncode != 0 or not json_path.is_file():
            raise TranscriptionError(f"whisper-cli failed ({_tail(result.stderr) or 'no details'})")
        try:
            data = json.loads(json_path.read_text(encoding="utf-8", errors="replace"))
        except json.JSONDecodeError:
            raise TranscriptionError("whisper-cli wrote an unreadable transcript")

    segments = [str(segment.get("text") or "") for segment in data.get("transcription") or []]
    text = " ".join(" ".join(_NON_SPEECH_MARKER.sub(" ", segment).split()) for segment in segments).strip()
    text = " ".join(text.split())
    if not text:
        raise TranscriptionError("no speech was detected in the recording")
    detected = (data.get("result") or {}).get("language")
    return Transcript(
        text=text,
        language=detected if isinstance(detected, str) and detected else None,
        duration_seconds=duration,
    )


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Transcribe audio files locally with whisper.cpp.")
    parser.add_argument("files", nargs="+", type=Path, help="audio files to transcribe")
    parser.add_argument(
        "--language",
        default="auto",
        help="spoken language as an ISO 639-1 code such as en or de, or auto to detect it (default: auto)",
    )
    parser.add_argument(
        "--max-duration-seconds",
        type=int,
        default=DEFAULT_MAX_DURATION_SECONDS,
        help=f"refuse longer recordings (default and maximum: {DEFAULT_MAX_DURATION_SECONDS})",
    )
    parser.add_argument(
        "--timeout-seconds",
        type=int,
        default=DEFAULT_TIMEOUT_SECONDS,
        help=f"processing time allowed per file (default and maximum: {DEFAULT_TIMEOUT_SECONDS})",
    )
    args = parser.parse_args(argv)

    if not re.fullmatch(r"auto|[a-z]{2,3}", args.language):
        _error(f"--language must be auto or a two- or three-letter language code, not {args.language!r}")
        return EXIT_USAGE
    # The limits can be lowered but not raised, so a caller cannot opt out of them.
    if not 0 < args.max_duration_seconds <= DEFAULT_MAX_DURATION_SECONDS:
        _error(f"--max-duration-seconds must be between 1 and {DEFAULT_MAX_DURATION_SECONDS}")
        return EXIT_USAGE
    if not 0 < args.timeout_seconds <= DEFAULT_TIMEOUT_SECONDS:
        _error(f"--timeout-seconds must be between 1 and {DEFAULT_TIMEOUT_SECONDS}")
        return EXIT_USAGE

    model_path = Path(os.environ.get(MODEL_PATH_ENV) or DEFAULT_MODEL_PATH)
    missing = missing_tools(model_path)
    if missing:
        _error(f"local transcription is not available in this environment. Missing: {', '.join(missing)}")
        return EXIT_MISSING_TOOLS

    threads = available_cpus()
    failed = False
    for audio in args.files:
        try:
            transcript = transcribe_file(
                audio,
                model_path=model_path,
                language=args.language,
                max_duration_seconds=args.max_duration_seconds,
                timeout_seconds=args.timeout_seconds,
                threads=threads,
            )
        except TranscriptionError as error:
            failed = True
            _error(f"{audio.name}: {error}")
            continue
        print(
            f"{audio.name}: transcribed {transcript.duration_seconds:.0f}s of audio, "
            f"language {transcript.language or args.language}",
            file=sys.stderr,
        )
        print(f"=== {audio.name} ===\n{transcript.text}\n")
    return EXIT_FAILED if failed else EXIT_OK


if __name__ == "__main__":
    sys.exit(main())

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Python

Security

What our scanner noticed· Low risk

  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
PostHog
Publisher
PostHog (Official)
Source
PostHog/skills · skills/omnibus/transcribing-slack-audio · commit a5d30e7
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by PostHog.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download transcribing-slack-audio.zip