feat: Add Codex CLI compatibility

- Add agents/openai.yaml for Codex skill discovery
- Make SKILL.md script path portable (repo, Claude, Codex, agents dirs)
- Platform-neutral output text ("assistant" instead of "Claude")
- Sandbox-friendly cache/output dirs with env var overrides and tempdir fallback
- Add Codex installation docs to README

Inspired by PR #24 (el-analista) and PR #5 (jblwilliams).
Zero impact on existing Claude Code behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-02-14 23:18:53 -08:00
parent 9397fcc937
commit a09413608d
10 changed files with 324 additions and 24 deletions
+10
View File
@@ -53,6 +53,16 @@ node ~/.claude/skills/last30days/scripts/lib/vendor/bird-search/bird-search.mjs
**Requirements:** Node.js 22+ (for the vendored Twitter GraphQL client). **Requirements:** Node.js 22+ (for the vendored Twitter GraphQL client).
### Codex CLI
This skill also works in OpenAI Codex CLI. Install to the Codex skills directory instead:
```bash
git clone https://github.com/mvanhorn/last30days-skill.git ~/.agents/skills/last30days
```
Same SKILL.md, same Python engine, same scripts. The `agents/openai.yaml` provides Codex-specific discovery metadata. Invoke with `$last30days` or through the `/skills` menu.
## Usage ## Usage
``` ```
+16 -1
View File
@@ -61,7 +61,22 @@ This text MUST appear before you call any tools. It confirms to the user that yo
**Step 1: Run the research script** **Step 1: Run the research script**
```bash ```bash
python3 "${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/skills/last30days}/scripts/last30days.py" "$ARGUMENTS" --emit=compact 2>&1 # Find skill root — works in repo checkout, Claude Code, or Codex install
for dir in \
"." \
"${CLAUDE_PLUGIN_ROOT:-}" \
"$HOME/.claude/skills/last30days" \
"$HOME/.agents/skills/last30days" \
"$HOME/.codex/skills/last30days"; do
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
done
if [ -z "${SKILL_ROOT:-}" ]; then
echo "ERROR: Could not find scripts/last30days.py" >&2
exit 1
fi
python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact 2>&1
``` ```
The script will automatically: The script will automatically:
+8
View File
@@ -0,0 +1,8 @@
interface:
display_name: "Last 30 Days"
short_description: "Research any topic across Reddit, X, YouTube, and the web from the last 30 days. Returns synthesized expert answers and copy-paste prompts."
default_prompt: "Research this topic from the last 30 days across Reddit, X, YouTube, and web. Synthesize what people are actually saying, upvoting, and sharing right now."
brand_color: "#FF6B35"
policy:
allow_implicit_invocation: true
@@ -0,0 +1,243 @@
---
title: "feat: Add Codex CLI compatibility"
type: feat
date: 2026-02-14
---
# feat: Add Codex CLI Compatibility
## Overview
Make /last30days work as a Codex CLI skill alongside Claude Code. Both platforms use `SKILL.md` with YAML frontmatter — the gap is small but the details matter. Inspired by PR #24 (el-analista) and PR #5 (jblwilliams) on the public repo, applied to the v2.1 codebase.
## Research Findings
### How Codex Skills Work (from [official docs](https://developers.openai.com/codex/skills))
**Format:** Identical to Claude Code — `SKILL.md` with YAML frontmatter + Markdown body.
**Required frontmatter:** Only `name` and `description`. The official skill-creator guidance says "Do not include any other fields in YAML frontmatter." This is stricter than Claude Code which allows `version`, `allowed-tools`, `argument-hint`, etc.
**Discovery:** Codex uses "progressive disclosure" — it reads ONLY the `description` field to decide whether to invoke a skill. The body loads only after triggering. This means the description must be comprehensive about when to use/not use the skill.
**Invocation:** Users invoke with `$skill-name` or `/skills` menu. Codex can also implicitly match based on the description (configurable via `agents/openai.yaml`).
**Installation paths** (scanned in order):
| Scope | Path |
|-------|------|
| Folder | `$CWD/.agents/skills/` |
| Repo | `$REPO_ROOT/.agents/skills/` |
| User | `$HOME/.agents/skills/` |
| Admin | `/etc/codex/skills/` |
| System | Bundled |
Note: Some docs also mention `~/.codex/skills/` as an alias for `$HOME/.agents/skills/`. Both should be checked.
**`agents/openai.yaml`** (optional sidecar):
```yaml
interface:
display_name: "User-facing name"
short_description: "Brief description"
default_prompt: "Surrounding prompt template"
brand_color: "#hex"
policy:
allow_implicit_invocation: true
dependencies:
tools:
- type: "mcp"
value: "toolName"
```
**Size guidance:** Keep SKILL.md under 500 lines. Use `references/` directory for detailed docs that load on demand.
**Scripts:** Put executable code in `scripts/`. These can run without being loaded into context — good for our Python research engine.
### What Real Codex Skills Look Like (from [openai/skills catalog](https://github.com/openai/skills))
**openai-docs skill** — Uses MCP tools (`mcp__openaiDeveloperDocs__search_openai_docs`). Has a workflow section, fallback instructions if MCP isn't set up, and quality rules. Clean and focused.
**pdf skill** — Runs scripts (`pdftoppm`, `reportlab`), has file conventions (`tmp/pdfs/`, `output/pdf/`), specifies dependencies. Good example of a skill that shells out to tools like we do.
**skill-creator** — The meta-skill. Emphasizes "the context window is a public good" and treating the LLM as "already very smart — only add information it genuinely lacks." Has 6 creation steps, validation scripts, and naming conventions.
### Key Insight: Frontmatter Compatibility Problem
Claude Code SKILL.md uses:
```yaml
name: last30days
version: "2.1"
description: Research a topic...
argument-hint: 'nano banana pro prompts...'
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
```
Codex wants only `name` and `description`. The question: does Codex error on unknown frontmatter fields, or ignore them?
**Safe answer:** Codex uses standard YAML parsing and likely ignores unknown keys. But the official guidance says "Do not include any other fields" — meaning it's untested territory and could break in future Codex updates.
**Our approach:** Keep one SKILL.md with Claude-specific fields. If Codex chokes, we add a thin wrapper. This is pragmatic — maintaining two SKILL.md files defeats the purpose of cross-platform compatibility.
### PR #24 Analysis (el-analista)
Good ideas to incorporate:
- Portable script path resolution (repo → Claude → Codex → agents)
- `agents/openai.yaml` for Codex discovery
- Platform-neutral output text ("assistant" instead of "Claude")
- Sandbox-friendly cache/output dir fallbacks with env var overrides
- Last-chance retry for Bird search (better query noise stripping)
Not applicable to v2.1:
- Based on v2.0 codebase — doesn't have YouTube, vendored Bird, or pipeline changes
- We'll cherry-pick the ideas, not the code
### PR #5 Analysis (jblwilliams)
Not needed:
- Codex JWT auth — our OpenAI API calls work natively in Codex already
- SSE response handling — we don't stream responses
- The 403 enrichment issues they hit are specific to Codex-hosted auth, not our use case
## Proposed Solution
Five changes, all additive — zero impact on existing Claude Code behavior:
### 1. Add `agents/openai.yaml` for Codex discovery
```yaml
interface:
display_name: "Last 30 Days"
short_description: "Research any topic across Reddit, X, YouTube, and the web from the last 30 days. Returns synthesized expert answers and copy-paste prompts."
default_prompt: "Research this topic from the last 30 days across Reddit, X, YouTube, and web. Synthesize what people are actually saying, upvoting, and sharing right now."
brand_color: "#FF6B35"
policy:
allow_implicit_invocation: true
```
### 2. Make SKILL.md script path portable
Replace the hardcoded Claude path with a lookup that checks multiple install locations:
```bash
# Find the skill root
for dir in \
"." \
"${CLAUDE_PLUGIN_ROOT:-}" \
"$HOME/.claude/skills/last30days" \
"$HOME/.agents/skills/last30days" \
"$HOME/.codex/skills/last30days"; do
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
done
if [ -z "${SKILL_ROOT:-}" ]; then
echo "ERROR: Could not find scripts/last30days.py" >&2
exit 1
fi
python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact 2>&1
```
### 3. Platform-neutral Python output text
Replace "Claude" with "assistant" in LLM-facing output strings only. Human-facing docs (README, etc.) stay as-is.
Files:
- `scripts/last30days.py` — web search marker text (~3 lines)
- `scripts/lib/render.py` — docstrings + web-only banner (~4 lines)
- `scripts/lib/http.py` — User-Agent string (~1 line)
### 4. Sandbox-friendly cache/output dirs
Codex runs sandboxed. Add env var overrides + tempdir fallback (from PR #24):
**`scripts/lib/cache.py`:**
- Check `LAST30DAYS_CACHE_DIR` env var
- Catch `PermissionError`, fall back to `tempfile.gettempdir()/last30days/cache`
**`scripts/lib/render.py`:**
- Check `LAST30DAYS_OUTPUT_DIR` env var
- Catch `PermissionError`, fall back to `tempfile.gettempdir()/last30days/out`
### 5. README + installation docs
Add a "Codex Compatibility" section to README:
```markdown
## Codex Compatibility
This skill works in both Claude Code and OpenAI Codex CLI.
**Claude Code:** `git clone` into `~/.claude/skills/last30days`
**Codex CLI:** `git clone` into `~/.agents/skills/last30days`
Both use the same SKILL.md, same Python engine, same scripts.
The `agents/openai.yaml` provides Codex-specific discovery metadata.
```
## What We're NOT Doing
- **Separate SKILL.md for Codex** — One file, both platforms. Claude-specific frontmatter fields (`allowed-tools`, `version`, `argument-hint`) are likely ignored by Codex's YAML parser. If this breaks, we'll address it then.
- **Codex JWT auth (PR #5)** — Our OpenAI Responses API calls work natively in Codex. No special handling needed.
- **SSE streaming (PR #5)** — Not our use case.
- **Codex-specific tool names in SKILL.md** — Both LLMs understand "do a web search" and "run this bash command." The instructions work cross-platform as-is.
- **Publishing to openai/skills catalog** — Out of scope for now. Users install via git clone.
## Acceptance Criteria
- [x] `agents/openai.yaml` exists with proper `interface` and `policy` sections
- [x] SKILL.md uses portable path resolution (repo checkout, `~/.claude/skills/`, `~/.agents/skills/`, `~/.codex/skills/`)
- [x] Python scripts use "assistant" instead of "Claude" in LLM-facing output (~8 string replacements)
- [x] Cache dir falls back gracefully in sandboxed environments (`LAST30DAYS_CACHE_DIR` env var + `PermissionError` catch)
- [x] Output dir falls back gracefully in sandboxed environments (`LAST30DAYS_OUTPUT_DIR` env var + `PermissionError` catch)
- [x] Existing Claude Code behavior is unchanged (zero regressions)
- [x] README documents Codex installation path (`~/.agents/skills/last30days`)
- [x] `python3 scripts/last30days.py "test topic" --mock --emit=compact` still works
## Files to Create/Modify
### New Files
- `agents/openai.yaml` — Codex discovery metadata (~10 lines)
### Modified Files
- `SKILL.md` — Portable script path resolution (~15 lines changed)
- `README.md` — Add "Codex Compatibility" section (~15 lines)
- `scripts/last30days.py` — "Claude" → "assistant" in output strings (~3 lines)
- `scripts/lib/render.py` — "Claude" → "assistant" + output dir fallback (~15 lines)
- `scripts/lib/cache.py` — Cache dir env override + fallback (~12 lines)
- `scripts/lib/http.py` — User-Agent string (~1 line)
### Total scope: ~70 lines changed across 7 files. Small, additive, low risk.
## Dependencies & Risks
| Risk | Likelihood | Mitigation |
|------|-----------|------------|
| Codex rejects unknown YAML frontmatter (`allowed-tools`, etc.) | Low-Medium | Standard YAML parsers ignore unknown keys. If it breaks, strip Claude-specific fields and use `agents/openai.yaml` for metadata. |
| Codex sandbox blocks Node.js (vendored Bird) | Medium | Bird failure already falls back to xAI API. If no xAI key, X search skipped gracefully. |
| yt-dlp not in Codex sandbox PATH | Medium | YouTube already degrades gracefully — "yt-dlp not installed, skipping YouTube." |
| Codex sandbox blocks `~/.cache/` writes | Medium | Env var override + tempdir fallback handles this. (Proven approach from PR #24) |
| Codex changes skill discovery paths | Low | We check 5 paths. Easy to add more. |
| Codex description matching triggers on wrong queries | Low | Write description with clear "use when" / "do not use when" boundaries per official guidance. |
## References
### Community PRs
- [PR #24](https://github.com/mvanhorn/last30days-skill/pull/24) (el-analista) — Codex compatibility, portable paths, platform-neutral text
- [PR #5](https://github.com/mvanhorn/last30days-skill/pull/5) (jblwilliams) — Codex auth support
### Official Codex Docs
- [Agent Skills](https://developers.openai.com/codex/skills) — SKILL.md format, discovery, installation paths
- [AGENTS.md Guide](https://developers.openai.com/codex/guides/agents-md/) — Custom instructions, hierarchical loading
- [Codex CLI Features](https://developers.openai.com/codex/cli/features/) — Overview of CLI capabilities
- [Configuration Reference](https://developers.openai.com/codex/config-reference/) — config.toml, skill enable/disable
### Examples
- [openai/skills catalog](https://github.com/openai/skills) — Official curated skills
- [skill-creator](https://github.com/openai/skills/blob/main/skills/.system/skill-creator/SKILL.md) — Meta-skill for creating skills, best practices
- [pdf skill](https://github.com/openai/skills/blob/main/skills/.curated/pdf/SKILL.md) — Example of skill that runs external scripts
- [openai-docs skill](https://github.com/openai/skills/blob/main/skills/.curated/openai-docs/SKILL.md) — Example of MCP-backed skill
### Community Analysis
- [Skills in OpenAI Codex](https://blog.fsck.com/2025/12/19/codex-skills/) — Jesse Vincent's deep dive on skill internals
- [Simon Willison on skills adoption](https://simonw.substack.com/p/openai-are-quietly-adopting-skills) — Cross-platform skill format analysis
- [SkillsMP marketplace](https://skillsmp.com/) — Community marketplace supporting both Claude Code and Codex skills
+4 -4
View File
@@ -376,8 +376,8 @@ def run_research(
Returns: Returns:
Tuple of (reddit_items, x_items, youtube_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error) Tuple of (reddit_items, x_items, youtube_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error)
Note: web_needed is True when WebSearch should be performed by Claude. Note: web_needed is True when web search should be performed by the assistant.
The script outputs a marker and Claude handles WebSearch in its session. The script outputs a marker and the assistant handles web search in its session.
""" """
reddit_items = [] reddit_items = []
x_items = [] x_items = []
@@ -392,7 +392,7 @@ def run_research(
# Check if WebSearch is needed (always needed in web-only mode) # Check if WebSearch is needed (always needed in web-only mode)
web_needed = sources in ("all", "web", "reddit-web", "x-web") web_needed = sources in ("all", "web", "reddit-web", "x-web")
# Web-only mode: no API calls needed, Claude handles everything # Web-only mode: no API calls needed, assistant handles everything
if sources == "web": if sources == "web":
if progress: if progress:
progress.start_web_only() progress.start_web_only()
@@ -803,7 +803,7 @@ def output_result(
print(f"Topic: {topic}") print(f"Topic: {topic}")
print(f"Date range: {from_date} to {to_date}") print(f"Date range: {from_date} to {to_date}")
print("") print("")
print("Claude: Use your WebSearch tool to find 8-15 relevant web pages.") print("Assistant: Use your web search tool to find 8-15 relevant web pages.")
print("EXCLUDE: reddit.com, x.com, twitter.com (already covered above)") print("EXCLUDE: reddit.com, x.com, twitter.com (already covered above)")
print(f"INCLUDE: blogs, docs, news, tutorials from the last {days} days") print(f"INCLUDE: blogs, docs, news, tutorials from the last {days} days")
print("") print("")
+16 -3
View File
@@ -3,6 +3,7 @@
import hashlib import hashlib
import json import json
import os import os
import tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -10,10 +11,22 @@ from typing import Any, Optional
CACHE_DIR = Path.home() / ".cache" / "last30days" CACHE_DIR = Path.home() / ".cache" / "last30days"
DEFAULT_TTL_HOURS = 24 DEFAULT_TTL_HOURS = 24
MODEL_CACHE_TTL_DAYS = 7 MODEL_CACHE_TTL_DAYS = 7
MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json"
def ensure_cache_dir(): def ensure_cache_dir():
"""Ensure cache directory exists.""" """Ensure cache directory exists. Supports env override and sandbox fallback."""
global CACHE_DIR, MODEL_CACHE_FILE
env_dir = os.environ.get("LAST30DAYS_CACHE_DIR")
if env_dir:
CACHE_DIR = Path(env_dir)
MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json"
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
except PermissionError:
CACHE_DIR = Path(tempfile.gettempdir()) / "last30days" / "cache"
MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json"
CACHE_DIR.mkdir(parents=True, exist_ok=True) CACHE_DIR.mkdir(parents=True, exist_ok=True)
@@ -112,8 +125,8 @@ def clear_cache():
pass pass
# Model selection cache (longer TTL) # Model selection cache (longer TTL) — MODEL_CACHE_FILE is set at module level
MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json" # and updated by ensure_cache_dir() if env override or fallback is needed.
def load_model_cache() -> dict: def load_model_cache() -> dict:
+1 -1
View File
@@ -20,7 +20,7 @@ def log(msg: str):
sys.stderr.flush() sys.stderr.flush()
MAX_RETRIES = 3 MAX_RETRIES = 3
RETRY_DELAY = 1.0 RETRY_DELAY = 1.0
USER_AGENT = "last30days-skill/2.0 (Claude Code Skill)" USER_AGENT = "last30days-skill/2.1 (Assistant Skill)"
class HTTPError(Exception): class HTTPError(Exception):
+18 -7
View File
@@ -1,6 +1,8 @@
"""Output rendering for last30days skill.""" """Output rendering for last30days skill."""
import json import json
import os
import tempfile
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
@@ -10,7 +12,16 @@ OUTPUT_DIR = Path.home() / ".local" / "share" / "last30days" / "out"
def ensure_output_dir(): def ensure_output_dir():
"""Ensure output directory exists.""" """Ensure output directory exists. Supports env override and sandbox fallback."""
global OUTPUT_DIR
env_dir = os.environ.get("LAST30DAYS_OUTPUT_DIR")
if env_dir:
OUTPUT_DIR = Path(env_dir)
try:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
except PermissionError:
OUTPUT_DIR = Path(tempfile.gettempdir()) / "last30days" / "out"
OUTPUT_DIR.mkdir(parents=True, exist_ok=True) OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
@@ -35,7 +46,7 @@ def _assess_data_freshness(report: schema.Report) -> dict:
def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "none") -> str: def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "none") -> str:
"""Render compact output for Claude to synthesize. """Render compact output for the assistant to synthesize.
Args: Args:
report: Report data report: Report data
@@ -61,7 +72,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
# Web-only mode banner (when no API keys) # Web-only mode banner (when no API keys)
if report.mode == "web-only": if report.mode == "web-only":
lines.append("**🌐 WEB SEARCH MODE** - Claude will search blogs, docs & news") lines.append("**🌐 WEB SEARCH MODE** - assistant will search blogs, docs & news")
lines.append("") lines.append("")
lines.append("---") lines.append("---")
lines.append("**⚡ Want better results?** Add API keys to unlock Reddit & X data:") lines.append("**⚡ Want better results?** Add API keys to unlock Reddit & X data:")
@@ -204,7 +215,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
lines.append(f" *{item.why_relevant}*") lines.append(f" *{item.why_relevant}*")
lines.append("") lines.append("")
# Web items (if any - populated by Claude) # Web items (if any - populated by the assistant)
if report.web_error: if report.web_error:
lines.append("### Web Results") lines.append("### Web Results")
lines.append("") lines.append("")
@@ -356,15 +367,15 @@ def render_full_report(report: schema.Report) -> str:
lines.append(f"> {item.snippet}") lines.append(f"> {item.snippet}")
lines.append("") lines.append("")
# Placeholders for Claude synthesis # Placeholders for assistant synthesis
lines.append("## Best Practices") lines.append("## Best Practices")
lines.append("") lines.append("")
lines.append("*To be synthesized by Claude*") lines.append("*To be synthesized by assistant*")
lines.append("") lines.append("")
lines.append("## Prompt Pack") lines.append("## Prompt Pack")
lines.append("") lines.append("")
lines.append("*To be synthesized by Claude*") lines.append("*To be synthesized by assistant*")
lines.append("") lines.append("")
return "\n".join(lines) return "\n".join(lines)
+2 -2
View File
@@ -344,7 +344,7 @@ class ProgressDisplay:
def end_web_only(self): def end_web_only(self):
"""End web-only spinner.""" """End web-only spinner."""
if self.spinner: if self.spinner:
self.spinner.stop(f"{Colors.GREEN}Web{Colors.RESET} Claude will search the web") self.spinner.stop(f"{Colors.GREEN}Web{Colors.RESET} assistant will search the web")
def show_web_only_complete(self): def show_web_only_complete(self):
"""Show completion for web-only mode.""" """Show completion for web-only mode."""
@@ -352,7 +352,7 @@ class ProgressDisplay:
if IS_TTY: if IS_TTY:
sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Ready for web search{Colors.RESET} ") sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Ready for web search{Colors.RESET} ")
sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n") sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n")
sys.stderr.write(f" {Colors.GREEN}Web:{Colors.RESET} Claude will search blogs, docs & news\n\n") sys.stderr.write(f" {Colors.GREEN}Web:{Colors.RESET} assistant will search blogs, docs & news\n\n")
else: else:
sys.stderr.write(f"✓ Ready for web search ({elapsed:.1f}s)\n") sys.stderr.write(f"✓ Ready for web search ({elapsed:.1f}s)\n")
sys.stderr.flush() sys.stderr.flush()
+4 -4
View File
@@ -1,12 +1,12 @@
"""WebSearch module for last30days skill. """WebSearch module for last30days skill.
NOTE: WebSearch uses Claude's built-in WebSearch tool, which runs INSIDE Claude Code. NOTE: WebSearch uses the assistant's built-in web search tool, which runs inside the host environment.
Unlike Reddit/X which use external APIs, WebSearch results are obtained by Claude Unlike Reddit/X which use external APIs, web search results are obtained by the assistant
directly and passed to this module for normalization and scoring. directly and passed to this module for normalization and scoring.
The typical flow is: The typical flow is:
1. Claude invokes WebSearch tool with the topic 1. The assistant invokes its web search tool with the topic
2. Claude passes results to parse_websearch_results() 2. The assistant passes results to parse_websearch_results()
3. Results are normalized into WebSearchItem objects 3. Results are normalized into WebSearchItem objects
""" """