# Skill Zulip

> Fetch a Zulip thread via API and write formatted JSON to a file. Invoke for /zulip command.

- Skill: `benbrastmckie/skill-zulip` (Agent Skill)
- Install (CLI): `npx skillmds@latest add benbrastmckie/skill-zulip`
- Raw SKILL.md: https://api.skillmd.com/api/skills/benbrastmckie/skill-zulip/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: benbrastmckie (https://skillmd.com/u/benbrastmckie)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/benbrastmckie/skill-zulip

---


# Zulip Fetch Skill (Direct Execution)

Direct execution skill that fetches all messages from a Zulip chat thread and writes formatted JSON output (sender, date, content) to a target file. Reads credentials from ~/.zuliprc and parses the Zulip URL to extract channel and topic.

## Execution

### Step 1: Parse Arguments

Extract URL and optional output path from the arguments passed to this skill.

Run these bash commands to parse the arguments:

```bash
url=$(echo "$ARGUMENTS" | awk '{print $1}')
output_path=$(echo "$ARGUMENTS" | awk 'NF>=2{print $2}')
```

If `$url` is empty, print usage and stop:

```bash
if [ -z "$url" ]; then
  echo "Error: Usage: /zulip <zulip-url> [output-path]"
  echo "Example: /zulip https://leanprover.zulipchat.com/#narrow/channel/113489-new-members/topic/some.20topic"
  exit 1
fi
```

### Step 2: Validate Prerequisites

Check that the credentials file exists and all required tools are available:

```bash
zuliprc="$HOME/.zuliprc"

if [ ! -f "$zuliprc" ]; then
  echo "Error: ~/.zuliprc not found. Create it with:"
  echo "  [api]"
  echo "  email=your@email.com"
  echo "  key=your_api_key"
  echo "  site=https://yourorg.zulipchat.com"
  exit 1
fi

for tool in curl jq python3; do
  if ! which "$tool" >/dev/null 2>&1; then
    echo "Error: Required tool not found: $tool"
    exit 1
  fi
done
```

### Step 3: Read Credentials

Parse ~/.zuliprc to extract the API email, key, and site URL:

```bash
email=$(grep -oP '(?<=email=)\S+' "$zuliprc")
api_key=$(grep -oP '(?<=key=)\S+' "$zuliprc")
site=$(grep -oP '(?<=site=)\S+' "$zuliprc" | sed 's|/$||')

if [ -z "$email" ] || [ -z "$api_key" ] || [ -z "$site" ]; then
  echo "Error: ~/.zuliprc is missing required fields (email, key, site)"
  exit 1
fi
```

### Step 4: Parse Zulip URL

Extract the base URL, channel/stream segment, topic segment, and optional message anchor from the URL.

Note: Zulip uses `#narrow/channel/` in newer versions and `#narrow/stream/` in older ones — both must be handled.

```bash
base=$(echo "$url" | grep -oP 'https?://[^/#]+')
channel_raw=$(echo "$url" | grep -oP '(?<=#narrow/(?:channel|stream)/)[^/]+')
topic_raw=$(echo "$url" | grep -oP '(?<=/topic/)[^/]+')
near_id=$(echo "$url" | grep -oP '(?<=/near/)[0-9]+' || true)

if [ -z "$base" ] || [ -z "$channel_raw" ]; then
  echo "Error: Could not parse Zulip URL. Expected format:"
  echo "  https://REALM.zulipchat.com/#narrow/channel/ID-stream-name/topic/topic.20text"
  exit 1
fi

if [ -z "$topic_raw" ]; then
  echo "Error: URL does not contain a topic segment. Provide a thread URL (not just a channel URL)."
  exit 1
fi
```

### Step 5: Decode URL Components

Convert the raw URL segments into a usable stream name and topic string.

Zulip uses dot-encoding in URL fragments: `.20` means space, `.2C` means comma, etc. These are different from standard percent-encoding. The conversion is: `.XX` -> `%XX`, then standard URL-decode.

```bash
channel_slug=$(echo "$channel_raw" | sed 's/^[0-9]*-//')
stream_name=$(echo "$channel_slug" | tr '-' ' ')

topic=$(python3 -c "
import re, sys
from urllib.parse import unquote
raw = sys.argv[1]
pct = re.sub(r'\.([0-9A-Fa-f]{2})', lambda m: '%' + m.group(1), raw)
print(unquote(pct))
" "$topic_raw")

echo "Fetching thread: stream='$stream_name', topic='$topic'"
if [ -n "$near_id" ]; then
  echo "Anchor: message ID $near_id"
fi
```

### Step 6: Resolve Output Path

If no output path was provided in the arguments, prompt the user to choose where to send the output.

Use AskUserQuestion to present two choices:
- **Option 1**: Print JSON to the screen (stdout)
- **Option 2**: Write JSON to a file (user provides path)

Ask: "Where should the Zulip thread output go? Enter '1' to display inline, or '2' to write to a file."

If the user chooses option 2 (write to file), ask a follow-up question: "Enter output file path (e.g., /tmp/thread.json or ~/thread.json):"

Set `output_path` to the entered path, or set `use_stdout=true` if option 1 was chosen.

If `output_path` was already provided in the arguments (Step 1), skip this step entirely.

### Step 7: Fetch Messages

Build the narrow parameter, URL-encode it, and call the Zulip REST API.

```bash
narrow=$(python3 -c "
import json, sys
print(json.dumps([
  {'operator': 'stream', 'operand': sys.argv[1]},
  {'operator': 'topic', 'operand': sys.argv[2]}
]))
" "$stream_name" "$topic")

narrow_encoded=$(python3 -c "
from urllib.parse import quote
import sys
print(quote(sys.argv[1]))
" "$narrow")

anchor="${near_id:-newest}"

echo "Calling Zulip API..."
response=$(curl -s --user "$email:$api_key" \
  "${site}/api/v1/messages?anchor=${anchor}&num_before=5000&num_after=0&narrow=${narrow_encoded}")

api_result=$(echo "$response" | jq -r '.result' 2>/dev/null)
if [ "$api_result" != "success" ]; then
  api_msg=$(echo "$response" | jq -r '.msg' 2>/dev/null || echo "Unknown error")
  echo "Error: Zulip API returned error: $api_msg"
  exit 1
fi

msg_count=$(echo "$response" | jq '.messages | length' 2>/dev/null || echo 0)
echo "Fetched $msg_count messages."
```

### Step 8: Format and Write Output

Extract sender, date, and content from the API response and write to the destination.

Format each message as a JSON object with three fields: `sender` (full name), `date` (ISO 8601 date string from Unix timestamp), and `content` (raw HTML).

```bash
formatted=$(echo "$response" | jq '[.messages[] | {
  sender: .sender_full_name,
  date: (.timestamp | todate),
  content: .content
}]')
```

If `use_stdout` is true, or if `output_path` is still empty after Step 6, print `$formatted` directly to stdout.

Otherwise write to file:

```bash
mkdir -p "$(dirname "$output_path")"
echo "$formatted" > "$output_path"
```

### Step 9: Report Result

Print a completion summary. If output was written to a file:

```bash
echo ""
echo "Thread written to: $output_path"
echo "Messages: $msg_count"
echo "Stream: $stream_name"
echo "Topic: $topic"
```

If output was printed to stdout:

```bash
echo ""
echo "---"
echo "Stream: $stream_name | Topic: $topic | Messages: $msg_count"
```

---

## Error Handling

- **~/.zuliprc missing**: Print setup instructions, exit 1
- **Required tool missing** (curl/jq/python3): Print tool name, exit 1
- **URL missing or malformed**: Print usage example, exit 1
- **No topic segment in URL**: Print error explaining a thread URL is needed, exit 1
- **API returns non-success**: Print the API error message, exit 1
- **Empty response**: jq safely returns empty array `[]` — this is valid output
- **Output directory missing**: `mkdir -p $(dirname "$output_path")` before writing

