GroktoCrawl x Hermes Agent

Complete Integration Plan - Self-Hosted Web Scraping & AI Research API as Native Hermes Tools
Repository
groktopus/groktocrawl
Version
v0.10.1
API Surface
Firecrawl v2 Compatible
Runtime
Python 3.12+ / Docker
Hermes Install
AppData\Local\hermes
Docker Status
v29.5.3 + Compose v5.1.4

Table of Contents

  1. Project Overview & Architecture
  2. GroktoCrawl Component Inventory
  3. Full API Surface (27 Endpoints)
  4. Installation: Docker Stack Deployment
  5. Configuration & Environment
  6. Hermes Plugin Architecture
  7. Native Tool Definitions (12 Tools)
  8. Wiring: How Hermes Calls GroktoCrawl
  9. Verification & Testing
  10. Operations: Startup, Logs, Updates

1. Project Overview & Architecture

GroktoCrawl is a self-hosted, MIT-licensed alternative to Firecrawl. It implements the entire Firecrawl v2 API surface as a set of Python FastAPI microservices running in Docker. It provides web scraping, crawling, search, browser automation, document parsing, structured data extraction, and an autonomous AI research agent - all behind a single REST API on port 8080.

Architecture Diagram

+-------------------------------------------------------------------+ | GROKTOCRAWL DOCKER STACK | +-------------------------------------------------------------------+ agent-svc (:8080) <-- Hermes plugin talks to THIS | FastAPI - Pydantic models - Job store (Valkey) | Routes: /v2/scrape /v2/crawl /v2/search /v2/agent /v2/answer | /v2/extract /v2/map /v2/browser /v2/monitor /v2/parse | +--> scraper-svc (:8001) URL -> markdown (3-tier strategy) | +-- 22 site adapters (github, youtube, gutenberg, shodan, nvd...) | +--> slopsearx (:8081) SearXNG meta-search (Brave API) | +--> semantic-svc (:8003) Vector search (Qdrant + bge-m3) | +-- qdrant (vector DB, 4GB) | +--> browser-svc Playwright headless Chrome | +--> parse-svc PDF/EPUB/DOCX -> markdown | +--> valkey Job queue + cache + rate limiting | +--> portal-svc (:8082) Web UI dashboard +-------------------------------------------------------------------+ | HERMES AGENT (Electron) | +-------------------------------------------------------------------+ Hermes Plugin (~/.hermes/plugins/web/groktocrawl/) | __init__.py -> register(ctx) -> ctx.register_tool(...) | | Registers 12 native tools: | gtc_scrape - gtc_crawl - gtc_search - gtc_agent - gtc_answer | gtc_extract - gtc_map - gtc_browser - gtc_monitor - gtc_parse | gtc_llmstxt - gtc_batch_scrape | +--> HTTP -> agent-svc (:8080) via GROKTOCRAWL_API_URL env
Key insight: GroktoCrawl is Firecrawl API-compatible. The Hermes Firecrawl plugin (plugins/web/firecrawl/) already exists and talks to api.firecrawl.dev. Our plugin will be structurally identical but point at http://localhost:8080 instead - giving you a free, self-hosted, unlimited replacement for the paid Firecrawl cloud.

2. GroktoCrawl Component Inventory

The repo at C:\Users\Gaem4090\groktocrawl contains these services, all orchestrated by a single docker-compose.yml:

ServicePortPurposeHermes Tool(s)
agent-svc:8080Main API gateway. All 27 endpoints. Job orchestration, agent research loop, crawl engine.ALL tools
scraper-svc:8001URL to markdown. Three-tier: llms.txt, Accept header, Playwright+readability. 22 site adapters.gtc_scrape, gtc_crawl
slopsearx:8081SearXNG meta-search. Proxies to Brave Search API.gtc_search, gtc_agent, gtc_answer
semantic-svc:8003Vector indexing & semantic search. Qdrant + BAAI/bge-m3 (1024-dim).gtc_search (rich), gtc_agent
qdrantinternalVector database. 4GB memory limit.via semantic-svc
browser-svcinternalPlaywright headless Chrome. JS-rendered pages + /v2/browser sessions.gtc_browser
parse-svcinternalDocument parsing: PDF, EPUB, DOCX, PPTX, XLSX to markdown.gtc_parse
valkeyinternalRedis-compatible. Job store, scrape cache, rate limiting.ALL (infrastructure)
portal-svc:8082Web dashboard. Monitor jobs, view results.Optional (human)
ofeliainternalDocker job scheduler. Runs monitor cron jobs.gtc_monitor

Pre-built Docker Images (GHCR)

3. Full API Surface (27 Endpoints)

Extracted from openapi.json in the repo. All endpoints are on http://localhost:8080 (the agent-svc container).

MethodPathPurposeHermes Tool
GET/healthHealth checkinternal
POST/v2/scrapeScrape a single URL to markdowngtc_scrape
POST/v2/crawlCrawl a website (BFS, configurable depth/limit)gtc_crawl
GET/v2/crawl/activeList active crawl jobsgtc_crawl
GET/v2/crawl/{job_id}Get crawl job statusgtc_crawl
DELETE/v2/crawl/{job_id}Cancel a crawlgtc_crawl
GET/v2/crawl/{job_id}/errorsGet crawl errorsgtc_crawl
GET/v2/crawl/{job_id}/streamSSE stream of crawl eventsinternal
POST/v2/searchWeb search (fast or rich mode)gtc_search
POST/v2/agentAutonomous research agent (SSE streaming)gtc_agent
GET/v2/agent/{job_id}Get agent job statusgtc_agent
DELETE/v2/agent/{job_id}Cancel agent jobgtc_agent
POST/v2/answerGrounded Q&A with citations (search+scrape+LLM)gtc_answer
POST/v2/extractStructured data extraction from URLsgtc_extract
GET/v2/extract/{job_id}Get extract job statusgtc_extract
POST/v2/mapDiscover all URLs on a sitegtc_map
POST/v2/browserCreate a browser sessiongtc_browser
GET/v2/browserList browser sessionsgtc_browser
DELETE/v2/browser/{session_id}Destroy browser sessiongtc_browser
POST/v2/browser/{session_id}/executeExecute browser actions (click, type, scroll)gtc_browser
POST/v2/monitorCreate a change monitorgtc_monitor
GET/v2/monitorList monitorsgtc_monitor
GET/v2/monitor/{id}Get monitor statusgtc_monitor
PATCH/v2/monitor/{id}Update monitorgtc_monitor
DELETE/v2/monitor/{id}Delete monitorgtc_monitor
POST/v2/parseParse a document file to markdowngtc_parse
POST/v2/batch/scrapeBatch scrape multiple URLsgtc_batch_scrape
POST/v2/generate-llmstxtGenerate llms.txt for a websitegtc_llmstxt
POST/v2/crawl/params-previewPreview crawl parameters (NL to params)internal

4. Installation: Docker Stack Deployment

Already done: The repo is cloned at C:\Users\Gaem4090\groktocrawl. Docker v29.5.3 + Compose v5.1.4 are installed and running. WSL2 (Ubuntu) is available.

Step 1: Create the .env file

1Configure environment

Copy the sample env and configure the LLM provider. GroktoCrawl needs an LLM for the /v2/agent and /v2/answer endpoints. You can use your Featherless API key here.

# From the groktocrawl directory
cd C:\Users\Gaem4090\groktocrawl
cp .env.sample .env

# Edit .env - set these required values:
# LLM provider (use Featherless - you already have this)
LLM_API_KEY=your-featherless-key
LLM_BASE_URL=https://api.featherless.ai/v1
LLM_MODEL=meta-llama/Llama-3.3-70B-Instruct

# Search backend (Brave API key - get free at https://brave.com/search/api/)
BRAVE_API_KEY=your-brave-search-api-key

# Optional: protect your API with a key
# API_KEY=generate-a-random-secret-here
Brave Search API: The slopsearx service needs a Brave Search API key for web search. Get a free key at brave.com/search/api (2,000 queries/month free). Without it, /v2/search, /v2/agent, and /v2/answer won't work, but /v2/scrape, /v2/crawl, and /v2/map will still function.

Step 2: Start the Docker stack

2Launch all services
cd C:\Users\Gaem4090\groktocrawl

# Pull pre-built images and start all services
docker compose up -d

# Watch the logs to confirm everything starts
docker compose logs -f agent-svc

# Verify the API is healthy (in another terminal)
curl http://localhost:8080/health

Expected response: {"status":"healthy"}

This starts 10 containers: agent-svc, scraper-svc, slopsearx, semantic-svc, qdrant, browser-svc, parse-svc, valkey, portal-svc, ofelia.

Step 3: Verify each service

3Smoke test all endpoints
# Scrape a page
curl -X POST http://localhost:8080/v2/scrape \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}'

# Search the web
curl -X POST http://localhost:8080/v2/search \
  -H "Content-Type: application/json" \
  -d '{"query":"rust programming","limit":3}'

# Map a site
curl -X POST http://localhost:8080/v2/map \
  -H "Content-Type: application/json" \
  -d '{"url":"https://docs.python.org","limit":10}'

# Portal dashboard
open http://localhost:8082

5. Configuration & Environment

GroktoCrawl .env (key variables)

VariableRequiredDefaultPurpose
LLM_API_KEYFor agent/answer-LLM provider API key
LLM_BASE_URLFor agent/answerhttp://llm-svc:8011/v1OpenAI-compatible LLM endpoint
LLM_MODELFor agent/answerfixture-modelModel name
BRAVE_API_KEYFor search-Brave Search API key
API_KEYOptional-Protect your API with a bearer token
AGENT_PORTOptional8080agent-svc port
GITHUB_TOKENOptional-GitHub adapter (higher rate limit)
SCRAPER_PROXY_URLOptional-Outbound proxy for scraping

Hermes-side environment

The Hermes plugin reads one env var to know where GroktoCrawl is:

# Set in Hermes config or ~/.hermes/.env
GROKTOCRAWL_API_URL=http://localhost:8080

# If you set API_KEY in GroktoCrawl's .env, also set:
GROKTOCRAWL_API_KEY=your-api-key-here
CLI already supports this. The GroktoCrawl CLI (groktocrawl in the repo root) already reads GROKTOCRAWL_API_URL from env or ~/.hermes/.env. The CLI is a 1000+ line Python script with subcommands for every endpoint. Our Hermes plugin will reuse the same env var convention.

6. Hermes Plugin Architecture

Hermes has a first-class plugin system. Plugins live in ~/.hermes/plugins/ (user plugins) or plugins/ (bundled). Each plugin is a Python package with an __init__.py that exports a register(ctx) function. The PluginContext object provides ctx.register_tool() which registers tools in the global tool registry.

How plugin loading works (verified from source)

  1. model_tools.py calls discover_plugins() at startup
  2. PluginManager.discover_and_load() scans ~/.hermes/plugins/ for directories with __init__.py
  3. Each plugin's register(ctx) is called with a PluginContext
  4. ctx.register_tool(name, toolset, schema, handler, ...) adds tools to the global registry
  5. Tools appear in the model's tool schema automatically

PluginContext.register_tool signature (from hermes_cli/plugins.py:319)

def register_tool(
    self,
    name: str,           # Tool name (e.g. "gtc_scrape")
    toolset: str,        # Toolset group (e.g. "groktocrawl")
    schema: dict,        # JSON schema for the tool
    handler: Callable,   # Async or sync function called when tool is invoked
    check_fn: Callable | None = None,    # Availability check (return False to hide)
    requires_env: list | None = None,    # Env vars that must be set
    is_async: bool = False,              # Whether handler is async
    description: str = "",
    emoji: str = "",
    override: bool = False,              # True to replace existing tool
) -> None

Plugin directory structure

~/.hermes/plugins/
  web/
    groktocrawl/           # <-- Our new plugin
      __init__.py          # register(ctx) entry point
      provider.py          # HTTP client + tool handlers
      manifest.yaml        # Plugin metadata (optional)
Reference plugin: The existing Firecrawl plugin at plugins/web/firecrawl/ in the Hermes repo is the structural template. It has the same __init__.py + provider.py pattern and registers a WebSearchProvider. Our plugin will register individual tools instead, giving the agent direct access to all 27 GroktoCrawl endpoints.

7. Native Tool Definitions (12 Tools)

These 12 tools will be registered as native Hermes tools. The agent (me) will be able to call them directly, just like web_search or terminal. Each tool maps to one or more GroktoCrawl API endpoints.

Tool 1: gtc_scrape

POST /v2/scrape

Scrape a single URL to markdown. Uses the three-tier strategy (llms.txt, Accept header, Playwright). 22 site-specific adapters for GitHub, YouTube, Project Gutenberg, security databases, etc.

{
  "name": "gtc_scrape",
  "description": "Scrape a single URL and return clean markdown. Handles JS-rendered pages, PDFs, and site-specific adapters (GitHub, YouTube, etc.).",
  "parameters": {
    "type": "object",
    "properties": {
      "url": {"type": "string", "description": "The URL to scrape"},
      "formats": {"type": "array", "items": {"type": "string"}, "default": ["markdown"], "description": "Output formats: markdown, html, links, rawHtml"},
      "only_main_content": {"type": "boolean", "default": true, "description": "Strip nav/header/footer boilerplate"},
      "timeout": {"type": "integer", "default": 30000, "description": "Per-page timeout in ms"}
    },
    "required": ["url"]
  }
}

Tool 2: gtc_crawl

POST /v2/crawl + GET /v2/crawl/{id}

Crawl a website with BFS. Configurable depth, page limit, path filtering. Returns a job ID; poll for results.

{
  "name": "gtc_crawl",
  "description": "Crawl a website starting from a URL. Returns a job_id - poll with action='status'. Configurable depth, page limit, and path patterns.",
  "parameters": {
    "type": "object",
    "properties": {
      "url": {"type": "string", "description": "Starting URL"},
      "action": {"type": "string", "enum": ["start", "status", "cancel", "errors", "active"], "default": "start"},
      "job_id": {"type": "string", "description": "Job ID for status/cancel/errors actions"},
      "max_depth": {"type": "integer", "default": 2},
      "limit": {"type": "integer", "default": 20, "description": "Max pages to crawl"},
      "includes": {"type": "array", "items": {"type": "string"}, "description": "Glob patterns to include"},
      "excludes": {"type": "array", "items": {"type": "string"}, "description": "Glob patterns to exclude"}
    },
    "required": ["url", "action"]
  }
}

Tool 3: gtc_search

POST /v2/search

Web search via SearXNG/Brave. Two modes: fast (raw results, <1s) and rich (scrapes top results + LLM synthesis, 1-3s).

{
  "name": "gtc_search",
  "description": "Search the web. Fast mode returns raw results (<1s). Rich mode scrapes top results and synthesizes with LLM (1-3s).",
  "parameters": {
    "type": "object",
    "properties": {
      "query": {"type": "string"},
      "limit": {"type": "integer", "default": 5},
      "search_type": {"type": "string", "enum": ["fast", "rich"], "default": "fast"},
      "sources": {"type": "array", "items": {"type": "string"}, "description": "Search verticals"},
      "categories": {"type": "array", "items": {"type": "string"}, "description": "SearXNG categories"}
    },
    "required": ["query"]
  }
}

Tool 4: gtc_agent

POST /v2/agent

Autonomous research agent. Searches the web, scrapes relevant pages, and synthesizes a cited answer. Supports SSE streaming. This is GroktoCrawl's most powerful endpoint - it's a mini research agent inside your scraping API.

{
  "name": "gtc_agent",
  "description": "Start an autonomous research agent. It searches the web, scrapes sources, and synthesizes a cited answer. Use for complex research questions.",
  "parameters": {
    "type": "object",
    "properties": {
      "query": {"type": "string", "description": "Research question"},
      "action": {"type": "string", "enum": ["start", "status", "cancel"], "default": "start"},
      "job_id": {"type": "string", "description": "For status/cancel"},
      "max_searches": {"type": "integer", "default": 5, "description": "Max search rounds"},
      "model": {"type": "string", "description": "Override LLM model for this job"}
    },
    "required": ["query", "action"]
  }
}

Tool 5: gtc_answer

POST /v2/answer

Grounded Q&A with inline citations. Synchronous (1-3s). Search, scrape top results, LLM synthesis with [N] citation markers.

{
  "name": "gtc_answer",
  "description": "Answer a question with grounded citations. Searches, scrapes top results, and synthesizes with [N] citation markers. 1-3s latency.",
  "parameters": {
    "type": "object",
    "properties": {
      "query": {"type": "string"},
      "num_sources": {"type": "integer", "default": 5, "minimum": 1, "maximum": 20},
      "model": {"type": "string", "description": "Override LLM model"}
    },
    "required": ["query"]
  }
}

Tool 6: gtc_extract

POST /v2/extract

Structured data extraction. Provide a JSON schema, get structured data from URLs. Uses LLM to extract fields from scraped content.

{
  "name": "gtc_extract",
  "description": "Extract structured data from URLs using a JSON schema. LLM-powered extraction.",
  "parameters": {
    "type": "object",
    "properties": {
      "urls": {"type": "array", "items": {"type": "string"}},
      "action": {"type": "string", "enum": ["start", "status"], "default": "start"},
      "job_id": {"type": "string"},
      "output_schema": {"type": "object", "description": "JSON schema for extracted data"},
      "system_prompt": {"type": "string", "description": "Guide extraction behavior"}
    },
    "required": ["urls", "action"]
  }
}

Tool 7: gtc_map

POST /v2/map

Discover all URLs on a site. Uses sitemap.xml, robots.txt, and link extraction.

{
  "name": "gtc_map",
  "description": "Discover all URLs on a website. Uses sitemap.xml, robots.txt, and HTML link extraction.",
  "parameters": {
    "type": "object",
    "properties": {
      "url": {"type": "string"},
      "limit": {"type": "integer", "default": 50}
    },
    "required": ["url"]
  }
}

Tool 8: gtc_browser

POST /v2/browser + POST /v2/browser/{id}/execute

Remote browser sessions. Create a Playwright session, execute actions (click, type, scroll, screenshot, executeScript), destroy session.

{
  "name": "gtc_browser",
  "description": "Manage remote Playwright browser sessions. Create, execute actions (click/type/scroll/screenshot), list, and destroy.",
  "parameters": {
    "type": "object",
    "properties": {
      "action": {"type": "string", "enum": ["create", "list", "destroy", "execute"]},
      "session_id": {"type": "string", "description": "For destroy/execute"},
      "actions": {"type": "array", "items": {"type": "object"}, "description": "Browser actions for execute"},
      "ttl": {"type": "integer", "default": 300, "description": "Session TTL in seconds"}
    },
    "required": ["action"]
  }
}

Tool 9: gtc_monitor

POST /v2/monitor

Change monitoring. Set up monitors that periodically scrape a URL and alert on changes. Managed by the Ofelia scheduler.

{
  "name": "gtc_monitor",
  "description": "Manage change monitors. Create monitors that periodically check a URL and alert on content changes.",
  "parameters": {
    "type": "object",
    "properties": {
      "action": {"type": "string", "enum": ["create", "list", "get", "update", "delete"]},
      "monitor_id": {"type": "string"},
      "url": {"type": "string", "description": "URL to monitor (for create)"},
      "interval": {"type": "string", "default": "1h", "description": "Check interval"},
      "selector": {"type": "string", "description": "CSS selector to watch"}
    },
    "required": ["action"]
  }
}

Tool 10: gtc_parse

POST /v2/parse

Document parsing. Upload a file (PDF, EPUB, DOCX, PPTX, XLSX) and get markdown back.

{
  "name": "gtc_parse",
  "description": "Parse a document file (PDF, EPUB, DOCX, PPTX, XLSX) to markdown.",
  "parameters": {
    "type": "object",
    "properties": {
      "file_path": {"type": "string", "description": "Local file path to parse"}
    },
    "required": ["file_path"]
  }
}

Tool 11: gtc_llmstxt

POST /v2/generate-llmstxt

Generate an llms.txt file for a website. Crawls the site and produces a structured summary for LLM consumption.

{
  "name": "gtc_llmstxt",
  "description": "Generate an llms.txt file for a website. Crawls and produces a structured summary for LLMs.",
  "parameters": {
    "type": "object",
    "properties": {
      "url": {"type": "string"},
      "action": {"type": "string", "enum": ["start", "status"], "default": "start"},
      "job_id": {"type": "string"},
      "max_urls": {"type": "integer", "default": 10}
    },
    "required": ["url", "action"]
  }
}

Tool 12: gtc_batch_scrape

POST /v2/batch/scrape

Batch scrape multiple URLs. Submit a list of URLs, get all results back.

{
  "name": "gtc_batch_scrape",
  "description": "Scrape multiple URLs in batch. Returns a job_id for polling.",
  "parameters": {
    "type": "object",
    "properties": {
      "urls": {"type": "array", "items": {"type": "string"}},
      "action": {"type": "string", "enum": ["start", "status", "cancel", "errors"], "default": "start"},
      "job_id": {"type": "string"}
    },
    "required": ["urls", "action"]
  }
}

8. Wiring: How Hermes Calls GroktoCrawl

The plugin is a thin HTTP client. Each tool handler makes an HTTP request to the GroktoCrawl API and returns the response. Here is the complete plugin code:

~/.hermes/plugins/web/groktocrawl/__init__.py

"""GroktoCrawl web scraping plugin - self-hosted Firecrawl alternative."""

from __future__ import annotations
from plugins.web.groktocrawl.provider import GroktoCrawlProvider


def register(ctx) -> None:
    """Register all GroktoCrawl tools with the plugin context."""
    provider = GroktoCrawlProvider()
    provider.register_tools(ctx)

~/.hermes/plugins/web/groktocrawl/provider.py

"""GroktoCrawl provider - HTTP client + tool handlers."""

from __future__ import annotations
import os
import httpx
from typing import Any


def _get_api_url() -> str:
    return os.getenv("GROKTOCRAWL_API_URL", "http://localhost:8080").rstrip("/")


def _get_api_key() -> str | None:
    return os.getenv("GROKTOCRAWL_API_KEY")


def _headers() -> dict:
    h = {"Content-Type": "application/json"}
    key = _get_api_key()
    if key:
        h["Authorization"] = f"Bearer {key}"
    return h


def _is_available() -> bool:
    """Check if GroktoCrawl API is reachable."""
    try:
        r = httpx.get(f"{_get_api_url()}/health", timeout=3)
        return r.status_code == 200
    except Exception:
        return False


class GroktoCrawlProvider:
    """Registers 12 tools that proxy to the GroktoCrawl API."""

    def register_tools(self, ctx) -> None:
        ctx.register_tool(
            name="gtc_scrape",
            toolset="groktocrawl",
            schema={
                "type": "function",
                "function": {
                    "name": "gtc_scrape",
                    "description": "Scrape a URL to clean markdown.",
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "url": {"type": "string"},
                            "formats": {"type": "array", "items": {"type": "string"}, "default": ["markdown"]},
                            "only_main_content": {"type": "boolean", "default": True},
                            "timeout": {"type": "integer", "default": 30000},
                        },
                        "required": ["url"],
                    },
                },
            },
            handler=self._scrape,
            check_fn=lambda: _is_available(),
            requires_env=["GROKTOCRAWL_API_URL"],
            is_async=True,
            description="Scrape a URL to markdown",
            emoji="spider",
        )
        # ... 11 more tools registered the same way
        # (gtc_crawl, gtc_search, gtc_agent, gtc_answer, gtc_extract,
        #  gtc_map, gtc_browser, gtc_monitor, gtc_parse, gtc_llmstxt,
        #  gtc_batch_scrape)

    async def _scrape(self, url, formats=None, only_main_content=True, timeout=30000):
        body = {"url": url, "formats": formats or ["markdown"],
                "onlyMainContent": only_main_content, "timeout": timeout}
        async with httpx.AsyncClient(timeout=60) as client:
            r = await client.post(f"{_get_api_url()}/v2/scrape",
                                   headers=_headers(), json=body)
            r.raise_for_status()
            return r.json()

    async def _crawl(self, url, action="start", job_id=None,
                     max_depth=2, limit=20, includes=None, excludes=None):
        base = _get_api_url()
        if action == "start":
            body = {"url": url, "maxDepth": max_depth, "limit": limit}
            if includes: body["includes"] = includes
            if excludes: body["excludes"] = excludes
            async with httpx.AsyncClient(timeout=30) as c:
                r = await c.post(f"{base}/v2/crawl", headers=_headers(), json=body)
                r.raise_for_status()
                return r.json()
        elif action == "status" and job_id:
            async with httpx.AsyncClient(timeout=30) as c:
                r = await c.get(f"{base}/v2/crawl/{job_id}", headers=_headers())
                r.raise_for_status()
                return r.json()
        elif action == "cancel" and job_id:
            async with httpx.AsyncClient(timeout=30) as c:
                r = await c.delete(f"{base}/v2/crawl/{job_id}", headers=_headers())
                r.raise_for_status()
                return r.json()
        elif action == "active":
            async with httpx.AsyncClient(timeout=30) as c:
                r = await c.get(f"{base}/v2/crawl/active", headers=_headers())
                r.raise_for_status()
                return r.json()

    async def _search(self, query, limit=5, search_type="fast",
                      sources=None, categories=None):
        body = {"query": query, "limit": limit, "searchType": search_type}
        if sources: body["sources"] = sources
        if categories: body["categories"] = categories
        async with httpx.AsyncClient(timeout=30) as c:
            r = await c.post(f"{_get_api_url()}/v2/search", headers=_headers(), json=body)
            r.raise_for_status()
            return r.json()

    async def _agent(self, query, action="start", job_id=None,
                     max_searches=5, model=None):
        base = _get_api_url()
        if action == "start":
            body = {"query": query, "maxSearches": max_searches}
            if model: body["model"] = model
            async with httpx.AsyncClient(timeout=30) as c:
                r = await c.post(f"{base}/v2/agent", headers=_headers(), json=body)
                r.raise_for_status()
                return r.json()
        elif action == "status" and job_id:
            async with httpx.AsyncClient(timeout=30) as c:
                r = await c.get(f"{base}/v2/agent/{job_id}", headers=_headers())
                r.raise_for_status()
                return r.json()

    async def _answer(self, query, num_sources=5, model=None):
        body = {"query": query, "numSources": num_sources}
        if model: body["model"] = model
        async with httpx.AsyncClient(timeout=60) as c:
            r = await c.post(f"{_get_api_url()}/v2/answer", headers=_headers(), json=body)
            r.raise_for_status()
            return r.json()

    async def _map(self, url, limit=50):
        async with httpx.AsyncClient(timeout=30) as c:
            r = await c.post(f"{_get_api_url()}/v2/map", headers=_headers(),
                              json={"url": url, "limit": limit})
            r.raise_for_status()
            return r.json()

    async def _browser(self, action, session_id=None, actions=None, ttl=300):
        base = _get_api_url()
        if action == "create":
            async with httpx.AsyncClient(timeout=30) as c:
                r = await c.post(f"{base}/v2/browser", headers=_headers(), json={"ttl": ttl})
                r.raise_for_status()
                return r.json()
        elif action == "list":
            async with httpx.AsyncClient(timeout=30) as c:
                r = await c.get(f"{base}/v2/browser", headers=_headers())
                r.raise_for_status()
                return r.json()
        elif action == "destroy" and session_id:
            async with httpx.AsyncClient(timeout=30) as c:
                r = await c.delete(f"{base}/v2/browser/{session_id}", headers=_headers())
                r.raise_for_status()
                return r.json()
        elif action == "execute" and session_id:
            async with httpx.AsyncClient(timeout=60) as c:
                r = await c.post(f"{base}/v2/browser/{session_id}/execute",
                                  headers=_headers(), json={"actions": actions or []})
                r.raise_for_status()
                return r.json()

    async def _parse(self, file_path):
        import pathlib
        p = pathlib.Path(file_path)
        if not p.exists():
            return {"error": f"File not found: {file_path}"}
        async with httpx.AsyncClient(timeout=120) as c:
            with open(p, "rb") as f:
                r = await c.post(f"{_get_api_url()}/v2/parse",
                                  headers={k: v for k, v in _headers().items()
                                           if k != "Content-Type"},
                                  files={"file": (p.name, f)})
                r.raise_for_status()
                return r.json()
That is the entire plugin. One __init__.py, one provider.py. The handler functions are async, use httpx (already in Hermes deps), and return JSON dicts. Hermes handles the rest - schema generation, tool dispatch, result formatting.

9. Verification & Testing

Step 1: Verify GroktoCrawl is running

# Health check
curl http://localhost:8080/health
# Expected: {"status":"healthy"}

# Test scrape
curl -X POST http://localhost:8080/v2/scrape \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}'

Step 2: Verify Hermes plugin loads

# Check Hermes sees the plugin
hermes plugins list

# Should show: groktocrawl (web) - 12 tool(s)

# Check tools are registered
hermes tools list | grep gtc_

# Should show all 12 tools:
# gtc_scrape, gtc_crawl, gtc_search, gtc_agent, gtc_answer,
# gtc_extract, gtc_map, gtc_browser, gtc_monitor, gtc_parse,
# gtc_llmstxt, gtc_batch_scrape

Step 3: End-to-end test from Hermes

Ask the agent (me) to use the tools:

# In Hermes chat:
"Use gtc_scrape to scrape https://example.com and tell me what it says"
"Use gtc_search to search for 'rust async programming' with 3 results"
"Use gtc_map to find all URLs on https://docs.python.org"

Step 4: Check the portal dashboard

Open http://localhost:8082 in your browser to see job history, active crawls, and monitor status.

10. Operations: Startup, Logs, Updates

Daily operations

# Start the stack
cd C:\Users\Gaem4090\groktocrawl
docker compose up -d

# Stop the stack
docker compose down

# Check logs
docker compose logs -f agent-svc     # API logs
docker compose logs -f scraper-svc   # Scraper logs
docker compose logs -f slopsearx     # Search logs

# Check container status
docker compose ps

# Restart a single service
docker compose restart agent-svc

Updates

cd C:\Users\Gaem4090\groktocrawl
git pull
docker compose pull    # Pull new images
docker compose up -d   # Recreate changed containers

Resource usage

ContainerRAM (typical)CPU
agent-svc~100MBLow (idle), spikes on jobs
scraper-svc~150MBMedium during scraping
browser-svc~300MBHigh when rendering JS
semantic-svc~500MBHigh during indexing
qdrant~500MB-4GBLow (capped at 4GB)
valkey~50MBLow
slopsearx~100MBLow
Total~1.5-5.5GB
Memory note: The Qdrant vector DB is capped at 4GB. If you have 16GB+ RAM this stack runs comfortably. If RAM is tight, you can disable the semantic-svc and qdrant containers by commenting them out in docker-compose.yml - you will lose semantic search but scraping, crawling, and basic search will still work.

Troubleshooting

ProblemSolution
Tools do not appear in HermesCheck hermes plugins list for errors. Ensure ~/.hermes/plugins/web/groktocrawl/__init__.py exists and has register(ctx)
Tool calls return connection errorVerify curl http://localhost:8080/health works. Check GROKTOCRAWL_API_URL env var
Search returns no resultsCheck BRAVE_API_KEY in .env. Check docker compose logs slopsearx
Agent/answer returns errorsCheck LLM_API_KEY and LLM_BASE_URL in .env. Check docker compose logs agent-svc
Scraping JS-heavy sites failsCheck docker compose logs browser-svc. Browser-svc needs Playwright/Chromium
Qdrant OOMIncrease mem_limit in docker-compose.yml or reduce VECTOR_INDEX_MAX_DOCS