Skill: xhunx/elevenlabs-stt-openclaw (version 1.2.2)

--- name: eleven-stt description: Transcribe audio files with ElevenLabs Speech-to-Text (Scribe v2) from the local CLI. Supports diarization, events, JSON output, webhooks, and advanced STT options. metadata: {"openclaw":{"requires":{"bins":["curl","jq","python3","ffmpeg","websocat"],"env":["ELEVENLABS_API_KEY"]}}} --- # ElevenLabs Speech-to-Text (Local CLI) ## Use Run the script in `scripts/transcribe.sh` with an audio file path or URL. Examples: ```bash scripts/transcribe.sh /path/to/audio.mp3 scripts/transcribe.sh /path/to/audio.mp3 --diarize --lang en scripts/transcribe.sh /path/to/audio.mp3 --json scripts/transcribe.sh /path/to/audio.mp3 --webhook --webhook-metadata '{"job":"call-001"}' scripts/transcribe.sh --url https://example.com/audio.mp3 --lang en ``` ## Environment Set `ELEVENLABS_API_KEY` in your shell or OpenClaw env before running. ## Notes - Defaults to `scribe_v2` (the Speech-to-Text model) and uses a filesystem lock to avoid parallel requests. - Requires `curl` and `jq`. - For async workflows, use `--webhook` with optional `--webhook-id` and `--webhook-metadata`. - Realtime streaming is available via `scripts/realtime.sh` (requires `ffmpeg` + `websocat`) and uses the `scribe_v2_realtime` model. - Live listener mode is available via `scripts/live_listen.sh` with toggle/always-on modes and optional TTS response.

path: README.md
content: # ElevenLabs STT for OpenClaw

A clean, local OpenClaw skill that transcribes audio with ElevenLabs Speech‑to‑Text (Scribe v2). It supports diarization, audio‑event tagging, JSON output, and optional webhook‑based async processing so users can choose how they want to run it.

---

## ✨ Features

- **Scribe v2** transcription (default model)
- **Speaker diarization** (`--diarize`)
- **Audio event tags** (`--events`) — e.g., `[laughing]`, `[chuckles]`
- **Word‑level JSON output** (`--json`)
- **Language hinting** (`--lang en`, `--lang hu`, etc.)
- **Async webhooks** (`--webhook`, `--webhook-id`, `--webhook-metadata`)
- **Cloud URL input** (`--url https://...`) for hosted files
- **Advanced controls**: timestamps granularity, entity detection, keyterms, multi‑channel
- **Concurrency‑safe** via a filesystem lock (no parallel requests)
- **No .env sourcing** — reads only explicit environment variables

---

## ✅ Requirements

- `curl`
- `jq`
- `python3` (for realtime/live listener)
- `ffmpeg` + `websocat` (for realtime/live listener)
- ElevenLabs API key set in environment:

```bash
export [REDACTED_SECRET]"
export ALLOW_LOCAL_FILE=true   # required to transcribe local files
```

---

## 🚀 Usage

From inside the skill folder:

```bash
# Basic transcription (local file)
ALLOW_LOCAL_FILE=true scripts/transcribe.sh /path/to/audio.ogg

# Diarization + language hint
ALLOW_LOCAL_FILE=true scripts/transcribe.sh /path/to/audio.ogg --diarize --lang en

# JSON output with word timings
ALLOW_LOCAL_FILE=true scripts/transcribe.sh /path/to/audio.ogg --json

# Audio‑event tagging
ALLOW_LOCAL_FILE=true scripts/transcribe.sh /path/to/audio.ogg --events
```

### Model override
The valid Speech‑to‑Text models are:

- `scribe_v1`
- `scribe_v1_experimental`
- `scribe_v2` (default)

Example:

```bash
scripts/transcribe.sh /path/to/audio.ogg --model scribe_v1_experimental
```

---

## 🛰️ Webhooks (Async)

Use webhooks if you want the job to run asynchronously and receive results later. This is ideal for longer files or when you don’t want to block the CLI while transcription runs.

```bash
scripts/transcribe.sh /path/to/audio.ogg --webhook
scripts/transcribe.sh /path/to/audio.ogg --webhook --webhook-id abc123 \
  --webhook-metadata '{"job":"call-001","owner":"dan"}'
```

### Quick test (Beeceptor)

1. Create a Beeceptor endpoint (or any HTTPS webhook URL).
2. Add it in ElevenLabs → Webhooks and enable **Transcription completed**.
3. Copy the **Webhook ID** from ElevenLabs.
4. Run:

```bash
scripts/transcribe.sh /path/to/audio.ogg --webhook --webhook-id <ID> \
  --webhook-metadata '{"test":"beeceptor"}'
```

You’ll see the payload arrive in Beeceptor within ~1–2 minutes.

---

## ☁️ Cloud URL Input

Transcribe from a public HTTPS URL instead of uploading a file:

```bash
scripts/transcribe.sh --url https://example.com/audio.mp3 --lang en
```

---

## 🔧 Advanced Flags

```bash
--timestamps none|word|character   # word is default
--num-speakers N                   # 1–32
--diarization-threshold X          # 0–1
--use-multi-channel                # split multi‑channel audio
--entity-detection MODE            # e.g. all|pii|phi|pci|offensive_language
--keyterms "a,b,c"                  # bias key terms
--enable-logging true|false        # default true
```

---

## 🔒 Concurrency Safety

The script uses a lock to prevent parallel requests. If you see:

```
Error: Another transcription is currently running. Please wait a moment.
```

…just wait for the prior request to finish and retry.

---

## Troubleshooting

- **422 Invalid model** → use only the Scribe models above.
- **429 Too Many Requests** → you hit rate limits; wait or reduce usage.
- **503 Service Unavailable** → ElevenLabs outage; retry later.

---

## Files

- `SKILL.md` — OpenClaw skill manifest
- `scripts/transcribe.sh` — transcription CLI

---

## ⚡ Realtime Streaming (WebSocket)

Two realtime options are included:

### 1) File streamer

`realtime.sh` converts any audio file to 16k mono PCM, chunks it, and streams it to ElevenLabs over WebSocket using `scribe_v2_realtime`.

```bash
brew install ffmpeg websocat
scripts/realtime.sh /path/to/audio.ogg
```

### 2) Live listener (mic)

`live_listen.sh` streams your **microphone** into ElevenLabs realtime STT and can **speak back** using TTS. It supports **toggle** (press key to start/stop) or **always‑on**. Toggle mode now runs a separate streaming process for stability.

```bash
brew install ffmpeg websocat

# Always-on, ElevenLabs TTS response
scripts/live_listen.sh --mode always --tts elevenlabs

# Toggle mode, macOS say() voice
scripts/live_listen.sh --mode toggle --tts say --key Q
```

Optional response hook:
```bash
# Run a custom command on each final transcript
scripts/live_listen.sh --mode toggle --on-transcript 'echo "$TEXT"'
```

Options:
- `--mode always|push`
- `--tts elevenlabs|say|none`
- `--device :0` (macOS avfoundation device)
- `--lang en` (optional language hint)
- `--voice-id <ID>` (ElevenLabs voice ID)


path: scripts/_realtime_stream.py
content: #!/usr/bin/env python3
import os, sys, json, base64, subprocess, threading, queue, tempfile

DEVICE = os.environ.get("RT_DEVICE", ":0")
LANG_CODE = os.environ.get("RT_LANG", "")
TTS_ENGINE = os.environ.get("RT_TTS", "none")
VOICE_ID = os.environ.get("RT_VOICE_ID", "")
[REDACTED_SECRET]"ELEVENLABS_API_KEY", "")
MODEL_ID = "scribe_v2_realtime"
AUDIO_FORMAT = "pcm_16000"

if not [REDACTED_SECRET]"Error: ELEVENLABS_API_KEY not set", file=sys.stderr)
    sys.exit(1)

WS_URL = f"wss://api.elevenlabs.io/v1/speech-to-text/realtime?model_id={MODEL_ID}&audio_format={AUDIO_FORMAT}&include_timestamps=true"
if LANG_CODE:
    WS_URL += f"&language_code={LANG_CODE}"

sample_rate = 16000
bytes_per_sample = 2
chunk_ms = 200
bytes_per_ms = int(sample_rate * bytes_per_sample / 1000)
chunk_bytes = bytes_per_ms * chunk_ms

# ffmpeg mic capture (continuous)
ffmpeg_cmd = [
    "ffmpeg", "-hide_banner", "-loglevel", "error",
    "-f", "avfoundation", "-i", DEVICE,
    "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "pipe:1"
]
ffmpeg = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)

# websocat websocket
ws = subprocess.Popen([
    "websocat", WS_URL, "-t", "-H", f"xi-[REDACTED_SECRET]"
], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)

q = queue.Queue()

def reader():
    for line in ws.stdout:
        q.put(line.strip())

threading.Thread(target=reader, daemon=True).start()


def tts_say(text):
    # Use -- to avoid option injection
    subprocess.Popen(["say", "--", text])


def tts_elevenlabs(text):
    if not VOICE_ID:
        print("[tts] No ELEVENLABS_VOICE_ID set", file=sys.stderr)
        return False
    tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3")
    tmp.close()
    tts_cmd = [
        "curl", "-s", "-X", "POST",
        f"https://api.elevenlabs.io/v1/text-to-speech/{VOICE_ID}/stream",
        "-H", f"xi-[REDACTED_SECRET]",
        "-H", "Content-Type: application/json",
        "-d", json.dumps({"text": text})
    ]
    with open(tmp.name, "wb") as f:
        subprocess.run(tts_cmd, stdout=f, stderr=subprocess.DEVNULL)

    # Validate MP3 header (avoid AudioFileOpen errors on bad payloads)
    try:
        if os.path.getsize(tmp.name) < 4:
            return False
        with open(tmp.name, "rb") as f:
            head = f.read(3)
        if head not in (b"ID3", b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"):
            return False
        subprocess.Popen(["afplay", tmp.name])
        return True
    except Exception:
        return False


def speak(text):
    if not text:
        return
    if TTS_ENGINE == "say":
        tts_say(text)
    elif TTS_ENGINE == "elevenlabs":
        ok = tts_elevenlabs(text)
        if not ok:
            # Fallback to say so user hears *something*
            tts_say(text)

chunk = ffmpeg.stdout.read(chunk_bytes)
count = 0
last_text = ""
while chunk:
    next_chunk = ffmpeg.stdout.read(chunk_bytes)
    count += 1
    # Force periodic commits so we actually receive committed transcripts
    commit = True if (count % 10 == 0) else False
    b64 = base64.b64encode(chunk).decode("ascii")
    msg = {
        "message_type": "input_audio_chunk",
        "audio_base_64": b64,
        "commit": commit,
        "sample_rate": sample_rate,
    }
    try:
        ws.stdin.write(json.dumps(msg) + "\n")
        ws.stdin.flush()
    except Exception:
        break

    while not q.empty():
        try:
            data = json.loads(q.get())
        except Exception:
            continue
        mtype = data.get("message_type", "")
        if mtype in ("committed_transcript", "committed_transcript_with_timestamps"):
            text = data.get("text", "")
            if text and text != last_text:
                last_text = text
                print(text, flush=True)
                speak(text)

    chunk = next_chunk


path: scripts/live_listen.sh
content: #!/usr/bin/env bash
set -euo pipefail
set +m  # disable job control messages

# Live Listener: mic → ElevenLabs realtime STT → optional response/TTS
# Requires: ffmpeg, websocat, python3

MODE="toggle"          # toggle | always
TTS_ENGINE="elevenlabs" # elevenlabs | say | none
PUSH_KEY="Q"            # key to toggle start/stop
DEVICE=":0"            # avfoundation device string (macOS)
LANG_CODE=""           # optional language code
VOICE_ID="${ELEVENLABS_VOICE_ID:-WNxHBFUm0NC5fojx98kr}"

while [[ $# -gt 0 ]]; do
  case $1 in
    --mode) MODE="$2"; shift 2 ;;
    --tts) TTS_ENGINE="$2"; shift 2 ;;
    --key) PUSH_KEY="$2"; shift 2 ;;
    --device) DEVICE="$2"; shift 2 ;;
    --lang) LANG_CODE="$2"; shift 2 ;;
    --voice-id) VOICE_ID="$2"; shift 2 ;;
    -h|--help)
      cat << EOF2
Usage: $(basename "$0") [options]

Options:
  --mode toggle|always        Toggle start/stop or always-on (default: toggle)
  --tts elevenlabs|say|none   TTS engine (default: elevenlabs)
  --key KEY                   Toggle key (default: Q)
  --device DEVICE             Mic device for ffmpeg (default: :0)
  --lang CODE                 Language code hint (optional)
  --voice-id ID               ElevenLabs voice ID for TTS
EOF2
      exit 0
      ;;
    *) echo "Unknown option: $1" >&2; exit 1 ;;
  esac
done

for bin in websocat ffmpeg python3; do
  command -v "$bin" >/dev/null 2>&1 || { echo "Error: $bin is required" >&2; exit 1; }
done

if [[ -z "${ELEVENLABS_API_KEY:-}" ]]; then
  echo "Error: ELEVENLABS_API_KEY not set" >&2
  exit 1
fi

export RT_DEVICE="$DEVICE"
export RT_LANG="$LANG_CODE"
export RT_TTS="$TTS_ENGINE"
export RT_VOICE_ID="$VOICE_ID"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STREAMER="$SCRIPT_DIR/_realtime_stream.py"

if [[ "$MODE" == "always" ]]; then
  python3 "$STREAMER"
  exit 0
fi

# Toggle mode
cleanup() {
  stty echo
  if [[ -n "${STREAM_PID:-}" ]]; then
    kill "$STREAM_PID" 2>/dev/null || true
  fi
}
trap cleanup EXIT

stty -echo -icanon time 0 min 0

listening=0
echo "Press $PUSH_KEY to start/stop. Ctrl+C to exit."

while true; do
  IFS= read -r -n1 key
  if [[ -z "$key" ]]; then
    sleep 0.05
    continue
  fi
  key_up=$(printf "%s" "$key" | tr '[:lower:]' '[:upper:]')
  push_up=$(printf "%s" "$PUSH_KEY" | tr '[:lower:]' '[:upper:]')
  if [[ "$key_up" == "$push_up" ]]; then
    if [[ $listening -eq 0 ]]; then
      python3 "$STREAMER" &
      STREAM_PID=$!
      listening=1
      echo "Listening…" >&2
    else
      kill "$STREAM_PID" 2>/dev/null || true
      wait "$STREAM_PID" 2>/dev/null || true
      listening=0
      echo "Stopped listening." >&2
    fi
  fi
  sleep 0.05

done


path: scripts/realtime.sh
content: #!/usr/bin/env bash
set -euo pipefail

# ElevenLabs Realtime STT (WebSocket) streamer
# Requires: ffmpeg, websocat, python3

if ! command -v websocat >/dev/null 2>&1; then
  echo "Error: websocat is required. Install: brew install websocat" >&2
  exit 1
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
  echo "Error: ffmpeg is required. Install: brew install ffmpeg" >&2
  exit 1
fi

if [[ -z "${ELEVENLABS_API_KEY:-}" ]]; then
  echo "Error: ELEVENLABS_API_KEY not set" >&2
  exit 1
fi

FILE="${1:-}"
if [[ -z "$FILE" || ! -f "$FILE" ]]; then
  echo "Usage: $(basename "$0") /path/to/audio.ogg" >&2
  exit 1
fi

MODEL_ID="scribe_v2_realtime"
AUDIO_FORMAT="pcm_16000" # 16k mono PCM
CHUNK_MS="200"

WS_URL="wss://api.elevenlabs.io/v1/speech-to-text/realtime?model_id=${MODEL_ID}&audio_format=${AUDIO_FORMAT}&include_timestamps=true"

# Convert to raw PCM and stream as base64 JSON chunks
python3 - <<'PY' "$FILE" "$CHUNK_MS" | \
  websocat "$WS_URL" -t -H "xi-[REDACTED_SECRET]"
import sys, base64, json, subprocess

file_path = sys.argv[1]
chunk_ms = int(sys.argv[2])

# PCM 16k mono 16‑bit
sample_rate = 16000
bytes_per_sample = 2
bytes_per_ms = int(sample_rate * bytes_per_sample / 1000)
chunk_bytes = bytes_per_ms * chunk_ms

# ffmpeg -> raw PCM
cmd = [
    "ffmpeg", "-hide_banner", "-loglevel", "error",
    "-i", file_path,
    "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "pipe:1"
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)

# Stream chunks (commit on final chunk)
chunk = proc.stdout.read(chunk_bytes)
while chunk:
    next_chunk = proc.stdout.read(chunk_bytes)
    commit = False if next_chunk else True
    b64 = base64.b64encode(chunk).decode("ascii")
    msg = {
        "message_type": "input_audio_chunk",
        "audio_base_64": b64,
        "commit": commit,
        "sample_rate": sample_rate,
    }
    print(json.dumps(msg), flush=True)
    chunk = next_chunk
PY


path: scripts/transcribe.sh
content: #!/usr/bin/env bash
set -euo pipefail

LOCK_DIR="/tmp/eleven-stt.lock"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
    echo "Error: Another transcription is currently running. Please wait a moment." >&2
    exit 1
fi
cleanup_lock() { rmdir "$LOCK_DIR" 2>/dev/null || true; }
trap cleanup_lock EXIT

# ElevenLabs Speech-to-Text transcription script
# Usage: transcribe.sh <audio_file> [options]

show_help() {
    cat << EOF
Usage: $(basename "$0") <audio_file> [options]

Core options:
  --diarize                 Enable speaker diarization
  --lang CODE               ISO language code (e.g., en, hu, es)
  --json                    Output full JSON response
  --events                  Tag audio events (laughter, music, etc.)
  --model MODEL             STT model (default: scribe_v2)

Async / webhook options:
  --webhook                 Enable async processing (webhook delivery)
  --webhook-id ID           Send to a specific webhook ID
  --webhook-metadata JSON   JSON string to attach to webhook callback

Advanced options:
  --timestamps MODE         none|word|character (default: word)
  --num-speakers N          Max speakers (1-32)
  --diarization-threshold X Diarization threshold (0-1)
  --use-multi-channel       Split multi-channel audio
  --entity-detection MODE   e.g. all|pii|phi|pci|offensive_language
  --keyterms "a,b,c"         Comma-separated keyterms (<=100)
  --enable-logging BOOL     true|false (default: true)
  --url HTTPS_URL           Transcribe via cloud URL instead of file

  -h, --help                Show this help

Environment:
  ELEVENLABS_API_KEY  Required API key

Examples:
  $(basename "$0") voice_note.ogg
  $(basename "$0") meeting.mp3 --diarize --lang en --json
  $(basename "$0") audio.wav --webhook --webhook-id abc123
  $(basename "$0") --url https://example.com/audio.mp3 --lang en
EOF
    exit 0
}

# Defaults
DIARIZE="false"
LANG_CODE=""
JSON_OUTPUT="false"
TAG_EVENTS="false"
MODEL_ID="scribe_v2"
TIMESTAMPS="word"
NUM_SPEAKERS=""
DIARIZATION_THRESHOLD=""
USE_MULTI_CHANNEL="false"
ENTITY_DETECTION=""
KEYTERMS=""
ENABLE_LOGGING="true"
WEBHOOK="false"
WEBHOOK_ID=""
WEBHOOK_METADATA=""
CLOUD_URL=""
FILE=""

# Parse arguments
while [[ $# -gt 0 ]]; do
    case $1 in
        -h|--help) show_help ;;
        --diarize) DIARIZE="true"; shift ;;
        --lang) LANG_CODE="$2"; shift 2 ;;
        --json) JSON_OUTPUT="true"; shift ;;
        --events) TAG_EVENTS="true"; shift ;;
        --model) MODEL_ID="$2"; shift 2 ;;
        --timestamps) TIMESTAMPS="$2"; shift 2 ;;
        --num-speakers) NUM_SPEAKERS="$2"; shift 2 ;;
        --diarization-threshold) DIARIZATION_THRESHOLD="$2"; shift 2 ;;
        --use-multi-channel) USE_MULTI_CHANNEL="true"; shift ;;
        --entity-detection) ENTITY_DETECTION="$2"; shift 2 ;;
        --keyterms) KEYTERMS="$2"; shift 2 ;;
        --enable-logging) ENABLE_LOGGING="$2"; shift 2 ;;
        --webhook) WEBHOOK="true"; shift ;;
        --webhook-id) WEBHOOK_ID="$2"; shift 2 ;;
        --webhook-metadata) WEBHOOK_METADATA="$2"; shift 2 ;;
        --url) CLOUD_URL="$2"; shift 2 ;;
        -*) echo "Unknown option: $1" >&2; exit 1 ;;
        *) FILE="$1"; shift ;;
    esac
done

# Validate inputs
if [[ -z "$FILE" && -z "$CLOUD_URL" ]]; then
    echo "Error: Provide a file path or --url" >&2
    show_help
fi

if [[ -n "$FILE" ]]; then
    if [[ ! -f "$FILE" ]]; then
        echo "Error: File not found: $FILE" >&2
        exit 1
    fi
    if [[ "${ALLOW_LOCAL_FILE:-false}" != "true" ]]; then
        echo "Error: Local file usage requires ALLOW_LOCAL_FILE=true" >&2
        exit 1
    fi
fi

if [[ -n "$CLOUD_URL" ]]; then
    if [[ ! "$CLOUD_URL" =~ ^https:// ]]; then
        echo "Error: --url must start with https://" >&2
        exit 1
    fi
    if [[ "$CLOUD_URL" =~ [[:space:]] ]]; then
        echo "Error: --url must not contain spaces" >&2
        exit 1
    fi
fi

# API key
[REDACTED_SECRET]"
if [[ -z "$API_KEY" ]]; then
    echo "Error: ELEVENLABS_API_KEY not set" >&2
    exit 1
fi

# Build curl command
CURL_ARGS=(
    -s
    -X POST
    "https://api.elevenlabs.io/v1/speech-to-text"
    -H "xi-[REDACTED_SECRET]"
    -F "model_id=$MODEL_ID"
    -F "diarize=$DIARIZE"
    -F "tag_audio_events=$TAG_EVENTS"
    -F "timestamps_granularity=$TIMESTAMPS"
    -F "enable_logging=$ENABLE_LOGGING"
    -F "use_multi_channel=$USE_MULTI_CHANNEL"
    -F "webhook=$WEBHOOK"
)

if [[ -n "$FILE" ]]; then
    CURL_ARGS+=(-F "file=@$FILE")
elif [[ -n "$CLOUD_URL" ]]; then
    CURL_ARGS+=(-F "cloud_storage_url=$CLOUD_URL")
fi

if [[ -n "$LANG_CODE" ]]; then
    CURL_ARGS+=(-F "language_code=$LANG_CODE")
fi

if [[ -n "$NUM_SPEAKERS" ]]; then
    CURL_ARGS+=(-F "num_speakers=$NUM_SPEAKERS")
fi

if [[ -n "$DIARIZATION_THRESHOLD" ]]; then
    CURL_ARGS+=(-F "diarization_threshold=$DIARIZATION_THRESHOLD")
fi

if [[ -n "$ENTITY_DETECTION" ]]; then
    CURL_ARGS+=(-F "entity_detection=$ENTITY_DETECTION")
fi

if [[ -n "$KEYTERMS" ]]; then
    CURL_ARGS+=(-F "keyterms=$KEYTERMS")
fi

if [[ -n "$WEBHOOK_ID" ]]; then
    CURL_ARGS+=(-F "webhook_id=$WEBHOOK_ID")
fi

if [[ -n "$WEBHOOK_METADATA" ]]; then
    if ! echo "$WEBHOOK_METADATA" | jq -e . >/dev/null 2>&1; then
        echo "Error: --webhook-metadata must be valid JSON" >&2
        exit 1
    fi
    CURL_ARGS+=(-F "webhook_metadata=$WEBHOOK_METADATA")
fi

# Make request
RESPONSE=$(curl "${CURL_ARGS[@]}")

# Check for errors
if echo "$RESPONSE" | grep -q '"detail"'; then
    echo "Error from API:" >&2
    echo "$RESPONSE" | jq -r '.detail.message // .detail' >&2
    exit 1
fi

# Output
if [[ "$JSON_OUTPUT" == "true" ]]; then
    echo "$RESPONSE" | jq .
else
    TEXT=$(echo "$RESPONSE" | jq -r '.text // empty')
    if [[ -n "$TEXT" ]]; then
        echo "$TEXT"
    else
        echo "$RESPONSE"
    fi
fi


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)