Files
last30days-skill/docs/plans/2026-02-25-feat-x-handle-resolution-plan.md
Matt Van Horn 4f584a4e96 feat(x): resolve X handles for person/brand topics via agent WebSearch
When a topic is a person/brand (e.g. "Dor Brothers", "Jason Calacanis"),
the agent now resolves their X handle via WebSearch before running the
script, then passes --x-handle to search their posts unfiltered (no
topic keywords required). This finds posts the entity made without
mentioning their own name.

- SKILL.md + OpenClaw variant: Step 0.5 handle resolution instructions
- last30days.py: --x-handle CLI arg, passed through to _run_supplemental()
- bird_x.search_handles(): topic is now Optional[str] for unfiltered mode
- schema.py: resolved_x_handle field on Report
- render.py: show resolved handle in stats output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:00:49 -08:00

13 KiB

title, type, status, date
title type status date
feat: Resolve X handles for topic entities feat completed 2026-02-25

feat: Resolve X handles for topic entities

Overview

When a user searches for a person, brand, or creator (e.g., "Dor Brothers", "Jason Calacanis"), the skill should automatically resolve their X handle and search their posts directly. Currently, Phase 1 only finds posts that mention the topic keywords, and Phase 2 only drills into handles that appeared in Phase 1 results. This misses the entity's own posts entirely when they don't literally include the topic string.

Problem Statement

The Dor Brothers example: Searching "Dor Brothers" found 30 X posts with 149 likes - all posts about them. But the Dor Brothers' own account posts constantly about their AI films, tools, and collabs. Those posts wouldn't appear in Phase 1 because the Dor Brothers don't write "Dor Brothers" in every tweet. Phase 2 can't help because entity_extract only finds handles already @mentioned in Phase 1 results.

The Jason Calacanis example: His handle is @jason - completely unguessable from his name. No amount of keyword searching or @mention extraction would discover it. You need an actual lookup.

What's missing: A handle resolution step that maps "topic name" -> @handle, then searches that handle's recent posts directly (without requiring topic keywords in the tweet text).

User Base Constraints

  • 80% Claude Code - agent is Claude, has WebSearch tool
  • 20% OpenClaw - agent has web search capability
  • Most users have: OPENAI_API_KEY
  • Some users have: XAI_API_KEY, Bird (browser cookies)
  • Few users have: BRAVE_API_KEY, PARALLEL_API_KEY, OPENROUTER_API_KEY
  • Everyone has: Claude (it's the runtime)

Critical constraint: The Python script's web search backends (brave_search.py, parallel_search.py, openrouter_search.py) ALL filter out x.com URLs via EXCLUDED_DOMAINS. Building handle resolution inside the Python script would require bypassing this filter, and most users don't have the API keys for those backends anyway.

Proposed Solution

Move handle resolution to the SKILL.md agent layer. The agent (Claude or OpenClaw) already has WebSearch as an allowed tool. It does a single WebSearch before running the Python script, extracts the handle, and passes it as a CLI argument.

Architecture

SKILL.md Agent Flow (revised):

1. Parse intent (existing)
2. NEW: If topic looks like a person/brand, WebSearch("{topic} X twitter handle")
3. NEW: Extract @handle from results (agent intelligence, not regex)
4. Run script: python3 last30days.py "Dor Brothers" --x-handle=TheDorBrothers --emit=compact
5. Script uses --x-handle in Phase 2: from:TheDorBrothers (no topic filter)
6. WebSearch supplementary (existing Step 2)
7. Synthesize (existing)

Why agent-level, not Python-level

Approach Works for Problem
Brave API in Python Users with BRAVE_API_KEY (~5%) Almost nobody has the key
OpenAI Responses API in Python Users with OPENAI_API_KEY (~90%) Costs money, adds complexity, x.com URLs may be filtered
Agent WebSearch (SKILL.md) 100% of users Adds ~5-8 sec before script
xAI API in Python Users with XAI_API_KEY (~30%) Not universal

The agent approach wins because:

  1. 100% availability - Claude/OpenClaw always has WebSearch
  2. Zero API key requirements - WebSearch is built into the agent runtime
  3. Smarter parsing - Claude is better at "find the X handle for Dor Brothers" than any regex
  4. Simpler implementation - SKILL.md instruction + --x-handle CLI arg, no new Python module
  5. No EXCLUDED_DOMAINS problem - agent WebSearch is independent of the Python web search backends

The ~5-8 second latency is negligible against the 2-3 minute script runtime.

SKILL.md Changes (Claude Code variant)

Add between intent parsing and Step 1:

## Step 0.5: Resolve X Handle (if topic is a person/brand)

If TOPIC looks like a person, creator, brand, or specific account (1-3 words, proper noun),
do ONE WebSearch to find their X handle:

WebSearch("{TOPIC} X twitter handle")

From the results, extract the X/Twitter handle. Look for:
- Profile URLs: x.com/{handle} or twitter.com/{handle}
- Mentions like "@handle" in bios, articles, or social profiles
- "Follow @handle on X" patterns

If you find a clear, unambiguous handle, pass it to the script:
  --x-handle={handle}

If ambiguous or not found, omit the flag. The script works fine without it.

Skip this step if:
- TOPIC is clearly not an entity (e.g., "best rap songs 2026", "how to use Docker")
- TOPIC already contains @ (e.g., "@elonmusk")
- Using --quick depth

OpenClaw Variant Changes

Same instruction added to variants/open/references/research.md (or inline in the open SKILL.md routing). OpenClaw agents also have WebSearch available.

Python Script Changes

last30days.py - Add --x-handle argument:

parser.add_argument('--x-handle', type=str, default=None,
                    help='Resolved X handle for topic entity (without @)')

Pass resolved_handle to _run_supplemental().

_run_supplemental() - Accept and use resolved handle:

def _run_supplemental(
    topic, reddit_items, x_items, from_date, to_date,
    depth, x_source, progress=None, skip_reddit=False,
    resolved_handle=None,  # NEW
):
    # Extract entities from Phase 1 (existing)
    entities = entity_extract.extract_entities(...)

    # Add resolved handle if not already in entity list
    if resolved_handle and resolved_handle.lower() not in {h.lower() for h in entities["x_handles"]}:
        # Search resolved handle separately - unfiltered (no topic keywords)
        # This is the key difference from entity-extracted handles
        resolved_future = executor.submit(
            bird_x.search_handles,
            [resolved_handle],
            None,  # topic=None means unfiltered search
            from_date,
            count_per=10,
        )

bird_x.search_handles() - Optional topic parameter:

def search_handles(handles, topic, from_date, count_per=5):
    # topic is now Optional[str]
    for handle in handles:
        handle = handle.lstrip("@")
        if topic:
            core_topic = _extract_core_subject(topic)
            query = f"from:{handle} {core_topic} since:{from_date}"
        else:
            # Unfiltered: get all recent posts from this handle
            query = f"from:{handle} since:{from_date}"

Why no topic filter for resolved handles

This is the key insight. When you resolve that @DorBrothers IS the Dor Brothers, you want ALL their recent posts - not just ones that literally contain "Dor Brothers." Their post about the Logan Paul collab says "our new AI film with @LoganPaul" - no mention of "Dor Brothers" anywhere. With topic filtering, you'd miss it. Without it, you get their full recent activity, which is exactly what the user wants.

Technical Considerations

Handle resolution requires Bird or xAI for Phase 2

The --x-handle is only useful if the script can search from:{handle}. Currently:

  • Bird: supports from:handle via Twitter GraphQL (free)
  • xAI: does NOT support from:handle (Grok semantic search only)

If the user only has xAI (no Bird), the resolved handle can't be searched in Phase 2. Options:

  1. Skip Phase 2 handle search when x_source == "xai" (current behavior for entity handles too)
  2. Future: Add handle search to xai_x.py using allowed_x_handles filter

For v1, accept this limitation. Bird is free and most X-enabled users have it.

Relevance scoring for unfiltered handle posts

Bird-parsed items default to relevance: 0.7. Unfiltered resolved-handle posts have no topic-keyword signal. Set relevance: 0.5 for these so engagement and recency drive ranking, preventing off-topic viral posts from the entity from outranking genuinely relevant Phase 1 results.

Stats block display

When --x-handle is used and produces results, show it in the stats:

├─ 🔵 X: 38 posts │ 782+ likes │ 36+ reposts │ via @TheDorBrothers + keyword search

Add resolved_x_handle field to Report schema for this.

Skip conditions for the agent

The SKILL.md instruction tells the agent to skip handle resolution when:

  • Topic is clearly not an entity (multi-word generic phrases)
  • Topic already contains @ (user provided the handle)
  • Using --quick depth
  • Agent judges it would be wasted effort

The agent's judgment here is a feature, not a bug. Claude is good at deciding "Dor Brothers = probably has an X account" vs "best rap songs 2026 = definitely not an entity."

Acceptance Criteria

  • SKILL.md updated with Step 0.5 handle resolution instructions
  • OpenClaw variant updated with same instructions
  • last30days.py accepts --x-handle argument
  • _run_supplemental() accepts resolved_handle parameter
  • bird_x.search_handles() accepts topic=None for unfiltered search
  • Resolved handle searched with from:{handle} (no topic filter) when Bird is available
  • Report schema includes resolved_x_handle field
  • Stats block shows resolved handle when used
  • Graceful when --x-handle is provided but Bird is not available (skips silently)
  • "Dor Brothers" search resolves handle and finds their direct posts (requires live test)
  • "Jason Calacanis" search resolves @jason (requires live test)
  • "best rap songs 2026" does NOT trigger handle resolution (agent judgment, not code)

Test Plan

Manual test cases

# Query Expected What it tests
1 "Dor Brothers" Agent resolves handle, passes --x-handle, script finds their posts Full happy path
2 "Jason Calacanis" Agent resolves @jason (non-obvious handle) Handle != name
3 "best rap songs 2026" Agent skips handle resolution entirely Non-entity detection
4 "OpenAI" Agent may or may not resolve (judgment call) Generic entity edge case
5 "Linus Ekenstam" Agent resolves @LinusEkenstam Person with matching handle
6 "Dor Brothers" with --quick No handle resolution Skip condition
7 "Dor Brothers" with xAI only (no Bird) Handle resolved but Phase 2 skips it Graceful degradation

Automated tests (tests/)

# test_bird_x.py - new tests
def test_search_handles_unfiltered_mode():
    """bird_x.search_handles(topic=None) omits topic keywords from query."""

def test_search_handles_with_topic():
    """bird_x.search_handles(topic="AI films") includes topic in query (existing behavior)."""

# test_last30days.py - integration
def test_x_handle_arg_parsed():
    """--x-handle=TheDorBrothers is parsed and passed to _run_supplemental."""

def test_resolved_handle_dedup_with_entity_extract():
    """Resolved handle already in entity list is not double-searched."""

def test_resolved_handle_skipped_when_xai_only():
    """When x_source='xai', resolved handle is not searched (no from: support)."""

def test_resolved_handle_relevance_set_lower():
    """Items from resolved handle search get relevance 0.5, not 0.7."""

E2E validation

# Run with explicit handle to test Python-side changes
python3 scripts/last30days.py "Dor Brothers" --x-handle=TheDorBrothers --emit=compact 2>&1 | grep -E "\[Phase|handle"

# Expected:
# [Phase 2] Drilling into @TheDorBrothers (resolved) + @handle1, @handle2 (extracted)
# [Phase 2] +0 Reddit, +8 X (5 from resolved handle)
# Full agent test (runs SKILL.md flow including handle resolution WebSearch)
claude --print "/last30days Dor Brothers" 2>&1 | grep -i "handle\|x-handle\|resolved"

Files Modified

File Change
SKILL.md Add Step 0.5: handle resolution via WebSearch
variants/open/references/research.md Same handle resolution instructions for OpenClaw
scripts/last30days.py Add --x-handle CLI arg, pass to _run_supplemental()
scripts/lib/bird_x.py search_handles() gets topic: Optional[str] param
scripts/lib/schema.py Add resolved_x_handle: Optional[str] to Report
scripts/lib/render.py Show resolved handle in stats block
tests/test_bird_x.py Tests for unfiltered search_handles mode
tests/test_last30days.py Tests for --x-handle arg handling

NOT modified: No new handle_resolve.py module. Resolution is agent intelligence, not Python code.

Dependencies & Risks

  • Bird availability: Resolved handle can only be searched when Bird is the X source. xAI doesn't support from:handle queries. ~70% of X-enabled users have Bird (free, browser cookies).
  • Agent judgment: The agent decides whether a topic is an entity worth resolving. Claude is good at this, but it's non-deterministic. Some edge cases will be missed. This is acceptable - the feature is additive (no regression when it doesn't fire).
  • WebSearch latency: Adds ~5-8 seconds before the script starts. Negligible against 2-3 minute script runtime.
  • Handle accuracy: The agent could resolve to the wrong handle. Mitigated by the agent's ability to evaluate results (unlike a regex, Claude can tell if a result actually belongs to the queried entity).

Sources

  • Existing entity extraction: scripts/lib/entity_extract.py
  • Phase 2 supplemental search: scripts/last30days.py:387-513
  • Bird search handles: scripts/lib/bird_x.py:273-346
  • SKILL.md agent flow: SKILL.md:82-120
  • OpenClaw variant: variants/open/SKILL.md
  • Smart supplemental search plan: docs/plans/2026-02-07-feat-smart-supplemental-search-plan.md