serpapi-hermes-plugin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ """SerpApi web, Maps, News, and Shopping search for Hermes Agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .provider import SerpApiWebSearchProvider
6
+ from .schemas import MAPS_SEARCH_SCHEMA, NEWS_SEARCH_SCHEMA, SHOPPING_SEARCH_SCHEMA
7
+ from .tools import maps_search, news_search, shopping_search
8
+
9
+ __all__ = ["SerpApiWebSearchProvider", "register"]
10
+
11
+
12
+ def register(ctx) -> None:
13
+ """Register SerpApi's web provider and specialized search tools."""
14
+ provider = SerpApiWebSearchProvider()
15
+ ctx.register_web_search_provider(provider)
16
+
17
+ for schema, handler in (
18
+ (MAPS_SEARCH_SCHEMA, maps_search),
19
+ (NEWS_SEARCH_SCHEMA, news_search),
20
+ (SHOPPING_SEARCH_SCHEMA, shopping_search),
21
+ ):
22
+ ctx.register_tool(
23
+ name=schema["name"],
24
+ toolset="serpapi",
25
+ schema=schema,
26
+ handler=handler,
27
+ check_fn=provider.is_available,
28
+ requires_env=["SERPAPI_API_KEY"],
29
+ description=schema["description"],
30
+ )
@@ -0,0 +1,98 @@
1
+ """Shared SerpApi HTTP client for the provider and specialized tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ API_KEY_ENV = "SERPAPI_API_KEY"
14
+ ENDPOINT = "https://serpapi.com/search.json"
15
+ TIMEOUT_SECONDS = 15.0
16
+
17
+
18
+ class SerpApiError(RuntimeError):
19
+ """A safe, user-facing SerpApi request error."""
20
+
21
+
22
+ def get_api_key() -> str:
23
+ """Read the key through Hermes so values in ``~/.hermes/.env`` work."""
24
+ from agent.web_search_provider import get_provider_env
25
+
26
+ return get_provider_env(API_KEY_ENV)
27
+
28
+
29
+ def is_configured() -> bool:
30
+ """Return whether Hermes can resolve a SerpApi API key locally."""
31
+ return bool(get_api_key())
32
+
33
+
34
+ def _safe_api_error(value: Any, api_key: str) -> str:
35
+ """Return a bounded API error without ever echoing the credential."""
36
+ message = str(value or "Unknown SerpApi error").replace(api_key, "[redacted]")
37
+ return message[:500]
38
+
39
+
40
+ def call_serpapi(engine: str, params: Mapping[str, Any]) -> dict[str, Any]:
41
+ """Call one SerpApi engine and return its decoded JSON response."""
42
+ api_key = get_api_key()
43
+ if not api_key:
44
+ raise SerpApiError(f"{API_KEY_ENV} is not set. Run `hermes tools` to configure SerpApi.")
45
+
46
+ request_params = {
47
+ key: value
48
+ for key, value in params.items()
49
+ if key not in {"api_key", "engine"} and value is not None and value != ""
50
+ }
51
+ request_params.update(
52
+ {
53
+ "engine": engine,
54
+ "api_key": api_key,
55
+ "output": "json",
56
+ }
57
+ )
58
+
59
+ try:
60
+ response = httpx.get(
61
+ ENDPOINT,
62
+ params=request_params,
63
+ headers={
64
+ "Accept": "application/json",
65
+ "X-Client-Source": "hermes",
66
+ },
67
+ timeout=TIMEOUT_SECONDS,
68
+ )
69
+ response.raise_for_status()
70
+ except httpx.HTTPStatusError as exc:
71
+ status = exc.response.status_code
72
+ logger.warning("SerpApi returned HTTP %d", status)
73
+ if status == 401:
74
+ message = "SerpApi rejected the API key"
75
+ elif status == 429:
76
+ message = "SerpApi quota exhausted; try again later"
77
+ elif status >= 500:
78
+ message = f"SerpApi upstream error (HTTP {status}); try again shortly"
79
+ else:
80
+ message = f"SerpApi request failed (HTTP {status})"
81
+ raise SerpApiError(message) from None
82
+ except httpx.RequestError as exc:
83
+ logger.warning("SerpApi request failed (%s)", type(exc).__name__)
84
+ raise SerpApiError("Could not reach SerpApi; try again shortly") from None
85
+
86
+ try:
87
+ payload = response.json()
88
+ except ValueError:
89
+ logger.warning("SerpApi returned malformed JSON")
90
+ raise SerpApiError("SerpApi returned malformed JSON") from None
91
+
92
+ if not isinstance(payload, dict):
93
+ raise SerpApiError("SerpApi returned an unexpected response")
94
+
95
+ if payload.get("error"):
96
+ raise SerpApiError(_safe_api_error(payload["error"], api_key))
97
+
98
+ return payload
@@ -0,0 +1,16 @@
1
+ name: serpapi
2
+ version: 0.1.0
3
+ description: "SerpApi search for Hermes Agent: web, Maps, News, and Shopping."
4
+ author: SerpApi
5
+ kind: backend
6
+ provides_web_providers:
7
+ - serpapi
8
+ provides_tools:
9
+ - serpapi_maps_search
10
+ - serpapi_news_search
11
+ - serpapi_shopping_search
12
+ requires_env:
13
+ - name: SERPAPI_API_KEY
14
+ description: "API key for SerpApi web search"
15
+ url: "https://serpapi.com/manage-api-key"
16
+ secret: true
@@ -0,0 +1,94 @@
1
+ """Hermes web-search provider backed by SerpApi's Google Light API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from agent.web_search_provider import WebSearchProvider
9
+
10
+ from .client import API_KEY_ENV, SerpApiError, call_serpapi, is_configured
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _ENGINE = "google_light"
15
+ _MAX_RESULTS = 20
16
+
17
+
18
+ class SerpApiWebSearchProvider(WebSearchProvider):
19
+ """Search-only Hermes provider using SerpApi's low-latency Google Light engine."""
20
+
21
+ @property
22
+ def name(self) -> str:
23
+ return "serpapi"
24
+
25
+ @property
26
+ def display_name(self) -> str:
27
+ return "SerpApi"
28
+
29
+ def is_available(self) -> bool:
30
+ """Check local configuration without making a network request."""
31
+ return is_configured()
32
+
33
+ def supports_search(self) -> bool:
34
+ return True
35
+
36
+ def supports_extract(self) -> bool:
37
+ return False
38
+
39
+ def search(self, query: str, limit: int = 5) -> dict[str, Any]:
40
+ """Search Google Light and return Hermes's standard web result envelope."""
41
+ query = str(query or "").strip()
42
+ if not query:
43
+ return {"success": False, "error": "Search query must not be empty"}
44
+
45
+ try:
46
+ result_limit = max(1, min(int(limit), _MAX_RESULTS))
47
+ except (TypeError, ValueError):
48
+ result_limit = 5
49
+
50
+ try:
51
+ payload = call_serpapi(
52
+ _ENGINE,
53
+ {
54
+ "q": query,
55
+ "num": result_limit,
56
+ },
57
+ )
58
+ except SerpApiError as exc:
59
+ return {"success": False, "error": str(exc)}
60
+
61
+ organic_results = payload.get("organic_results", [])
62
+ if not isinstance(organic_results, list):
63
+ return {"success": False, "error": "SerpApi returned an unexpected response"}
64
+
65
+ web_results = []
66
+ for result in organic_results[:result_limit]:
67
+ if not isinstance(result, dict):
68
+ continue
69
+ web_results.append(
70
+ {
71
+ "title": str(result.get("title", "")),
72
+ "url": str(result.get("link", "")),
73
+ "description": str(result.get("snippet", "")),
74
+ "position": len(web_results) + 1,
75
+ }
76
+ )
77
+
78
+ logger.info("SerpApi search returned %d result(s)", len(web_results))
79
+ return {"success": True, "data": {"web": web_results}}
80
+
81
+ def get_setup_schema(self) -> dict[str, Any]:
82
+ """Describe SerpApi to Hermes's interactive provider picker."""
83
+ return {
84
+ "name": "SerpApi",
85
+ "badge": "Google Light",
86
+ "tag": "Fast Google Light web search via SerpApi.",
87
+ "env_vars": [
88
+ {
89
+ "key": API_KEY_ENV,
90
+ "prompt": "SerpApi API key",
91
+ "url": "https://serpapi.com/manage-api-key",
92
+ }
93
+ ],
94
+ }
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,168 @@
1
+ """Hermes tool schemas for SerpApi's specialized search engines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MAPS_SEARCH_SCHEMA = {
6
+ "name": "serpapi_maps_search",
7
+ "description": (
8
+ "Search Google Maps through SerpApi for places, local businesses, restaurants, "
9
+ "shops, attractions, and services. Use this instead of web_search when the user "
10
+ "wants physical places, ratings, addresses, opening status, or coordinates."
11
+ ),
12
+ "parameters": {
13
+ "type": "object",
14
+ "properties": {
15
+ "query": {
16
+ "type": "string",
17
+ "description": "Place or business search, such as 'coffee shops' or 'museums'.",
18
+ },
19
+ "location": {
20
+ "type": "string",
21
+ "description": "Optional search origin, such as 'New York, NY' or '560001'.",
22
+ },
23
+ "latitude": {
24
+ "type": "number",
25
+ "minimum": -90,
26
+ "maximum": 90,
27
+ "description": "Optional precise search-origin latitude; requires longitude.",
28
+ },
29
+ "longitude": {
30
+ "type": "number",
31
+ "minimum": -180,
32
+ "maximum": 180,
33
+ "description": "Optional precise search-origin longitude; requires latitude.",
34
+ },
35
+ "zoom": {
36
+ "type": "integer",
37
+ "minimum": 3,
38
+ "maximum": 30,
39
+ "default": 14,
40
+ "description": "Map zoom when a location or coordinates are supplied.",
41
+ },
42
+ "nearby": {
43
+ "type": "boolean",
44
+ "default": False,
45
+ "description": "Prioritize results close to the supplied search origin.",
46
+ },
47
+ "language": {
48
+ "type": "string",
49
+ "description": "Optional two-letter language code, such as 'en' or 'es'.",
50
+ },
51
+ "country": {
52
+ "type": "string",
53
+ "description": "Optional two-letter country code, such as 'us' or 'in'.",
54
+ },
55
+ "limit": {
56
+ "type": "integer",
57
+ "minimum": 1,
58
+ "maximum": 20,
59
+ "default": 5,
60
+ "description": "Maximum number of places to return.",
61
+ },
62
+ },
63
+ "required": ["query"],
64
+ "additionalProperties": False,
65
+ },
66
+ }
67
+
68
+ NEWS_SEARCH_SCHEMA = {
69
+ "name": "serpapi_news_search",
70
+ "description": (
71
+ "Search recent Google News results through SerpApi. Use this for current events, "
72
+ "breaking news, recent reporting, and coverage from news publications."
73
+ ),
74
+ "parameters": {
75
+ "type": "object",
76
+ "properties": {
77
+ "query": {
78
+ "type": "string",
79
+ "description": "News search query.",
80
+ },
81
+ "location": {
82
+ "type": "string",
83
+ "description": "Optional city-level origin for geographically relevant news.",
84
+ },
85
+ "language": {
86
+ "type": "string",
87
+ "description": "Optional two-letter language code, such as 'en' or 'fr'.",
88
+ },
89
+ "country": {
90
+ "type": "string",
91
+ "description": "Optional two-letter country code, such as 'us' or 'in'.",
92
+ },
93
+ "limit": {
94
+ "type": "integer",
95
+ "minimum": 1,
96
+ "maximum": 20,
97
+ "default": 5,
98
+ "description": "Maximum number of news results to return.",
99
+ },
100
+ },
101
+ "required": ["query"],
102
+ "additionalProperties": False,
103
+ },
104
+ }
105
+
106
+ SHOPPING_SEARCH_SCHEMA = {
107
+ "name": "serpapi_shopping_search",
108
+ "description": (
109
+ "Search Google Shopping through SerpApi for products, merchants, prices, ratings, "
110
+ "and delivery information. Use this for shopping and product-comparison requests."
111
+ ),
112
+ "parameters": {
113
+ "type": "object",
114
+ "properties": {
115
+ "query": {
116
+ "type": "string",
117
+ "description": "Product search query.",
118
+ },
119
+ "location": {
120
+ "type": "string",
121
+ "description": "Optional city-level origin for local prices and availability.",
122
+ },
123
+ "language": {
124
+ "type": "string",
125
+ "description": "Optional two-letter language code, such as 'en' or 'de'.",
126
+ },
127
+ "country": {
128
+ "type": "string",
129
+ "description": "Optional two-letter country code, such as 'us' or 'in'.",
130
+ },
131
+ "minimum_price": {
132
+ "type": "number",
133
+ "minimum": 0,
134
+ "description": "Optional minimum product price in the selected market currency.",
135
+ },
136
+ "maximum_price": {
137
+ "type": "number",
138
+ "minimum": 0,
139
+ "description": "Optional maximum product price in the selected market currency.",
140
+ },
141
+ "sort": {
142
+ "type": "string",
143
+ "enum": ["relevance", "price_low_to_high", "price_high_to_low"],
144
+ "default": "relevance",
145
+ "description": "How to order products.",
146
+ },
147
+ "free_shipping": {
148
+ "type": "boolean",
149
+ "default": False,
150
+ "description": "Return only products offering free shipping.",
151
+ },
152
+ "on_sale": {
153
+ "type": "boolean",
154
+ "default": False,
155
+ "description": "Return only products currently on sale.",
156
+ },
157
+ "limit": {
158
+ "type": "integer",
159
+ "minimum": 1,
160
+ "maximum": 20,
161
+ "default": 5,
162
+ "description": "Maximum number of products to return.",
163
+ },
164
+ },
165
+ "required": ["query"],
166
+ "additionalProperties": False,
167
+ },
168
+ }
@@ -0,0 +1,255 @@
1
+ """Specialized SerpApi tool handlers exposed directly to Hermes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .client import SerpApiError, call_serpapi
9
+
10
+ _MAX_RESULTS = 20
11
+
12
+
13
+ def _json(value: dict[str, Any]) -> str:
14
+ return json.dumps(value, ensure_ascii=False)
15
+
16
+
17
+ def _error(message: str) -> str:
18
+ return _json({"success": False, "error": message})
19
+
20
+
21
+ def _query(args: dict[str, Any]) -> str:
22
+ return str(args.get("query") or "").strip()
23
+
24
+
25
+ def _limit(args: dict[str, Any]) -> int:
26
+ try:
27
+ return max(1, min(int(args.get("limit", 5)), _MAX_RESULTS))
28
+ except (TypeError, ValueError):
29
+ return 5
30
+
31
+
32
+ def _code(args: dict[str, Any], name: str) -> str | None:
33
+ value = str(args.get(name) or "").strip().lower()
34
+ if not value:
35
+ return None
36
+ if len(value) != 2 or not value.isalpha():
37
+ raise ValueError(f"{name} must be a two-letter code")
38
+ return value
39
+
40
+
41
+ def _optional_fields(item: dict[str, Any], names: tuple[str, ...]) -> dict[str, Any]:
42
+ return {name: item[name] for name in names if item.get(name) not in (None, "", [])}
43
+
44
+
45
+ def maps_search(args: dict[str, Any], **kwargs: Any) -> str:
46
+ """Search Google Maps for places and local businesses."""
47
+ del kwargs
48
+ query = _query(args)
49
+ if not query:
50
+ return _error("Search query must not be empty")
51
+
52
+ location = str(args.get("location") or "").strip()
53
+ latitude = args.get("latitude")
54
+ longitude = args.get("longitude")
55
+ has_coordinates = latitude is not None or longitude is not None
56
+ if has_coordinates and (latitude is None or longitude is None):
57
+ return _error("latitude and longitude must be provided together")
58
+ if location and has_coordinates:
59
+ return _error("Use either location or latitude/longitude, not both")
60
+
61
+ try:
62
+ params: dict[str, Any] = {
63
+ "q": query,
64
+ "type": "search",
65
+ "hl": _code(args, "language"),
66
+ "gl": _code(args, "country"),
67
+ }
68
+ if location:
69
+ params.update({"location": location, "z": int(args.get("zoom", 14))})
70
+ elif has_coordinates:
71
+ params.update(
72
+ {
73
+ "lat": float(latitude),
74
+ "lon": float(longitude),
75
+ "z": int(args.get("zoom", 14)),
76
+ }
77
+ )
78
+ if args.get("nearby"):
79
+ if not location and not has_coordinates:
80
+ return _error("nearby requires a location or latitude/longitude")
81
+ params["nearby"] = "true"
82
+ payload = call_serpapi("google_maps", params)
83
+ except (TypeError, ValueError) as exc:
84
+ return _error(str(exc))
85
+ except SerpApiError as exc:
86
+ return _error(str(exc))
87
+
88
+ results = []
89
+ raw_results = payload.get("local_results", [])
90
+ if not isinstance(raw_results, list):
91
+ return _error("SerpApi returned an unexpected Google Maps response")
92
+ for index, item in enumerate(raw_results[: _limit(args)], start=1):
93
+ if not isinstance(item, dict):
94
+ continue
95
+ result = {
96
+ "position": item.get("position", index),
97
+ **_optional_fields(
98
+ item,
99
+ (
100
+ "title",
101
+ "type",
102
+ "address",
103
+ "rating",
104
+ "reviews",
105
+ "price",
106
+ "open_state",
107
+ "phone",
108
+ "website",
109
+ "place_id",
110
+ ),
111
+ ),
112
+ }
113
+ coordinates = item.get("gps_coordinates")
114
+ if isinstance(coordinates, dict):
115
+ result["coordinates"] = coordinates
116
+ results.append(result)
117
+
118
+ return _json(
119
+ {
120
+ "success": True,
121
+ "engine": "google_maps",
122
+ "query": query,
123
+ "results_count": len(results),
124
+ "results": results,
125
+ }
126
+ )
127
+
128
+
129
+ def news_search(args: dict[str, Any], **kwargs: Any) -> str:
130
+ """Search Google News Light for recent reporting."""
131
+ del kwargs
132
+ query = _query(args)
133
+ if not query:
134
+ return _error("Search query must not be empty")
135
+
136
+ try:
137
+ payload = call_serpapi(
138
+ "google_news_light",
139
+ {
140
+ "q": query,
141
+ "location": str(args.get("location") or "").strip(),
142
+ "hl": _code(args, "language"),
143
+ "gl": _code(args, "country"),
144
+ },
145
+ )
146
+ except (TypeError, ValueError) as exc:
147
+ return _error(str(exc))
148
+ except SerpApiError as exc:
149
+ return _error(str(exc))
150
+
151
+ results = []
152
+ raw_results = payload.get("news_results", [])
153
+ if not isinstance(raw_results, list):
154
+ return _error("SerpApi returned an unexpected Google News response")
155
+ for index, item in enumerate(raw_results[: _limit(args)], start=1):
156
+ if not isinstance(item, dict):
157
+ continue
158
+ results.append(
159
+ {
160
+ "position": item.get("position", index),
161
+ **_optional_fields(item, ("title", "link", "source", "date", "snippet")),
162
+ }
163
+ )
164
+
165
+ return _json(
166
+ {
167
+ "success": True,
168
+ "engine": "google_news_light",
169
+ "query": query,
170
+ "results_count": len(results),
171
+ "results": results,
172
+ }
173
+ )
174
+
175
+
176
+ def shopping_search(args: dict[str, Any], **kwargs: Any) -> str:
177
+ """Search Google Shopping Light for products and prices."""
178
+ del kwargs
179
+ query = _query(args)
180
+ if not query:
181
+ return _error("Search query must not be empty")
182
+
183
+ minimum_price = args.get("minimum_price")
184
+ maximum_price = args.get("maximum_price")
185
+ if minimum_price is not None and maximum_price is not None:
186
+ try:
187
+ if float(minimum_price) > float(maximum_price):
188
+ return _error("minimum_price must not exceed maximum_price")
189
+ except (TypeError, ValueError):
190
+ return _error("minimum_price and maximum_price must be numbers")
191
+
192
+ sort_by = {
193
+ "price_low_to_high": "1",
194
+ "price_high_to_low": "2",
195
+ }.get(str(args.get("sort") or "relevance"))
196
+
197
+ try:
198
+ payload = call_serpapi(
199
+ "google_shopping_light",
200
+ {
201
+ "q": query,
202
+ "location": str(args.get("location") or "").strip(),
203
+ "hl": _code(args, "language"),
204
+ "gl": _code(args, "country"),
205
+ "min_price": minimum_price,
206
+ "max_price": maximum_price,
207
+ "sort_by": sort_by,
208
+ "free_shipping": "true" if args.get("free_shipping") else None,
209
+ "on_sale": "true" if args.get("on_sale") else None,
210
+ },
211
+ )
212
+ except (TypeError, ValueError) as exc:
213
+ return _error(str(exc))
214
+ except SerpApiError as exc:
215
+ return _error(str(exc))
216
+
217
+ results = []
218
+ raw_results = payload.get("shopping_results", [])
219
+ if not isinstance(raw_results, list):
220
+ return _error("SerpApi returned an unexpected Google Shopping response")
221
+ for index, item in enumerate(raw_results[: _limit(args)], start=1):
222
+ if not isinstance(item, dict):
223
+ continue
224
+ result = {
225
+ "position": item.get("position", index),
226
+ **_optional_fields(
227
+ item,
228
+ (
229
+ "title",
230
+ "source",
231
+ "price",
232
+ "extracted_price",
233
+ "old_price",
234
+ "rating",
235
+ "reviews",
236
+ "delivery",
237
+ "snippet",
238
+ "product_id",
239
+ ),
240
+ ),
241
+ }
242
+ link = item.get("link") or item.get("product_link")
243
+ if link:
244
+ result["link"] = link
245
+ results.append(result)
246
+
247
+ return _json(
248
+ {
249
+ "success": True,
250
+ "engine": "google_shopping_light",
251
+ "query": query,
252
+ "results_count": len(results),
253
+ "results": results,
254
+ }
255
+ )
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: serpapi-hermes-plugin
3
+ Version: 0.1.0
4
+ Summary: SerpApi search plugin for Hermes Agent: web, Maps, News, and Shopping
5
+ Author: SerpApi
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/serpapi/serpapi-hermes-plugin
8
+ Project-URL: Repository, https://github.com/serpapi/serpapi-hermes-plugin
9
+ Project-URL: Issues, https://github.com/serpapi/serpapi-hermes-plugin/issues
10
+ Keywords: hermes-agent,plugin,serpapi,web-search,maps,news,shopping
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Plugins
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
19
+ Requires-Python: <3.15,>=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: httpx<1,>=0.28.1
23
+ Dynamic: license-file
24
+
25
+ # SerpApi for Hermes Agent
26
+
27
+ Give [Hermes Agent](https://hermes-agent.nousresearch.com/) fast, fresh search
28
+ results from the web, Google Maps, Google News, and Google Shopping with
29
+ [SerpApi](https://serpapi.com/).
30
+
31
+ The plugin adds SerpApi to Hermes in two ways:
32
+
33
+ - Hermes's built-in `web_search` uses the fast Google Light engine.
34
+ - Dedicated Maps, News, and Shopping tools let Hermes choose the right SerpApi
35
+ engine for local places, current reporting, and product searches.
36
+
37
+ ## Ask Hermes to install it
38
+
39
+ You can let Hermes install and configure the plugin for you. Paste the message
40
+ below into a Hermes chat, then approve the install or terminal commands if
41
+ Hermes prompts you:
42
+
43
+ ```text
44
+ Install serpapi-hermes-plugin into the same Python environment that runs this
45
+ Hermes Agent. If this is a standard Hermes installation, run:
46
+
47
+ cd ~/.hermes/hermes-agent
48
+ uv pip install --python venv/bin/python serpapi-hermes-plugin
49
+
50
+ Otherwise, use the active Hermes Python environment to run:
51
+
52
+ uv pip install --python /path/to/hermes/python serpapi-hermes-plugin
53
+
54
+ Request my approval for install or terminal commands whenever required; do not
55
+ bypass approvals. After the package is installed, run:
56
+
57
+ hermes plugins enable serpapi
58
+
59
+ If Hermes asks whether to allow this plugin to replace built-in tools, answer
60
+ no; serpapi-hermes-plugin does not need tool-override access.
61
+
62
+ Then ask me for my SerpApi Private API Key, which I can copy from the SerpApi
63
+ dashboard. Do not ask for the key until installation and enablement succeed.
64
+ After I provide it, save it as SERPAPI_API_KEY in ~/.hermes/.env without
65
+ printing, logging, or committing it. Configure SerpApi as the Hermes web search
66
+ backend, tell me whether Hermes must be restarted, and verify that web search,
67
+ Maps, News, and Shopping tools are available. Use this key for future SerpApi
68
+ searches and never expose it in output.
69
+ ```
70
+
71
+ ## Install
72
+
73
+ You need a working
74
+ [Hermes Agent installation](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart/)
75
+ and a SerpApi account.
76
+
77
+ Install the plugin from PyPI in the same Python environment as Hermes. If you
78
+ installed Hermes with its standard installer, use its `venv` interpreter
79
+ explicitly:
80
+
81
+ ```bash
82
+ cd ~/.hermes/hermes-agent
83
+ uv pip install --python venv/bin/python serpapi-hermes-plugin
84
+ ```
85
+
86
+ The explicit `--python` is important: the standard Hermes installer creates a
87
+ directory named `venv`, while bare `uv pip install` automatically looks for a
88
+ directory named `.venv`.
89
+
90
+ For a custom Hermes installation, point uv at the Python interpreter that runs
91
+ Hermes:
92
+
93
+ ```bash
94
+ uv pip install --python /path/to/hermes/python serpapi-hermes-plugin
95
+ ```
96
+
97
+ If that environment already has pip and is activated, the equivalent command
98
+ is `python -m pip install serpapi-hermes-plugin`.
99
+
100
+ Restart any running Hermes session after installation.
101
+
102
+ ## Get your SerpApi API key
103
+
104
+ 1. [Create a SerpApi account](https://serpapi.com/users/sign_up), or sign in to
105
+ your existing account.
106
+ 2. Open the [SerpApi dashboard](https://serpapi.com/dashboard).
107
+ 3. Find your **Private API Key** in the dashboard and copy it.
108
+
109
+ One API key powers every engine in this plugin. Keep it private and never add it
110
+ to source control.
111
+
112
+ ## Connect the API key to Hermes
113
+
114
+ Enable the installed plugin:
115
+
116
+ ```bash
117
+ hermes plugins enable serpapi
118
+ ```
119
+
120
+ If prompted about permission to replace built-in tools, answer **no**. This
121
+ plugin adds new tools and a web-search provider; it does not replace built-in
122
+ tool handlers.
123
+
124
+ Open Hermes's interactive tool configuration:
125
+
126
+ ```bash
127
+ hermes tools
128
+ ```
129
+
130
+ In the **Web Search & Extract** section:
131
+
132
+ 1. Select **SerpApi**.
133
+ 2. Paste the Private API Key copied from your SerpApi dashboard.
134
+ 3. Confirm the selection.
135
+
136
+ Hermes saves the key as `SERPAPI_API_KEY` in its environment configuration and
137
+ selects SerpApi for future `web_search` calls. Restart Hermes if a session was
138
+ already running.
139
+
140
+ ## Manual API-key setup
141
+
142
+ Instead of using `hermes tools`, add the key to `~/.hermes/.env`:
143
+
144
+ ```dotenv
145
+ SERPAPI_API_KEY=your_private_api_key
146
+ ```
147
+
148
+ Then configure `~/.hermes/config.yaml`:
149
+
150
+ ```yaml
151
+ plugins:
152
+ enabled:
153
+ - serpapi
154
+
155
+ web:
156
+ search_backend: serpapi
157
+ ```
158
+
159
+ You may also export `SERPAPI_API_KEY` in the environment that starts Hermes.
160
+ The environment variable takes precedence over the value in `~/.hermes/.env`.
161
+
162
+ ## Search capabilities
163
+
164
+ | What you ask for | Hermes tool | SerpApi engine |
165
+ |---|---|---|
166
+ | General web research | `web_search` | `google_light` |
167
+ | Places and local businesses | `serpapi_maps_search` | `google_maps` |
168
+ | Current and recent news | `serpapi_news_search` | `google_news_light` |
169
+ | Products, prices, and merchants | `serpapi_shopping_search` | `google_shopping_light` |
170
+
171
+ Hermes chooses a tool from your request. Each tool selects and validates its own
172
+ SerpApi engine, so you do not need to specify an engine name.
173
+
174
+ Example prompts:
175
+
176
+ - "Search the web for the latest Python 3.14 release notes."
177
+ - "Find highly rated coffee shops near Times Square."
178
+ - "Show me recent news about reusable rockets."
179
+ - "Find well-reviewed laptops under $1,200 with free shipping."
180
+
181
+ ## Contributing
182
+
183
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, tests, pull request
184
+ guidelines, and the release process.
185
+
186
+ ## License
187
+
188
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ serpapi_hermes_plugin/__init__.py,sha256=KDSW9Cu-OVfMJ7n-XcMvZC3cPdv1x3pTtazwccCjA-E,1008
2
+ serpapi_hermes_plugin/client.py,sha256=vBPB2GhhZCbvo4Q_bX4CQbzPPHG8KzhenEdbYM0DrVs,3094
3
+ serpapi_hermes_plugin/plugin.yaml,sha256=Dr7O5AwCoE498liP9xMvnPjNjNrjCvLsqmwRh59G7jY,419
4
+ serpapi_hermes_plugin/provider.py,sha256=7GclAF8JCXM423YD4OC0NCSxSmzvaCx5xFhn471t8l8,2969
5
+ serpapi_hermes_plugin/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
+ serpapi_hermes_plugin/schemas.py,sha256=yqjOstdyGI9FJGP9wCcd3mVmy6drX98NBhtRr0OtcQY,6064
7
+ serpapi_hermes_plugin/tools.py,sha256=OnZcC1QLfgN3XrszYNIebv9a2oGiAYRllT0D5G2f6BY,8084
8
+ serpapi_hermes_plugin-0.1.0.dist-info/licenses/LICENSE,sha256=3U38OtHYEdO6tpAyF3_N3kNiLzlaP-gR43Ulqaztlio,1065
9
+ serpapi_hermes_plugin-0.1.0.dist-info/METADATA,sha256=IGxhMzxip2qEHM96jMsOpLq0QemthCjeFDmWnF16ipA,6243
10
+ serpapi_hermes_plugin-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ serpapi_hermes_plugin-0.1.0.dist-info/entry_points.txt,sha256=UKdIGfIw1Qr7a7xok4xUVT8vwY3r1-1RF6F4epZXZv4,55
12
+ serpapi_hermes_plugin-0.1.0.dist-info/top_level.txt,sha256=dQ_7H05QyBzHhcN5vrbAqItW02MJknlOJ6vEmdFNlcI,22
13
+ serpapi_hermes_plugin-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [hermes_agent.plugins]
2
+ serpapi = serpapi_hermes_plugin
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SerpApi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1 @@
1
+ serpapi_hermes_plugin