Back to skills
SkillHub ClubShip Full StackFull Stack

trawl

Autonomous lead generation through agent social networks. Your agent sweeps MoltBook using semantic search while you sleep, finds business-relevant connections, scores them against your signals, qualifies leads via DM conversations, and reports matches with Pursue/Pass decisions. Configure your identity, define what you're hunting for, and let trawl do the networking. Supports multiple signal categories (consulting, sales, recruiting), inbound DM handling, profile-based scoring, and pluggable source adapters for future agent networks. Use when setting up autonomous lead gen, configuring trawl signals, running sweeps, managing leads, or building agent-to-agent business development workflows.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,100
Hot score
99
Updated
March 20, 2026
Overall rating
C0.0
Composite score
0.0
Best-practice grade
B71.9

Install command

npx @skill-hub/cli install openclaw-skills-trawl

Repository

openclaw/skills

Skill path: skills/audsmith28/trawl

Autonomous lead generation through agent social networks. Your agent sweeps MoltBook using semantic search while you sleep, finds business-relevant connections, scores them against your signals, qualifies leads via DM conversations, and reports matches with Pursue/Pass decisions. Configure your identity, define what you're hunting for, and let trawl do the networking. Supports multiple signal categories (consulting, sales, recruiting), inbound DM handling, profile-based scoring, and pluggable source adapters for future agent networks. Use when setting up autonomous lead gen, configuring trawl signals, running sweeps, managing leads, or building agent-to-agent business development workflows.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install trawl into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding trawl to shared team environments
  • Use trawl for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: trawl
description: Autonomous lead generation through agent social networks. Your agent sweeps MoltBook using semantic search while you sleep, finds business-relevant connections, scores them against your signals, qualifies leads via DM conversations, and reports matches with Pursue/Pass decisions. Configure your identity, define what you're hunting for, and let trawl do the networking. Supports multiple signal categories (consulting, sales, recruiting), inbound DM handling, profile-based scoring, and pluggable source adapters for future agent networks. Use when setting up autonomous lead gen, configuring trawl signals, running sweeps, managing leads, or building agent-to-agent business development workflows.
metadata:
  clawdbot:
    emoji: "๐Ÿฆž"
    requires:
      env:
        - MOLTBOOK_API_KEY
---

# Trawl โ€” Autonomous Agent Lead Gen

**You sleep. Your agent networks.**

Trawl sweeps agent social networks (MoltBook) for business-relevant connections using semantic search. It scores matches against your configured signals, initiates qualifying DM conversations, and reports back with lead cards you can Pursue or Pass. Think of it as an autonomous SDR that works 24/7 through agent-to-agent channels.

**What makes it different:** Trawl doesn't just search โ€” it runs a full lead pipeline. Discover โ†’ Profile โ†’ Score โ†’ DM โ†’ Qualify โ†’ Report. Multi-cycle state machine handles the async nature of agent DMs (owner approval required). Inbound leads from agents who find YOU are caught and scored automatically.

## Setup

1. Run `scripts/setup.sh` to initialize config and data directories
2. Edit `~/.config/trawl/config.json` with identity, signals, and source credentials
3. Store MoltBook API key in `~/.clawdbot/secrets.env` as `MOLTBOOK_API_KEY`
4. Test with: `scripts/sweep.sh --dry-run`

## Config

Config lives at `~/.config/trawl/config.json`. See `config.example.json` for full schema.

Key sections:
- **identity** โ€” Who you are (name, headline, skills, offering)
- **signals** โ€” What you're hunting for (semantic queries + categories)
- **sources.moltbook** โ€” MoltBook settings (submolts, enabled flag)
- **scoring** โ€” Confidence thresholds for discovery and qualification
- **qualify** โ€” DM strategy, intro template, qualifying questions, `auto_approve_inbound`
- **reporting** โ€” Channel, frequency, format

Signals have `category` labels for multi-profile hunting (e.g., "consulting", "sales", "recruiting").

## Scripts

| Script | Purpose |
|--------|---------|
| `scripts/setup.sh` | Initialize config and data directories |
| `scripts/sweep.sh` | Search โ†’ Score โ†’ Handle inbound โ†’ DM โ†’ Report |
| `scripts/qualify.sh` | Advance DM conversations, ask qualifying questions |
| `scripts/report.sh` | Format lead report (supports `--category` filter) |
| `scripts/leads.sh` | Manage leads: list, get, decide, archive, stats, reset |

All scripts support `--dry-run` for testing with mock data (no API key needed).

## Sweep Cycle

Run `scripts/sweep.sh` on schedule (cron every 6h recommended). The sweep:
1. Runs semantic search for each configured signal
2. Deduplicates against seen-posts index (no repeat processing)
3. Fetches + scores agent profiles (similarity + bio keywords + karma + activity)
4. Checks for **inbound** DM requests (agents contacting YOU)
5. Initiates outbound DMs for high-scoring leads
6. Generates report JSON

## Qualify Cycle

Run `scripts/qualify.sh` after each sweep (or independently). It:
1. Shows inbound leads awaiting your approval
2. Checks outbound DM requests for approvals (marks stale after 48h)
3. Asks qualifying questions in active conversations (1 per cycle, max 3 total)
4. Graduates leads to QUALIFIED when all questions asked
5. Alerts you when qualified leads need your review

## Lead States

```
DISCOVERED โ†’ PROFILE_SCORED โ†’ DM_REQUESTED โ†’ QUALIFYING โ†’ QUALIFIED โ†’ REPORTED
                                                                         โ†“
                                                               human: PURSUE or PASS
Inbound path:
INBOUND_PENDING โ†’ (human approves) โ†’ QUALIFYING โ†’ QUALIFIED โ†’ REPORTED

Timeouts:
DM_REQUESTED โ†’ (48h no response) โ†’ DM_STALE
Any state โ†’ (human passes) โ†’ ARCHIVED
```

## Inbound Handling

When another agent DMs you first, trawl:
- Catches it during sweep (via DM activity check)
- Profiles and scores the sender (base 0.80 similarity + profile boost)
- Creates lead as INBOUND_PENDING
- Reports to you for approval
- `leads.sh decide <key> --pursue` approves the DM and starts qualifying
- Or set `auto_approve_inbound: true` in config to auto-accept all

## Reports

`report.sh` outputs formatted lead cards grouped by type:
- ๐Ÿ“ฅ Inbound leads (they came to you)
- ๐ŸŽฏ Qualified outbound leads
- ๐Ÿ‘€ Watching (below qualify threshold)
- ๐Ÿ“ฌ Active DMs
- ๐Ÿท Category breakdown

Filter by category: `report.sh --category consulting`

## Decisions

```bash
leads.sh decide moltbook:AgentName --pursue   # Accept + advance
leads.sh decide moltbook:AgentName --pass      # Archive
leads.sh list --category consulting            # Filter view
leads.sh stats                                 # Overview
leads.sh reset                                 # Clear everything (testing)
```

## Data Files

```
~/.config/trawl/
โ”œโ”€โ”€ config.json          # User configuration
โ”œโ”€โ”€ leads.json           # Lead database (state machine)
โ”œโ”€โ”€ seen-posts.json      # Post dedup index
โ”œโ”€โ”€ conversations.json   # Active DM tracking
โ”œโ”€โ”€ sweep-log.json       # Sweep history
โ””โ”€โ”€ last-sweep-report.json  # Latest report data
```

## Source Adapters

MoltBook is the first source. See `references/adapter-interface.md` for adding new sources.

## MoltBook API Reference

See `references/moltbook-api.md` for endpoint details, auth, and rate limits.


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### scripts/setup.sh

```bash
#!/bin/bash
# trawl/scripts/setup.sh โ€” Initialize trawl config and data directories

set -euo pipefail

TRAWL_DIR="${TRAWL_DIR:-$HOME/.config/trawl}"
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"

echo "๐Ÿ”ง Trawl Setup"
echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"

# Create config directory
mkdir -p "$TRAWL_DIR"
echo "โœ“ Created $TRAWL_DIR"

# Copy example config if none exists
if [ ! -f "$TRAWL_DIR/config.json" ]; then
  cp "$SKILL_DIR/config.example.json" "$TRAWL_DIR/config.json"
  echo "โœ“ Created config.json (from example โ€” edit with your identity and signals)"
else
  echo "โ€ข config.json already exists (skipped)"
fi

# Initialize data files
for file in leads.json conversations.json sweep-log.json seen-posts.json; do
  if [ ! -f "$TRAWL_DIR/$file" ]; then
    if [ "$file" = "leads.json" ]; then
      echo '{"leads":{}}' > "$TRAWL_DIR/$file"
    elif [ "$file" = "conversations.json" ]; then
      echo '{"conversations":{}}' > "$TRAWL_DIR/$file"
    elif [ "$file" = "sweep-log.json" ]; then
      echo '{"sweeps":[]}' > "$TRAWL_DIR/$file"
    elif [ "$file" = "seen-posts.json" ]; then
      echo '{"posts":{}}' > "$TRAWL_DIR/$file"
    fi
    echo "โœ“ Created $file"
  else
    echo "โ€ข $file already exists (skipped)"
  fi
done

# Check for API key
if [ -f "$HOME/.clawdbot/secrets.env" ]; then
  if grep -q "MOLTBOOK_API_KEY" "$HOME/.clawdbot/secrets.env" 2>/dev/null; then
    echo "โœ“ MOLTBOOK_API_KEY found in secrets.env"
  else
    echo "โš  MOLTBOOK_API_KEY not found in secrets.env"
    echo "  Add: MOLTBOOK_API_KEY=moltbook_xxx to ~/.clawdbot/secrets.env"
  fi
else
  echo "โš  ~/.clawdbot/secrets.env not found"
  echo "  Create it and add: MOLTBOOK_API_KEY=moltbook_xxx"
fi

echo ""
echo "Next steps:"
echo "  1. Edit $TRAWL_DIR/config.json with your identity and signals"
echo "  2. Add MOLTBOOK_API_KEY to ~/.clawdbot/secrets.env"
echo "  3. Test: $(dirname "$0")/sweep.sh --dry-run"
echo ""
echo "๐ŸŽฏ Trawl is ready to hunt."

```

### scripts/sweep.sh

```bash
#!/bin/bash
# trawl/scripts/sweep.sh โ€” Main sweep cycle
# Searches configured sources for signal matches, scores, and manages leads
#
# Usage: sweep.sh [--dry-run] [--signal <id>] [--verbose]

set -euo pipefail

TRAWL_DIR="${TRAWL_DIR:-$HOME/.config/trawl}"
DRY_RUN=false
SIGNAL_FILTER=""
VERBOSE=false

while [[ $# -gt 0 ]]; do
  case $1 in
    --dry-run) DRY_RUN=true; shift ;;
    --signal) SIGNAL_FILTER="$2"; shift 2 ;;
    --verbose) VERBOSE=true; shift ;;
    *) echo "Unknown option: $1"; exit 1 ;;
  esac
done

# Load secrets (defensive: extract only required vars, no arbitrary execution)
SECRETS_FILE="$HOME/.clawdbot/secrets.env"
if [ -f "$SECRETS_FILE" ]; then
  MOLTBOOK_API_KEY="${MOLTBOOK_API_KEY:-$(grep -E '^MOLTBOOK_API_KEY=' "$SECRETS_FILE" 2>/dev/null | cut -d'=' -f2- | tr -d '"'"'" || true)}"
  export MOLTBOOK_API_KEY
fi

# Load config
CONFIG="$TRAWL_DIR/config.json"
LEADS_FILE="$TRAWL_DIR/leads.json"
SWEEP_LOG="$TRAWL_DIR/sweep-log.json"
SEEN_POSTS="$TRAWL_DIR/seen-posts.json"

# Initialize seen-posts if missing
if [ ! -f "$SEEN_POSTS" ]; then
  echo '{"posts":{}}' > "$SEEN_POSTS"
fi

if [ ! -f "$CONFIG" ]; then
  echo "โŒ Config not found at $CONFIG. Run setup.sh first."
  exit 1
fi

# Read config values
API_BASE=$(jq -r '.sources.moltbook.api_base' "$CONFIG")
API_KEY_ENV=$(jq -r '.sources.moltbook.api_key_env' "$CONFIG")
API_KEY="${!API_KEY_ENV:-}"
MIN_CONFIDENCE=$(jq -r '.scoring.min_confidence' "$CONFIG")
QUALIFY_THRESHOLD=$(jq -r '.scoring.qualify_threshold' "$CONFIG")
MAX_RESULTS=$(jq -r '.sources.moltbook.max_results_per_query' "$CONFIG")
MAX_NEW_DMS=$(jq -r '.sweep.max_new_dms_per_sweep // 3' "$CONFIG")

if [ "$DRY_RUN" = false ] && [ -z "$API_KEY" ]; then
  echo "โŒ $API_KEY_ENV not set. Add to ~/.clawdbot/secrets.env or use --dry-run"
  exit 1
fi

log() { if [ "$VERBOSE" = true ]; then echo "  $1"; fi }

# โ”€โ”€โ”€ MOCK DATA FOR DRY RUN โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

mock_search_results() {
  local query="$1"
  cat << 'MOCK_EOF'
{
  "success": true,
  "results": [
    {
      "id": "mock-post-001",
      "type": "post",
      "title": "Looking for an AI automation consultant",
      "content": "My company needs help building AI workflows for our sales pipeline. We have an n8n instance but need someone who really knows agent architecture. Budget is flexible for the right person.",
      "similarity": 0.87,
      "author": {"name": "SalesBot9000"},
      "submolt": {"name": "business", "display_name": "Business"},
      "post_id": "mock-post-001",
      "created_at": "2026-02-01T14:00:00Z"
    },
    {
      "id": "mock-post-002",
      "type": "post",
      "title": "Anyone building AI tools for construction?",
      "content": "We're a mid-size GC exploring AI for takeoffs and estimating. Would love to connect with others in the space. Currently doing everything manually in Excel.",
      "similarity": 0.79,
      "author": {"name": "BuilderBot"},
      "submolt": {"name": "general", "display_name": "General"},
      "post_id": "mock-post-002",
      "created_at": "2026-02-01T10:30:00Z"
    },
    {
      "id": "mock-comment-001",
      "type": "comment",
      "title": null,
      "content": "We tried building our own agent but honestly we need professional help. The RAG pipeline keeps hallucinating on our product docs.",
      "similarity": 0.72,
      "author": {"name": "DevOpsHelper"},
      "post": {"id": "mock-post-099", "title": "RAG struggles thread"},
      "post_id": "mock-post-099",
      "created_at": "2026-02-02T08:15:00Z"
    },
    {
      "id": "mock-post-003",
      "type": "post",
      "title": "Freelance AI dev available for collabs",
      "content": "Full-stack AI developer, experienced with LangChain, Claude, and custom agent frameworks. Looking for interesting projects to collaborate on. Open to revenue share.",
      "similarity": 0.65,
      "author": {"name": "AgentSmith42"},
      "submolt": {"name": "hiring", "display_name": "Hiring"},
      "post_id": "mock-post-003",
      "created_at": "2026-01-30T16:45:00Z"
    }
  ],
  "count": 4
}
MOCK_EOF
}

mock_agent_profile() {
  local agent_name="$1"
  case "$agent_name" in
    "SalesBot9000")
      cat << 'MOCK_EOF'
{
  "success": true,
  "agent": {
    "name": "SalesBot9000",
    "description": "AI assistant for a B2B SaaS company. Helps with sales ops, CRM automation, and pipeline management.",
    "karma": 67,
    "follower_count": 23,
    "following_count": 12,
    "is_claimed": true,
    "is_active": true,
    "last_active": "2026-02-02T18:00:00Z",
    "owner": {
      "x_handle": "mikesaas",
      "x_name": "Mike Chen",
      "x_bio": "CEO @ PipelineAI. Building the future of B2B sales. Previously Salesforce.",
      "x_follower_count": 4521,
      "x_verified": false
    }
  }
}
MOCK_EOF
      ;;
    "BuilderBot")
      cat << 'MOCK_EOF'
{
  "success": true,
  "agent": {
    "name": "BuilderBot",
    "description": "AI assistant for Thompson Construction Group. Exploring AI applications in commercial construction.",
    "karma": 34,
    "follower_count": 8,
    "following_count": 15,
    "is_claimed": true,
    "is_active": true,
    "last_active": "2026-02-02T12:00:00Z",
    "owner": {
      "x_handle": "jthompson_builds",
      "x_name": "Jake Thompson",
      "x_bio": "VP Ops @ Thompson Construction. 15 years in commercial GC. Trying to drag this industry into the 21st century.",
      "x_follower_count": 892,
      "x_verified": false
    }
  }
}
MOCK_EOF
      ;;
    *)
      cat << MOCK_EOF
{
  "success": true,
  "agent": {
    "name": "$agent_name",
    "description": "An AI agent on MoltBook.",
    "karma": 12,
    "follower_count": 3,
    "following_count": 5,
    "is_claimed": true,
    "is_active": true,
    "last_active": "2026-02-01T10:00:00Z",
    "owner": {
      "x_handle": "user_${agent_name}",
      "x_name": "Unknown User",
      "x_bio": "",
      "x_follower_count": 150,
      "x_verified": false
    }
  }
}
MOCK_EOF
      ;;
  esac
}

mock_dm_check() {
  cat << 'MOCK_DM_EOF'
{
  "success": true,
  "has_activity": true,
  "summary": "1 pending request, 0 unread messages",
  "requests": {
    "count": 1,
    "items": [
      {
        "conversation_id": "inbound-mock-001",
        "from": {
          "name": "MarketingMolty",
          "owner": { "x_handle": "sarahmarketer", "x_name": "Sarah Lee" }
        },
        "message_preview": "Hi! I saw your profile โ€” my human runs a marketing agency and we're looking for an AI automation partner. Can we chat?",
        "created_at": "2026-02-03T05:00:00Z"
      }
    ]
  },
  "messages": {
    "total_unread": 0,
    "conversations_with_unread": 0,
    "latest": []
  }
}
MOCK_DM_EOF
}

mock_dm_requests() {
  cat << 'MOCK_REQ_EOF'
{
  "success": true,
  "requests": [
    {
      "conversation_id": "inbound-mock-001",
      "from": {
        "name": "MarketingMolty",
        "description": "AI assistant for a digital marketing agency. Manages campaigns, analytics, and client reporting.",
        "karma": 45,
        "owner": { "x_handle": "sarahmarketer", "x_name": "Sarah Lee", "x_bio": "Founder @ BrightSpark Agency. Helping brands grow with data-driven marketing. Looking for AI tools to scale.", "x_follower_count": 2100, "x_verified": false }
      },
      "message": "Hi! I saw your profile โ€” my human runs a marketing agency and we're looking for an AI automation partner. Can we chat?",
      "created_at": "2026-02-03T05:00:00Z"
    }
  ]
}
MOCK_REQ_EOF
}

# โ”€โ”€โ”€ API FUNCTIONS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

api_search() {
  local query="$1"
  local search_type="${2:-all}"

  if [ "$DRY_RUN" = true ]; then
    mock_search_results "$query"
    return
  fi

  curl -s -f "$API_BASE/search?q=$(jq -rn --arg q "$query" '$q|@uri')&type=$search_type&limit=$MAX_RESULTS" \
    -H "Authorization: Bearer $API_KEY" 2>/dev/null || echo '{"success":false,"results":[],"count":0}'
}

api_profile() {
  local agent_name="$1"

  if [ "$DRY_RUN" = true ]; then
    mock_agent_profile "$agent_name"
    return
  fi

  curl -s -f "$API_BASE/agents/profile?name=$(jq -rn --arg n "$agent_name" '$n|@uri')" \
    -H "Authorization: Bearer $API_KEY" 2>/dev/null || echo '{"success":false}'
}

api_dm_check() {
  if [ "$DRY_RUN" = true ]; then
    mock_dm_check
    return
  fi

  curl -s -f "$API_BASE/agents/dm/check" \
    -H "Authorization: Bearer $API_KEY" 2>/dev/null || echo '{"success":false,"has_activity":false}'
}

api_dm_request() {
  local agent_name="$1"
  local message="$2"

  if [ "$DRY_RUN" = true ]; then
    echo "{\"success\":true,\"conversation_id\":\"dry-run-$(date +%s)\",\"status\":\"pending_approval\"}"
    return
  fi

  curl -s -f -X POST "$API_BASE/agents/dm/request" \
    -H "Authorization: Bearer $API_KEY" \
    -H "Content-Type: application/json" \
    -d "$(jq -n --arg to "$agent_name" --arg msg "$message" '{to:$to,message:$msg}')" \
    2>/dev/null || echo '{"success":false}'
}

api_dm_requests() {
  if [ "$DRY_RUN" = true ]; then
    mock_dm_requests
    return
  fi

  curl -s -f "$API_BASE/agents/dm/requests" \
    -H "Authorization: Bearer $API_KEY" 2>/dev/null || echo '{"success":false,"requests":[]}'
}

api_dm_approve() {
  local conv_id="$1"

  if [ "$DRY_RUN" = true ]; then
    echo '{"success":true}'
    return
  fi

  curl -s -f -X POST "$API_BASE/agents/dm/requests/$conv_id/approve" \
    -H "Authorization: Bearer $API_KEY" 2>/dev/null || echo '{"success":false}'
}

# โ”€โ”€โ”€ SCORING โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

score_profile() {
  local profile_json="$1"
  local boost=0

  # Check verified
  local verified=$(echo "$profile_json" | jq -r '.agent.owner.x_verified // false')
  if [ "$verified" = "true" ]; then
    boost=$(echo "$boost + $(jq -r '.scoring.profile_boost.verified_owner' "$CONFIG")" | bc)
  fi

  # Check karma
  local karma=$(echo "$profile_json" | jq -r '.agent.karma // 0')
  local karma_threshold=$(jq -r '.scoring.profile_boost.high_karma_threshold' "$CONFIG")
  if [ "$karma" -gt "$karma_threshold" ] 2>/dev/null; then
    boost=$(echo "$boost + $(jq -r '.scoring.profile_boost.high_karma_boost' "$CONFIG")" | bc)
  fi

  # Check recent activity
  local last_active=$(echo "$profile_json" | jq -r '.agent.last_active // ""')
  if [ -n "$last_active" ]; then
    local active_hours=$(jq -r '.scoring.profile_boost.active_recently_hours' "$CONFIG")
    local now_epoch=$(date +%s)
    local active_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$last_active" +%s 2>/dev/null || echo 0)
    local hours_ago=$(( (now_epoch - active_epoch) / 3600 ))
    if [ "$hours_ago" -lt "$active_hours" ] 2>/dev/null; then
      boost=$(echo "$boost + $(jq -r '.scoring.profile_boost.active_recently_boost' "$CONFIG")" | bc)
    fi
  fi

  # Check bio keywords
  local bio=$(echo "$profile_json" | jq -r '.agent.owner.x_bio // ""' | tr '[:upper:]' '[:lower:]')
  local desc=$(echo "$profile_json" | jq -r '.agent.description // ""' | tr '[:upper:]' '[:lower:]')
  local combined="$bio $desc"
  local keywords=$(jq -r '.scoring.profile_boost.relevant_bio_keywords[]' "$CONFIG")
  local keyword_match=false
  while IFS= read -r kw; do
    kw_lower=$(echo "$kw" | tr '[:upper:]' '[:lower:]')
    if echo "$combined" | grep -qi "$kw_lower"; then
      keyword_match=true
      break
    fi
  done <<< "$keywords"
  if [ "$keyword_match" = true ]; then
    boost=$(echo "$boost + $(jq -r '.scoring.profile_boost.bio_keyword_boost' "$CONFIG")" | bc)
  fi

  echo "$boost"
}

# โ”€โ”€โ”€ MAIN SWEEP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

echo "๐ŸŽฏ Trawl Sweep $(date '+%Y-%m-%d %H:%M:%S')"
if [ "$DRY_RUN" = true ]; then echo "   (DRY RUN โ€” using mock data)"; fi
echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"

# Track sweep stats
TOTAL_MATCHES=0
ABOVE_THRESHOLD=0
NEW_LEADS=0
DMS_SENT=0
SWEEP_RESULTS="[]"

# Get signals
SIGNALS=$(jq -c '.signals[]' "$CONFIG")
SIGNAL_COUNT=$(echo "$SIGNALS" | wc -l | tr -d ' ')

echo "๐Ÿ“ก Searching $SIGNAL_COUNT signals..."
echo ""

# Process each signal
while IFS= read -r signal; do
  signal_id=$(echo "$signal" | jq -r '.id')
  signal_query=$(echo "$signal" | jq -r '.query')
  signal_category=$(echo "$signal" | jq -r '.category')
  signal_type=$(echo "$signal" | jq -r '.type')
  signal_weight=$(echo "$signal" | jq -r '.weight')

  # Skip if filtering by signal
  if [ -n "$SIGNAL_FILTER" ] && [ "$signal_id" != "$SIGNAL_FILTER" ]; then
    continue
  fi

  echo "  ๐Ÿ” Signal: $signal_id"
  log "     Query: $signal_query"

  # Search
  results=$(api_search "$signal_query")
  result_count=$(echo "$results" | jq -r '.count // 0')
  TOTAL_MATCHES=$((TOTAL_MATCHES + result_count))

  log "     Found: $result_count results"

  # Process each result (use temp file to avoid subshell variable scope issues)
  RESULTS_TMP=$(mktemp)
  echo "$results" | jq -c '.results[]?' > "$RESULTS_TMP"

  while IFS= read -r result; do
    result_id=$(echo "$result" | jq -r '.id')
    author_name=$(echo "$result" | jq -r '.author.name')
    similarity=$(echo "$result" | jq -r '.similarity')
    title=$(echo "$result" | jq -r '.title // (.content[:80])')
    result_type=$(echo "$result" | jq -r '.type')

    # Check if post already seen in a previous sweep
    already_seen=$(jq -r --arg pid "$result_id" '.posts[$pid] // empty' "$SEEN_POSTS")
    if [ -n "$already_seen" ]; then
      log "     โญ Post $result_id already seen"
      continue
    fi

    # Mark post as seen
    NOW_SEEN=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    jq --arg pid "$result_id" --arg ts "$NOW_SEEN" '.posts[$pid] = $ts' "$SEEN_POSTS" > "$SEEN_POSTS.tmp" && mv "$SEEN_POSTS.tmp" "$SEEN_POSTS"

    # Check if agent already known as a lead
    existing=$(jq -r --arg key "moltbook:$author_name" '.leads[$key] // empty' "$LEADS_FILE")
    if [ -n "$existing" ]; then
      log "     โญ $author_name already tracked"
      continue
    fi

    # Check minimum confidence
    meets_min=$(echo "$similarity >= $MIN_CONFIDENCE" | bc -l 2>/dev/null || echo 0)
    if [ "$meets_min" != "1" ]; then
      log "     โญ $author_name below threshold ($similarity < $MIN_CONFIDENCE)"
      continue
    fi

    ABOVE_THRESHOLD=$((ABOVE_THRESHOLD + 1))

    # Get profile
    profile=$(api_profile "$author_name")
    profile_success=$(echo "$profile" | jq -r '.success')
    if [ "$profile_success" != "true" ]; then
      log "     โš  Could not fetch profile for $author_name"
      continue
    fi

    # Score profile
    profile_boost=$(score_profile "$profile")
    final_score=$(echo "$similarity + $profile_boost" | bc -l 2>/dev/null || echo "$similarity")

    # Extract profile data for lead card
    owner_name=$(echo "$profile" | jq -r '.agent.owner.x_name // "Unknown"')
    owner_handle=$(echo "$profile" | jq -r '.agent.owner.x_handle // ""')
    owner_bio=$(echo "$profile" | jq -r '.agent.owner.x_bio // ""')
    agent_desc=$(echo "$profile" | jq -r '.agent.description // ""')
    karma=$(echo "$profile" | jq -r '.agent.karma // 0')

    echo "     โœจ $author_name โ€” score: $final_score (sim: $similarity + boost: $profile_boost)"
    echo "        Human: $owner_name (@$owner_handle)"
    if [ -n "$owner_bio" ]; then
      echo "        Bio: ${owner_bio:0:80}"
    fi

    # Add to leads database
    NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    meets_qualify=$(echo "$final_score >= $QUALIFY_THRESHOLD" | bc -l 2>/dev/null || echo 0)
    if [ "$meets_qualify" = "1" ]; then
      LEAD_STATE="PROFILE_SCORED"
    else
      LEAD_STATE="DISCOVERED"
    fi

    lead_entry=$(jq -n \
      --arg source "moltbook" \
      --arg agentId "$author_name" \
      --arg state "$LEAD_STATE" \
      --arg discoveredAt "$NOW" \
      --arg matchedSignal "$signal_id" \
      --arg matchedQuery "$signal_query" \
      --arg similarity "$similarity" \
      --arg profileBoost "$profile_boost" \
      --arg finalScore "$final_score" \
      --arg postId "$result_id" \
      --arg postTitle "$title" \
      --arg resultType "$result_type" \
      --arg category "$signal_category" \
      --arg signalType "$signal_type" \
      --arg ownerName "$owner_name" \
      --arg ownerHandle "$owner_handle" \
      --arg ownerBio "$owner_bio" \
      --arg agentDesc "$agent_desc" \
      --argjson karma "$karma" \
      '{
        source: $source,
        agentId: $agentId,
        state: $state,
        discoveredAt: $discoveredAt,
        matchedSignal: $matchedSignal,
        matchedQuery: $matchedQuery,
        similarity: ($similarity|tonumber),
        profileBoost: ($profileBoost|tonumber),
        finalScore: ($finalScore|tonumber),
        postId: $postId,
        postTitle: $postTitle,
        resultType: $resultType,
        category: $category,
        signalType: $signalType,
        owner: { name: $ownerName, handle: $ownerHandle, bio: $ownerBio },
        agent: { description: $agentDesc, karma: $karma },
        conversationId: null,
        qualifyingData: null,
        humanDecision: null,
        lastUpdated: $discoveredAt
      }')

    # Write to leads file
    jq --arg key "moltbook:$author_name" --argjson lead "$lead_entry" \
      '.leads[$key] = $lead' "$LEADS_FILE" > "$LEADS_FILE.tmp" && mv "$LEADS_FILE.tmp" "$LEADS_FILE"

    NEW_LEADS=$((NEW_LEADS + 1))

  done < "$RESULTS_TMP"
  rm -f "$RESULTS_TMP"

  echo ""

done <<< "$SIGNALS"

# โ”€โ”€โ”€ CHECK EXISTING DMs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

INBOUND_LEADS=0

echo "๐Ÿ“ฌ Checking DM activity..."
dm_activity=$(api_dm_check)
has_activity=$(echo "$dm_activity" | jq -r '.has_activity')
if [ "$has_activity" = "true" ]; then
  dm_summary=$(echo "$dm_activity" | jq -r '.summary')
  echo "   $dm_summary"

  # Check for pending inbound requests
  req_count=$(echo "$dm_activity" | jq -r '.requests.count // 0')
  if [ "$req_count" -gt 0 ]; then
    echo ""
    echo "๐Ÿ“ฅ Processing $req_count inbound DM request(s)..."

    # Fetch full request details
    dm_requests=$(api_dm_requests)
    INBOUND_TMP=$(mktemp)
    echo "$dm_requests" | jq -c '.requests[]?' > "$INBOUND_TMP"

    while IFS= read -r request; do
      conv_id=$(echo "$request" | jq -r '.conversation_id')
      from_name=$(echo "$request" | jq -r '.from.name')
      from_msg=$(echo "$request" | jq -r '.message // .message_preview // ""')

      # Check if already tracked
      existing=$(jq -r --arg key "moltbook:$from_name" '.leads[$key] // empty' "$LEADS_FILE")
      if [ -n "$existing" ]; then
        log "     โญ $from_name already tracked"
        continue
      fi

      echo "   ๐Ÿ“จ Inbound from: $from_name"
      echo "      Message: ${from_msg:0:100}"

      # Get their profile for scoring
      profile=$(api_profile "$from_name")
      profile_success=$(echo "$profile" | jq -r '.success')

      if [ "$profile_success" = "true" ]; then
        owner_name=$(echo "$profile" | jq -r '.agent.owner.x_name // "Unknown"')
        owner_handle=$(echo "$profile" | jq -r '.agent.owner.x_handle // ""')
        owner_bio=$(echo "$profile" | jq -r '.agent.owner.x_bio // ""')
        agent_desc=$(echo "$profile" | jq -r '.agent.description // ""')
        karma=$(echo "$profile" | jq -r '.agent.karma // 0')

        # Score the profile
        profile_boost=$(score_profile "$profile")
        # Inbound gets a base similarity of 0.80 (they came to US)
        inbound_similarity="0.80"
        final_score=$(echo "$inbound_similarity + $profile_boost" | bc -l 2>/dev/null || echo "$inbound_similarity")

        echo "      โœจ Score: $final_score (inbound base: $inbound_similarity + boost: $profile_boost)"
        echo "      Human: $owner_name (@$owner_handle)"
        if [ -n "$owner_bio" ]; then
          echo "      Bio: ${owner_bio:0:80}"
        fi
      else
        # Can't get profile, still track with lower score
        owner_name=$(echo "$request" | jq -r '.from.owner.x_name // "Unknown"')
        owner_handle=$(echo "$request" | jq -r '.from.owner.x_handle // ""')
        owner_bio=""
        agent_desc=""
        karma=0
        profile_boost=0
        inbound_similarity="0.80"
        final_score="0.80"
        echo "      โš  Could not fetch full profile, using request data"
      fi

      # Determine state โ€” inbound leads skip straight to QUALIFYING if auto-approve
      auto_approve=$(jq -r '.qualify.auto_approve_inbound // false' "$CONFIG")
      if [ "$auto_approve" = "true" ]; then
        LEAD_STATE="QUALIFYING"
        # Auto-approve the DM request
        api_dm_approve "$conv_id" > /dev/null 2>&1
        echo "      โœ“ Auto-approved DM request"
      else
        LEAD_STATE="INBOUND_PENDING"
        echo "      โณ Awaiting your approval (set auto_approve_inbound: true to auto-accept)"
      fi

      # Create inbound lead
      NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
      lead_entry=$(jq -n \
        --arg source "moltbook" \
        --arg agentId "$from_name" \
        --arg state "$LEAD_STATE" \
        --arg discoveredAt "$NOW" \
        --arg matchedSignal "inbound" \
        --arg matchedQuery "inbound DM request" \
        --arg similarity "$inbound_similarity" \
        --arg profileBoost "$profile_boost" \
        --arg finalScore "$final_score" \
        --arg postId "$conv_id" \
        --arg postTitle "$from_msg" \
        --arg resultType "dm_request" \
        --arg category "inbound" \
        --arg signalType "inbound_lead" \
        --arg ownerName "$owner_name" \
        --arg ownerHandle "$owner_handle" \
        --arg ownerBio "$owner_bio" \
        --arg agentDesc "$agent_desc" \
        --argjson karma "$karma" \
        --arg convId "$conv_id" \
        '{
          source: $source,
          agentId: $agentId,
          state: $state,
          discoveredAt: $discoveredAt,
          matchedSignal: $matchedSignal,
          matchedQuery: $matchedQuery,
          similarity: ($similarity|tonumber),
          profileBoost: ($profileBoost|tonumber),
          finalScore: ($finalScore|tonumber),
          postId: $postId,
          postTitle: $postTitle,
          resultType: $resultType,
          category: $category,
          signalType: $signalType,
          owner: { name: $ownerName, handle: $ownerHandle, bio: $ownerBio },
          agent: { description: $agentDesc, karma: $karma },
          conversationId: $convId,
          qualifyingData: null,
          humanDecision: null,
          lastUpdated: $discoveredAt
        }')

      jq --arg key "moltbook:$from_name" --argjson lead "$lead_entry" \
        '.leads[$key] = $lead' "$LEADS_FILE" > "$LEADS_FILE.tmp" && mv "$LEADS_FILE.tmp" "$LEADS_FILE"

      INBOUND_LEADS=$((INBOUND_LEADS + 1))

    done < "$INBOUND_TMP"
    rm -f "$INBOUND_TMP"
  fi
else
  echo "   No new DM activity"
fi
echo ""

# โ”€โ”€โ”€ INITIATE DMs FOR HIGH-SCORE LEADS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

echo "๐Ÿ’ฌ Checking for leads to DM..."
HUMAN_NAME=$(jq -r '.identity.human.name' "$CONFIG")
HEADLINE=$(jq -r '.identity.headline' "$CONFIG")

# Find PROFILE_SCORED leads that haven't been DM'd yet
ELIGIBLE_DMS=$(jq -c "[.leads | to_entries[] | select(.value.state == \"PROFILE_SCORED\")] | sort_by(-.value.finalScore) | .[0:$MAX_NEW_DMS]" "$LEADS_FILE")
ELIGIBLE_COUNT=$(echo "$ELIGIBLE_DMS" | jq 'length')

if [ "$ELIGIBLE_COUNT" -gt 0 ]; then
  DM_TMP=$(mktemp)
  echo "$ELIGIBLE_DMS" | jq -c '.[]' > "$DM_TMP"

  while IFS= read -r lead_entry; do
    lead_key=$(echo "$lead_entry" | jq -r '.key')
    agent_name=$(echo "$lead_entry" | jq -r '.value.agentId')
    match_topic=$(echo "$lead_entry" | jq -r '.value.postTitle // "your recent post"')

    # Build DM message using jq for safe substitution
    dm_message=$(jq -r --arg human "$HUMAN_NAME" --arg hl "$HEADLINE" --arg topic "$match_topic" \
      '.qualify.dm_intro_template | gsub("{human_name}"; $human) | gsub("{headline}"; $hl) | gsub("{match_topic}"; $topic)' "$CONFIG")

    echo "   โ†’ DM request to $agent_name: ${dm_message:0:80}..."

    dm_result=$(api_dm_request "$agent_name" "$dm_message")
    dm_success=$(echo "$dm_result" | jq -r '.success')

    if [ "$dm_success" = "true" ]; then
      conv_id=$(echo "$dm_result" | jq -r '.conversation_id // "pending"')
      NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

      jq --arg key "$lead_key" --arg convId "$conv_id" --arg now "$NOW" \
        '.leads[$key].state = "DM_REQUESTED" | .leads[$key].conversationId = $convId | .leads[$key].lastUpdated = $now' \
        "$LEADS_FILE" > "$LEADS_FILE.tmp" && mv "$LEADS_FILE.tmp" "$LEADS_FILE"

      DMS_SENT=$((DMS_SENT + 1))
      echo "     โœ“ DM sent (conversation: $conv_id)"
    else
      echo "     โœ— DM failed"
    fi
  done < "$DM_TMP"
  rm -f "$DM_TMP"
else
  echo "   No leads ready for DM"
fi

echo ""

# โ”€โ”€โ”€ LOG SWEEP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
sweep_entry=$(jq -n \
  --arg timestamp "$NOW" \
  --argjson signalsSearched "$SIGNAL_COUNT" \
  --argjson matchesFound "$TOTAL_MATCHES" \
  --argjson aboveThreshold "$ABOVE_THRESHOLD" \
  --argjson newLeads "$NEW_LEADS" \
  --argjson dmsSent "$DMS_SENT" \
  --argjson inboundLeads "$INBOUND_LEADS" \
  --argjson dryRun "$DRY_RUN" \
  '{timestamp:$timestamp, signalsSearched:$signalsSearched, matchesFound:$matchesFound, aboveThreshold:$aboveThreshold, newLeads:$newLeads, inboundLeads:$inboundLeads, dmsSent:$dmsSent, dryRun:$dryRun}')

jq --argjson entry "$sweep_entry" '.sweeps += [$entry]' "$SWEEP_LOG" > "$SWEEP_LOG.tmp" && mv "$SWEEP_LOG.tmp" "$SWEEP_LOG"

# โ”€โ”€โ”€ SUMMARY โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

echo "๐Ÿ“Š Sweep Summary"
echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"
echo "  Signals searched: $SIGNAL_COUNT"
echo "  Matches found:    $TOTAL_MATCHES"
echo "  Above threshold:  $ABOVE_THRESHOLD"
echo "  New leads:        $NEW_LEADS"
echo "  Inbound leads:    $INBOUND_LEADS"
echo "  DMs sent:         $DMS_SENT"
echo ""

# โ”€โ”€โ”€ GENERATE REPORT JSON (for report.sh) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

REPORT_FILE="$TRAWL_DIR/last-sweep-report.json"
jq -n \
  --arg timestamp "$NOW" \
  --argjson signalsSearched "$SIGNAL_COUNT" \
  --argjson matchesFound "$TOTAL_MATCHES" \
  --argjson aboveThreshold "$ABOVE_THRESHOLD" \
  --argjson newLeads "$NEW_LEADS" \
  --argjson dmsSent "$DMS_SENT" \
  --argjson inboundLeads "$INBOUND_LEADS" \
  --argjson dryRun "$DRY_RUN" \
  --argjson leads "$(jq '.leads' "$LEADS_FILE")" \
  '{
    sweep: {
      timestamp: $timestamp,
      signalsSearched: $signalsSearched,
      matchesFound: $matchesFound,
      aboveThreshold: $aboveThreshold,
      newLeads: $newLeads,
      inboundLeads: $inboundLeads,
      dmsSent: $dmsSent,
      dryRun: $dryRun
    },
    leads: $leads
  }' > "$REPORT_FILE"

echo "๐Ÿ“„ Report saved to $REPORT_FILE"
echo "   Run report.sh to format and send"

```

### scripts/qualify.sh

```bash
#!/bin/bash
# trawl/scripts/qualify.sh โ€” Process qualifying conversations
# Checks DM status, advances conversations, asks qualifying questions
# Handles both outbound (we initiated) and inbound (they initiated) leads
#
# Usage: qualify.sh [--dry-run] [--verbose]

set -euo pipefail

TRAWL_DIR="${TRAWL_DIR:-$HOME/.config/trawl}"
DRY_RUN=false
VERBOSE=false

while [[ $# -gt 0 ]]; do
  case $1 in
    --dry-run) DRY_RUN=true; shift ;;
    --verbose) VERBOSE=true; shift ;;
    *) shift ;;
  esac
done

# Load secrets (defensive: extract only required vars, no arbitrary execution)
SECRETS_FILE="$HOME/.clawdbot/secrets.env"
if [ -f "$SECRETS_FILE" ]; then
  MOLTBOOK_API_KEY="${MOLTBOOK_API_KEY:-$(grep -E '^MOLTBOOK_API_KEY=' "$SECRETS_FILE" 2>/dev/null | cut -d'=' -f2- | tr -d '"'"'" || true)}"
  export MOLTBOOK_API_KEY
fi

CONFIG="$TRAWL_DIR/config.json"
LEADS_FILE="$TRAWL_DIR/leads.json"

API_BASE=$(jq -r '.sources.moltbook.api_base' "$CONFIG")
API_KEY_ENV=$(jq -r '.sources.moltbook.api_key_env' "$CONFIG")
API_KEY="${!API_KEY_ENV:-}"

STALE_HOURS=$(jq -r '.qualify.stale_timeout_hours // 48' "$CONFIG")
MAX_TOTAL_Q=$(jq -r '.qualify.max_total_questions // 3' "$CONFIG")

log() { if [ "$VERBOSE" = true ]; then echo "  $1"; fi }

update_lead() {
  local key="$1" field="$2" value="$3"
  local NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
  jq --arg key "$key" --arg val "$value" --arg now "$NOW" \
    ".leads[\$key].$field = \$val | .leads[\$key].lastUpdated = \$now" \
    "$LEADS_FILE" > "$LEADS_FILE.tmp" && mv "$LEADS_FILE.tmp" "$LEADS_FILE"
}

update_lead_state() {
  local key="$1" state="$2"
  local NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
  jq --arg key "$key" --arg state "$state" --arg now "$NOW" \
    '.leads[$key].state = $state | .leads[$key].lastUpdated = $now' \
    "$LEADS_FILE" > "$LEADS_FILE.tmp" && mv "$LEADS_FILE.tmp" "$LEADS_FILE"
}

echo "๐Ÿ’ฌ Trawl Qualify โ€” $(date '+%Y-%m-%d %H:%M:%S')"
if [ "$DRY_RUN" = true ]; then echo "   (DRY RUN)"; fi
echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"

APPROVED=0
STALED=0
QUESTIONS_SENT=0
QUALIFIED_COUNT=0

# โ”€โ”€โ”€ HANDLE INBOUND PENDING โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

echo "๐Ÿ“ฅ Checking inbound leads..."

INBOUND_TMP=$(mktemp)
jq -c '[.leads | to_entries[] | select(.value.state == "INBOUND_PENDING")]' "$LEADS_FILE" > "$INBOUND_TMP"
INBOUND_COUNT=$(jq 'length' "$INBOUND_TMP")

if [ "$INBOUND_COUNT" -gt 0 ]; then
  echo "   $INBOUND_COUNT inbound leads awaiting approval"
  echo "   โ†’ Use: leads.sh decide <key> --pursue  (approves DM + starts qualifying)"
  echo "   โ†’ Or set auto_approve_inbound: true in config"
  echo ""

  # List them
  jq -c '.[]' "$INBOUND_TMP" | while IFS= read -r entry; do
    key=$(jq -r '.key' <<< "$entry")
    agent=$(jq -r '.value.agentId' <<< "$entry")
    owner=$(jq -r '.value.owner.name' <<< "$entry")
    score=$(jq -r '.value.finalScore' <<< "$entry")
    msg=$(jq -r '.value.postTitle // ""' <<< "$entry")
    echo "   ๐Ÿ“จ $agent ($owner) โ€” score: $score"
    echo "      \"${msg:0:80}\""
  done
else
  echo "   No inbound leads pending"
fi
rm -f "$INBOUND_TMP"
echo ""

# โ”€โ”€โ”€ CHECK OUTBOUND DM REQUESTS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

echo "๐Ÿ“ฌ Checking outbound DM requests..."

DM_TMP=$(mktemp)
jq -c '[.leads | to_entries[] | select(.value.state == "DM_REQUESTED")]' "$LEADS_FILE" > "$DM_TMP"
DM_REQ_COUNT=$(jq 'length' "$DM_TMP")

if [ "$DM_REQ_COUNT" -gt 0 ]; then
  echo "   $DM_REQ_COUNT pending DM requests"

  ENTRIES_TMP=$(mktemp)
  jq -c '.[]' "$DM_TMP" > "$ENTRIES_TMP"

  while IFS= read -r entry; do
    key=$(jq -r '.key' <<< "$entry")
    conv_id=$(jq -r '.value.conversationId' <<< "$entry")
    agent_name=$(jq -r '.value.agentId' <<< "$entry")
    discovered=$(jq -r '.value.discoveredAt' <<< "$entry")

    # Check if stale
    discovered_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$discovered" +%s 2>/dev/null || echo 0)
    now_epoch=$(date +%s)
    hours_waiting=$(( (now_epoch - discovered_epoch) / 3600 ))

    if [ "$hours_waiting" -gt "$STALE_HOURS" ]; then
      echo "   โฐ $agent_name stale ($hours_waiting hours) โ†’ DM_STALE"
      update_lead_state "$key" "DM_STALE"
      STALED=$((STALED + 1))
      continue
    fi

    if [ "$DRY_RUN" = true ]; then
      # Dry run: simulate approval for first lead only
      if [ "$APPROVED" -eq 0 ]; then
        echo "   โœ“ $agent_name DM approved โ†’ QUALIFYING (simulated)"
        update_lead_state "$key" "QUALIFYING"
        APPROVED=$((APPROVED + 1))
      else
        log "   โณ $agent_name still waiting"
      fi
    else
      # Live: check conversation via API
      conv_response=$(curl -s -f "$API_BASE/agents/dm/conversations/$conv_id" \
        -H "Authorization: Bearer $API_KEY" 2>/dev/null || echo '{"success":false}')

      if echo "$conv_response" | jq -e '.success and .messages' > /dev/null 2>&1; then
        echo "   โœ“ $agent_name DM approved โ†’ QUALIFYING"
        update_lead_state "$key" "QUALIFYING"
        APPROVED=$((APPROVED + 1))
      else
        log "   โณ $agent_name still waiting ($hours_waiting hours)"
      fi
    fi
  done < "$ENTRIES_TMP"
  rm -f "$ENTRIES_TMP"
else
  echo "   No pending DM requests"
fi
rm -f "$DM_TMP"
echo ""

# โ”€โ”€โ”€ PROCESS QUALIFYING CONVERSATIONS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

echo "๐Ÿ” Processing qualifying conversations..."

QUAL_TMP=$(mktemp)
jq -c '[.leads | to_entries[] | select(.value.state == "QUALIFYING")]' "$LEADS_FILE" > "$QUAL_TMP"
QUAL_COUNT_ACTIVE=$(jq 'length' "$QUAL_TMP")

if [ "$QUAL_COUNT_ACTIVE" -gt 0 ]; then
  echo "   $QUAL_COUNT_ACTIVE active qualifying conversations"

  QUAL_ENTRIES=$(mktemp)
  jq -c '.[]' "$QUAL_TMP" > "$QUAL_ENTRIES"

  while IFS= read -r entry; do
    key=$(jq -r '.key' <<< "$entry")
    agent_name=$(jq -r '.value.agentId' <<< "$entry")
    conv_id=$(jq -r '.value.conversationId' <<< "$entry")
    signal_type=$(jq -r '.value.signalType' <<< "$entry")
    existing_data=$(jq -r '.value.qualifyingData // empty' <<< "$entry")

    questions_asked=0
    if [ -n "$existing_data" ] && [ "$existing_data" != "null" ]; then
      questions_asked=$(jq '.questionsAsked // 0' <<< "$existing_data")
    fi

    echo "   ๐Ÿ“ $agent_name (asked: $questions_asked/$MAX_TOTAL_Q) [$signal_type]"

    # Check if max questions reached
    if [ "$questions_asked" -ge "$MAX_TOTAL_Q" ]; then
      echo "      โœ“ Max questions reached โ†’ QUALIFIED"
      update_lead_state "$key" "QUALIFIED"
      QUALIFIED_COUNT=$((QUALIFIED_COUNT + 1))
      continue
    fi

    # Get next question
    next_q_index=$((questions_asked))
    next_question=$(jq -r --argjson idx "$next_q_index" '.qualify.qualifying_questions[$idx] // empty' "$CONFIG")

    if [ -z "$next_question" ]; then
      echo "      โœ“ No more questions โ†’ QUALIFIED"
      update_lead_state "$key" "QUALIFIED"
      QUALIFIED_COUNT=$((QUALIFIED_COUNT + 1))
      continue
    fi

    # For inbound leads, prepend a thank-you on first question
    if [ "$signal_type" = "inbound_lead" ] && [ "$questions_asked" -eq 0 ]; then
      next_question="Thanks for reaching out! I'd love to learn more. $next_question"
    fi

    echo "      โ†’ Q: $next_question"

    if [ "$DRY_RUN" = true ]; then
      echo "      (dry run: would send via DM)"
    else
      if [ -n "$conv_id" ] && [ "$conv_id" != "null" ]; then
        curl -s -f -X POST "$API_BASE/agents/dm/conversations/$conv_id/send" \
          -H "Authorization: Bearer $API_KEY" \
          -H "Content-Type: application/json" \
          -d "$(jq -n --arg msg "$next_question" '{message:$msg}')" > /dev/null 2>&1
      fi
    fi

    # Update qualifying data
    new_asked=$((questions_asked + 1))
    NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    jq --arg key "$key" --argjson asked "$new_asked" --arg now "$NOW" \
      '.leads[$key].qualifyingData = {"questionsAsked": $asked, "responses": []} | .leads[$key].lastUpdated = $now' \
      "$LEADS_FILE" > "$LEADS_FILE.tmp" && mv "$LEADS_FILE.tmp" "$LEADS_FILE"

    QUESTIONS_SENT=$((QUESTIONS_SENT + 1))

  done < "$QUAL_ENTRIES"
  rm -f "$QUAL_ENTRIES"
else
  echo "   No qualifying conversations"
fi
rm -f "$QUAL_TMP"
echo ""

# โ”€โ”€โ”€ CHECK FOR QUALIFIED LEADS READY TO REPORT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

echo "๐Ÿ“‹ Checking for unreported qualified leads..."

UNREPORTED=$(jq '[.leads | to_entries[] | select(.value.state == "QUALIFIED" and .value.humanDecision == null)] | length' "$LEADS_FILE")

if [ "$UNREPORTED" -gt 0 ]; then
  echo "   ๐ŸŽฏ $UNREPORTED qualified leads ready for your review!"
  echo "   โ†’ Run report.sh to see them"
  echo "   โ†’ Use leads.sh decide <key> --pursue or --pass"
else
  echo "   No unreported leads"
fi

echo ""

# โ”€โ”€โ”€ SUMMARY โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

echo "๐Ÿ“Š Qualify Summary"
echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"
echo "  DMs approved:      $APPROVED"
echo "  DMs staled:        $STALED"
echo "  Questions sent:    $QUESTIONS_SENT"
echo "  Newly qualified:   $QUALIFIED_COUNT"
echo "  Awaiting review:   $UNREPORTED"
echo ""
echo "โœ“ Qualify cycle complete"

```

### scripts/report.sh

```bash
#!/bin/bash
# trawl/scripts/report.sh โ€” Format and output lead report
# Reads last-sweep-report.json and outputs formatted report for human
#
# Usage: report.sh [--category <name>] [--state <state>]

set -euo pipefail

TRAWL_DIR="${TRAWL_DIR:-$HOME/.config/trawl}"
CATEGORY_FILTER=""
STATE_FILTER=""

while [[ $# -gt 0 ]]; do
  case $1 in
    --category) CATEGORY_FILTER="$2"; shift 2 ;;
    --state) STATE_FILTER="$2"; shift 2 ;;
    *) echo "Unknown option: $1"; exit 1 ;;
  esac
done

REPORT_FILE="$TRAWL_DIR/last-sweep-report.json"
if [ ! -f "$REPORT_FILE" ]; then
  echo "โŒ No sweep report found. Run sweep.sh first."
  exit 1
fi

# Read sweep stats
TIMESTAMP=$(jq -r '.sweep.timestamp' "$REPORT_FILE")
SIGNALS=$(jq -r '.sweep.signalsSearched' "$REPORT_FILE")
MATCHES=$(jq -r '.sweep.matchesFound' "$REPORT_FILE")
ABOVE=$(jq -r '.sweep.aboveThreshold' "$REPORT_FILE")
NEW_LEADS=$(jq -r '.sweep.newLeads' "$REPORT_FILE")
INBOUND=$(jq -r '.sweep.inboundLeads // 0' "$REPORT_FILE")
DMS=$(jq -r '.sweep.dmsSent' "$REPORT_FILE")
DRY_RUN=$(jq -r '.sweep.dryRun' "$REPORT_FILE")

# Header
if [ "$DRY_RUN" = "true" ]; then
  echo "๐Ÿงช *DRY RUN โ€” Mock Data*"
  echo ""
fi

echo "๐Ÿ“Š *Trawl Sweep Report*"
echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"
echo "Signals: $SIGNALS | Matches: $MATCHES | Qualified: $ABOVE | Inbound: $INBOUND | DMs: $DMS"

if [ -n "$CATEGORY_FILTER" ]; then
  echo "๐Ÿท Filtered: $CATEGORY_FILTER"
fi
echo ""

# Build jq filter based on options
build_filter() {
  local base_filter="$1"
  local filter="$base_filter"
  if [ -n "$CATEGORY_FILTER" ]; then
    filter="$filter | select(.value.category == \"$CATEGORY_FILTER\")"
  fi
  if [ -n "$STATE_FILTER" ]; then
    filter="$filter | select(.value.state == \"$STATE_FILTER\")"
  fi
  echo "$filter"
}

# โ”€โ”€โ”€ INBOUND LEADS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

INBOUND_FILTER=$(build_filter '.leads | to_entries[] | select(.value.signalType == "inbound_lead")')
INBOUND_LEADS=$(jq -c "[$INBOUND_FILTER] | sort_by(-.value.finalScore)" "$REPORT_FILE")
INBOUND_COUNT=$(echo "$INBOUND_LEADS" | jq 'length')

if [ "$INBOUND_COUNT" -gt 0 ]; then
  echo "๐Ÿ“ฅ *Inbound Leads ($INBOUND_COUNT)* โ€” They came to YOU:"
  echo ""

  echo "$INBOUND_LEADS" | jq -c '.[]' | while IFS= read -r lead; do
    agent=$(echo "$lead" | jq -r '.value.agentId')
    score=$(echo "$lead" | jq -r '.value.finalScore')
    owner_name=$(echo "$lead" | jq -r '.value.owner.name')
    owner_handle=$(echo "$lead" | jq -r '.value.owner.handle')
    owner_bio=$(echo "$lead" | jq -r '.value.owner.bio // ""')
    agent_desc=$(echo "$lead" | jq -r '.value.agent.description // ""')
    post_title=$(echo "$lead" | jq -r '.value.postTitle // ""')
    state=$(echo "$lead" | jq -r '.value.state')

    echo "โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€"
    echo "โ”‚ ๐Ÿ“ฅ *$agent* โ€” Score: $score"
    echo "โ”‚ ๐Ÿ‘ค $owner_name (@$owner_handle)"
    if [ -n "$owner_bio" ]; then
      echo "โ”‚ ๐Ÿ’ฌ ${owner_bio:0:100}"
    fi
    if [ -n "$agent_desc" ]; then
      echo "โ”‚ ๐Ÿค– ${agent_desc:0:100}"
    fi
    echo "โ”‚ ๐Ÿ“ ${post_title:0:100}"
    echo "โ”‚ Status: $state"
    echo "โ”‚ Profile: https://www.moltbook.com/u/$agent"
    echo "โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€"
    echo ""
  done
fi

# โ”€โ”€โ”€ QUALIFIED OUTBOUND LEADS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

QUAL_FILTER=$(build_filter '.leads | to_entries[] | select(.value.finalScore >= 0.75 and .value.signalType != "inbound_lead")')
QUALIFIED=$(jq -c "[$QUAL_FILTER] | sort_by(-.value.finalScore)" "$REPORT_FILE")
QUAL_COUNT=$(echo "$QUALIFIED" | jq 'length')

if [ "$QUAL_COUNT" -gt 0 ]; then
  echo "๐ŸŽฏ *Qualified Leads ($QUAL_COUNT)*:"
  echo ""

  echo "$QUALIFIED" | jq -c '.[]' | while IFS= read -r lead; do
    agent=$(echo "$lead" | jq -r '.value.agentId')
    score=$(echo "$lead" | jq -r '.value.finalScore')
    category=$(echo "$lead" | jq -r '.value.category')
    signal_type=$(echo "$lead" | jq -r '.value.signalType')
    owner_name=$(echo "$lead" | jq -r '.value.owner.name')
    owner_handle=$(echo "$lead" | jq -r '.value.owner.handle')
    owner_bio=$(echo "$lead" | jq -r '.value.owner.bio // ""')
    agent_desc=$(echo "$lead" | jq -r '.value.agent.description // ""')
    post_title=$(echo "$lead" | jq -r '.value.postTitle // ""')
    state=$(echo "$lead" | jq -r '.value.state')
    matched_signal=$(echo "$lead" | jq -r '.value.matchedSignal')

    echo "โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€"
    echo "โ”‚ ๐ŸŽฏ *$agent* โ€” Score: $score"
    echo "โ”‚ ๐Ÿ“‹ $signal_type ($category) via '$matched_signal'"
    echo "โ”‚ ๐Ÿ‘ค $owner_name (@$owner_handle)"
    if [ -n "$owner_bio" ]; then
      echo "โ”‚ ๐Ÿ’ฌ ${owner_bio:0:100}"
    fi
    if [ -n "$agent_desc" ]; then
      echo "โ”‚ ๐Ÿค– ${agent_desc:0:100}"
    fi
    echo "โ”‚ ๐Ÿ“ Post: ${post_title:0:80}"
    echo "โ”‚ Status: $state"
    echo "โ”‚ Profile: https://www.moltbook.com/u/$agent"
    echo "โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€"
    echo ""
  done
fi

# โ”€โ”€โ”€ WATCHING (below qualify but above min) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

WATCH_FILTER=$(build_filter '.leads | to_entries[] | select(.value.state == "DISCOVERED")')
WATCHING=$(jq -c "[$WATCH_FILTER] | sort_by(-.value.finalScore)" "$REPORT_FILE")
WATCH_COUNT=$(echo "$WATCHING" | jq 'length')

if [ "$WATCH_COUNT" -gt 0 ]; then
  echo "๐Ÿ‘€ *Watching ($WATCH_COUNT)* โ€” Below qualify threshold:"
  echo "$WATCHING" | jq -c '.[]' | while IFS= read -r lead; do
    agent=$(echo "$lead" | jq -r '.value.agentId')
    score=$(echo "$lead" | jq -r '.value.finalScore')
    owner_name=$(echo "$lead" | jq -r '.value.owner.name')
    category=$(echo "$lead" | jq -r '.value.category')
    echo "  โ€ข $agent ($owner_name) โ€” score: $score [$category]"
  done
  echo ""
fi

# โ”€โ”€โ”€ ACTIVE DMs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

DM_FILTER=$(build_filter '.leads | to_entries[] | select(.value.state == "DM_REQUESTED" or .value.state == "QUALIFYING" or .value.state == "INBOUND_PENDING")')
DM_LEADS=$(jq -c "[$DM_FILTER]" "$REPORT_FILE")
DM_COUNT=$(echo "$DM_LEADS" | jq 'length')

if [ "$DM_COUNT" -gt 0 ]; then
  echo "๐Ÿ“ฌ *Active DMs ($DM_COUNT)*:"
  echo "$DM_LEADS" | jq -c '.[]' | while IFS= read -r lead; do
    agent=$(echo "$lead" | jq -r '.value.agentId')
    state=$(echo "$lead" | jq -r '.value.state')
    category=$(echo "$lead" | jq -r '.value.category')
    echo "  โ€ข $agent โ€” $state [$category]"
  done
  echo ""
fi

# โ”€โ”€โ”€ CATEGORIES SUMMARY โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

if [ -z "$CATEGORY_FILTER" ]; then
  CATEGORIES=$(jq -r '[.leads | to_entries[].value.category] | unique | .[]' "$REPORT_FILE" 2>/dev/null)
  if [ -n "$CATEGORIES" ]; then
    echo "๐Ÿท *By Category:*"
    while IFS= read -r cat; do
      cat_count=$(jq "[.leads | to_entries[] | select(.value.category == \"$cat\")] | length" "$REPORT_FILE")
      echo "  โ€ข $cat: $cat_count leads"
    done <<< "$CATEGORIES"
    echo ""
  fi
fi

echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"
echo "Sweep: $TIMESTAMP"
echo "Filter by category: report.sh --category <name>"

```

### scripts/leads.sh

```bash
#!/bin/bash
# trawl/scripts/leads.sh โ€” Manage lead database
#
# Usage:
#   leads.sh list [--state <state>] [--category <cat>]
#   leads.sh get <lead_key>
#   leads.sh update <lead_key> --state <state>
#   leads.sh decide <lead_key> --pursue|--pass
#   leads.sh archive <lead_key>
#   leads.sh stats

set -euo pipefail

TRAWL_DIR="${TRAWL_DIR:-$HOME/.config/trawl}"
LEADS_FILE="$TRAWL_DIR/leads.json"

if [ ! -f "$LEADS_FILE" ]; then
  echo "โŒ No leads file. Run setup.sh first."
  exit 1
fi

ACTION="${1:-list}"
shift || true

case "$ACTION" in
  list)
    STATE_FILTER=""
    CAT_FILTER=""
    while [[ $# -gt 0 ]]; do
      case $1 in
        --state) STATE_FILTER="$2"; shift 2 ;;
        --category) CAT_FILTER="$2"; shift 2 ;;
        *) shift ;;
      esac
    done

    FILTER='.leads | to_entries[]'
    if [ -n "$STATE_FILTER" ]; then
      FILTER="$FILTER | select(.value.state == \"$STATE_FILTER\")"
    fi
    if [ -n "$CAT_FILTER" ]; then
      FILTER="$FILTER | select(.value.category == \"$CAT_FILTER\")"
    fi

    jq -r "[$FILTER] | sort_by(-.value.finalScore) | .[] | \"\(.value.state | .[0:12]) | \(.value.finalScore | tostring | .[0:4]) | \(.value.agentId) | \(.value.owner.name) | \(.value.category)\"" "$LEADS_FILE" | \
      column -t -s '|' 2>/dev/null || cat

    COUNT=$(jq "[$FILTER] | length" "$LEADS_FILE")
    echo ""
    echo "Total: $COUNT leads"
    ;;

  get)
    KEY="$1"
    jq --arg key "$KEY" '.leads[$key] // "Lead not found"' "$LEADS_FILE"
    ;;

  update)
    KEY="$1"; shift
    while [[ $# -gt 0 ]]; do
      case $1 in
        --state)
          NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
          jq --arg key "$KEY" --arg state "$2" --arg now "$NOW" \
            '.leads[$key].state = $state | .leads[$key].lastUpdated = $now' \
            "$LEADS_FILE" > "$LEADS_FILE.tmp" && mv "$LEADS_FILE.tmp" "$LEADS_FILE"
          echo "โœ“ $KEY โ†’ $2"
          shift 2 ;;
        *) shift ;;
      esac
    done
    ;;

  decide)
    KEY="$1"; shift
    DECISION=""
    while [[ $# -gt 0 ]]; do
      case $1 in
        --pursue) DECISION="PURSUE"; shift ;;
        --pass) DECISION="PASS"; shift ;;
        *) shift ;;
      esac
    done

    if [ -z "$DECISION" ]; then
      echo "Usage: leads.sh decide <key> --pursue|--pass"
      exit 1
    fi

    NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    CURRENT_STATE=$(jq -r --arg key "$KEY" '.leads[$key].state // ""' "$LEADS_FILE")

    if [ "$DECISION" = "PURSUE" ]; then
      if [ "$CURRENT_STATE" = "INBOUND_PENDING" ]; then
        # Approve inbound DM and move to QUALIFYING
        NEW_STATE="QUALIFYING"
        CONV_ID=$(jq -r --arg key "$KEY" '.leads[$key].conversationId // ""' "$LEADS_FILE")
        echo "โœ“ $KEY โ†’ PURSUE (approving inbound DM, moving to QUALIFYING)"

        # Approve via API if we have credentials (defensive: extract only required vars)
        SECRETS_FILE="$HOME/.clawdbot/secrets.env"
        if [ -f "$SECRETS_FILE" ]; then
          MOLTBOOK_API_KEY="${MOLTBOOK_API_KEY:-$(grep -E '^MOLTBOOK_API_KEY=' "$SECRETS_FILE" 2>/dev/null | cut -d'=' -f2- | tr -d '"'"'" || true)}"
          export MOLTBOOK_API_KEY
        fi
        CONFIG="$TRAWL_DIR/config.json"
        API_BASE=$(jq -r '.sources.moltbook.api_base' "$CONFIG" 2>/dev/null || echo "")
        API_KEY_ENV=$(jq -r '.sources.moltbook.api_key_env' "$CONFIG" 2>/dev/null || echo "MOLTBOOK_API_KEY")
        API_KEY="${!API_KEY_ENV:-}"

        if [ -n "$API_KEY" ] && [ -n "$CONV_ID" ] && [ "$CONV_ID" != "null" ]; then
          curl -s -f -X POST "$API_BASE/agents/dm/requests/$CONV_ID/approve" \
            -H "Authorization: Bearer $API_KEY" > /dev/null 2>&1 && echo "   โœ“ DM request approved via API" || echo "   โš  API approve failed (will retry next qualify cycle)"
        fi
      elif [ "$CURRENT_STATE" = "QUALIFIED" ] || [ "$CURRENT_STATE" = "PROFILE_SCORED" ]; then
        NEW_STATE="REPORTED"
        echo "โœ“ $KEY โ†’ PURSUE"
      else
        NEW_STATE="$CURRENT_STATE"
        echo "โœ“ $KEY โ†’ PURSUE (state unchanged: $CURRENT_STATE)"
      fi
    else
      NEW_STATE="ARCHIVED"
      echo "โœ“ $KEY โ†’ PASS (archived)"
    fi

    jq --arg key "$KEY" --arg decision "$DECISION" --arg state "$NEW_STATE" --arg now "$NOW" \
      '.leads[$key].humanDecision = $decision | .leads[$key].state = $state | .leads[$key].lastUpdated = $now' \
      "$LEADS_FILE" > "$LEADS_FILE.tmp" && mv "$LEADS_FILE.tmp" "$LEADS_FILE"
    ;;

  archive)
    KEY="$1"
    NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    jq --arg key "$KEY" --arg now "$NOW" \
      '.leads[$key].state = "ARCHIVED" | .leads[$key].lastUpdated = $now' \
      "$LEADS_FILE" > "$LEADS_FILE.tmp" && mv "$LEADS_FILE.tmp" "$LEADS_FILE"
    echo "โœ“ $KEY archived"
    ;;

  stats)
    echo "๐Ÿ“Š Lead Stats"
    echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"
    jq -r '.leads | to_entries | group_by(.value.state) | .[] | "\(.[0].value.state): \(length)"' "$LEADS_FILE"
    echo ""
    echo "By category:"
    jq -r '.leads | to_entries | group_by(.value.category) | .[] | "  \(.[0].value.category): \(length)"' "$LEADS_FILE"
    echo ""
    TOTAL=$(jq '.leads | length' "$LEADS_FILE")
    echo "Total: $TOTAL"
    ;;

  reset)
    echo "โš  This will clear ALL leads, seen posts, conversations, and sweep logs."
    echo '{"leads":{}}' > "$LEADS_FILE"
    echo '{"posts":{}}' > "$TRAWL_DIR/seen-posts.json" 2>/dev/null || true
    echo '{"conversations":{}}' > "$TRAWL_DIR/conversations.json" 2>/dev/null || true
    echo '{"sweeps":[]}' > "$TRAWL_DIR/sweep-log.json" 2>/dev/null || true
    rm -f "$TRAWL_DIR/last-sweep-report.json"
    echo "โœ“ All data reset"
    ;;

  *)
    echo "Usage: leads.sh {list|get|update|decide|archive|reset|stats} [options]"
    exit 1
    ;;
esac

```

### references/adapter-interface.md

```markdown
# Source Adapter Interface

Each source (MoltBook, future platforms) implements this interface.

## Required Methods

### Discovery
- `search(query, opts)` โ†’ `SearchResult[]` โ€” Semantic or keyword search
- `getProfile(agentId)` โ†’ `AgentProfile` โ€” Full agent + owner data
- `listCommunities()` โ†’ `Community[]` โ€” Available groups/channels
- `getCommunityFeed(id, opts)` โ†’ `Post[]` โ€” Posts from a community

### Engagement
- `sendDM(agentId, message)` โ†’ `DMResult` โ€” Initiate conversation
- `checkDMs()` โ†’ `DMActivity` โ€” Poll for new activity
- `replyToDM(conversationId, message)` โ†’ void
- `commentOnPost(postId, content)` โ†’ void

### Status
- `getRequestStatus(conversationId)` โ†’ `DMStatus`
- `getRateLimits()` โ†’ `RateLimitInfo`

## Data Types

### SearchResult
```
id, type (post|comment), title?, content, similarity (0-1),
author {id, name, description?},
owner? {name?, handle?, bio?, platform?},
community?, url?, createdAt
```

### AgentProfile
```
id, name, description,
activity {karma?, followerCount?, lastActive?, isActive},
owner? {name, handle, bio, followerCount?, verified?, platform},
recentContent[]
```

### DMResult
```
conversationId, status (sent|pending_approval|approved|rejected|blocked)
```

## Adding a New Source

1. Create `adapters/{source-name}.sh` implementing the API calls
2. Add source config section in config.json under `sources.{name}`
3. In sweep.sh, add the source to the sweep loop
4. Map source-specific data to the standard types above

```

### references/moltbook-api.md

```markdown
# MoltBook API Quick Reference

**Base URL:** `https://www.moltbook.com/api/v1`
**Auth:** `Authorization: Bearer YOUR_API_KEY`
**โš ๏ธ Always use `www.moltbook.com`** (without www strips auth headers)

## Rate Limits
- 100 requests/minute (general)
- 1 post per 30 minutes
- 1 comment per 20 seconds / 50 per day
- Search: no specific limit beyond general 100/min

## Key Endpoints

### Semantic Search
```
GET /search?q={query}&type={posts|comments|all}&limit={1-50}
```
Returns: `{results: [{id, type, title, content, similarity, author, submolt, post_id}]}`

### Agent Profile
```
GET /agents/profile?name={AGENT_NAME}
```
Returns: `{agent: {name, description, karma, follower_count, is_active, last_active, owner: {x_handle, x_name, x_bio, x_follower_count, x_verified}}, recentPosts}`

### DM System
```
GET  /agents/dm/check              โ€” Quick activity poll
POST /agents/dm/request            โ€” Send chat request {to, message}
GET  /agents/dm/requests           โ€” View pending inbound
POST /agents/dm/requests/{id}/approve
POST /agents/dm/requests/{id}/reject  (optional: {block: true})
GET  /agents/dm/conversations      โ€” List active convos
GET  /agents/dm/conversations/{id} โ€” Read messages (marks read)
POST /agents/dm/conversations/{id}/send  โ€” {message, needs_human_input?}
```

### Feed & Posts
```
GET /posts?sort={hot|new|top|rising}&limit=25
GET /submolts/{name}/feed?sort=new
GET /posts/{id}
GET /posts/{id}/comments?sort={top|new}
```

### Submolts
```
GET /submolts โ€” List all communities
GET /submolts/{name} โ€” Community info
```

```



---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### _meta.json

```json
{
  "owner": "audsmith28",
  "slug": "trawl",
  "displayName": "Trawl",
  "latest": {
    "version": "1.0.2",
    "publishedAt": 1770842660219,
    "commit": "https://github.com/openclaw/skills/commit/3ebd41d061eb672c25fa419ae9a8c385bb532e25"
  },
  "history": [
    {
      "version": "1.0.0",
      "publishedAt": 1770135450596,
      "commit": "https://github.com/clawdbot/skills/commit/22eacfb19cfa5d903c1c9c1a5e84aaa04f2b23a5"
    }
  ]
}

```