diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index 1c7dd9f7497e8..2b34b96635fe7 100644
--- a/agent/auxiliary_client.py
+++ b/agent/auxiliary_client.py
@@ -155,6 +155,9 @@ def _extract_url_query_params(url: str):
"github-models": "copilot",
"github-copilot-acp": "copilot-acp",
"copilot-acp-agent": "copilot-acp",
+ "grok-build": "grok-build",
+ "grokbuild": "grok-build",
+ "grok-cli": "grok-build",
"tencent": "tencent-tokenhub",
"tokenhub": "tencent-tokenhub",
"tencent-cloud": "tencent-tokenhub",
@@ -3225,31 +3228,43 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
or _read_main_model(),
provider,
)
- if provider == "copilot-acp":
+ if provider in {"copilot-acp", "grok-build"}:
api_key = str(creds.get("api_key", "")).strip()
base_url = str(creds.get("base_url", "")).strip()
command = str(creds.get("command", "")).strip() or None
args = list(creds.get("args") or [])
if not final_model:
logger.warning(
- "resolve_provider_client: copilot-acp requested but no model "
- "was provided or configured"
+ "resolve_provider_client: %s requested but no model "
+ "was provided or configured",
+ provider,
)
return None, None
if not api_key or not base_url:
logger.warning(
- "resolve_provider_client: copilot-acp requested but external "
- "process credentials are incomplete"
+ "resolve_provider_client: %s requested but external "
+ "process credentials are incomplete",
+ provider,
)
return None, None
- from agent.copilot_acp_client import CopilotACPClient
+ if provider == "grok-build":
+ from agent.grok_cli_client import GrokCliClient
+
+ client = GrokCliClient(
+ api_key=api_key,
+ base_url=base_url,
+ command=command,
+ args=args,
+ )
+ else:
+ from agent.copilot_acp_client import CopilotACPClient
- client = CopilotACPClient(
- api_key=api_key,
- base_url=base_url,
- command=command,
- args=args,
- )
+ client = CopilotACPClient(
+ api_key=api_key,
+ base_url=base_url,
+ command=command,
+ args=args,
+ )
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
diff --git a/agent/grok_cli_client.py b/agent/grok_cli_client.py
new file mode 100644
index 0000000000000..34ebc1c2777f0
--- /dev/null
+++ b/agent/grok_cli_client.py
@@ -0,0 +1,332 @@
+"""OpenAI-compatible shim that forwards Hermes requests to the Grok CLI."""
+
+from __future__ import annotations
+
+import json
+import os
+import shlex
+import subprocess
+import tempfile
+import threading
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+from agent.copilot_acp_client import _extract_tool_calls_from_text
+
+GROK_CLI_MARKER_BASE_URL = "grok-cli://local"
+_DEFAULT_TIMEOUT_SECONDS = 900.0
+
+
+def _resolve_command() -> str:
+ for candidate in (
+ os.getenv("HERMES_GROK_BUILD_COMMAND", "").strip(),
+ os.getenv("GROK_CLI_PATH", "").strip(),
+ os.path.expanduser("~/.grok/bin/grok"),
+ os.path.expanduser("~/.local/bin/grok"),
+ "grok",
+ ):
+ if not candidate:
+ continue
+ if os.path.isabs(candidate):
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
+ return candidate
+ continue
+ return candidate
+ return "grok"
+
+
+def _resolve_args() -> list[str]:
+ raw = os.getenv("HERMES_GROK_BUILD_ARGS", "").strip()
+ if raw:
+ return shlex.split(raw)
+ effort = (
+ os.getenv("HERMES_GROK_BUILD_EFFORT", "").strip()
+ or os.getenv("GROK_BUILD_EFFORT", "").strip()
+ or "xhigh"
+ )
+ return [
+ "--no-memory",
+ "--disable-web-search",
+ "--max-turns",
+ "1",
+ "--output-format",
+ "plain",
+ "--effort",
+ effort,
+ ]
+
+
+def _normalize_timeout(timeout: Any) -> float:
+ if timeout is None:
+ return _DEFAULT_TIMEOUT_SECONDS
+ if isinstance(timeout, (int, float)):
+ return float(timeout)
+ candidates = [
+ getattr(timeout, attr, None)
+ for attr in ("read", "write", "connect", "pool", "timeout")
+ ]
+ numeric = [float(v) for v in candidates if isinstance(v, (int, float))]
+ return max(numeric) if numeric else _DEFAULT_TIMEOUT_SECONDS
+
+
+def _build_subprocess_env() -> dict[str, str]:
+ env = os.environ.copy()
+ home = os.environ.get("HOME", "").strip() or os.path.expanduser("~")
+ if home and home != "~":
+ env["HOME"] = home
+ # Non-interactive launchd/SSH services often miss user-local CLI install dirs.
+ path = env.get("PATH", "")
+ extras = [os.path.expanduser("~/.grok/bin"), os.path.expanduser("~/.local/bin")]
+ env["PATH"] = os.pathsep.join([p for p in extras + [path] if p])
+ return env
+
+
+def _format_messages_as_prompt(
+ messages: list[dict[str, Any]],
+ *,
+ model: str | None = None,
+ tools: list[dict[str, Any]] | None = None,
+ tool_choice: Any = None,
+) -> str:
+ sections: list[str] = [
+ "You are the active Grok Build backend for Hermes Agent.",
+ "Complete the latest user request using the conversation transcript below.",
+ "If a tool is needed, emit ONLY {...} blocks with JSON in OpenAI function-call shape.",
+ "If no tool is needed, answer normally.",
+ ]
+ if model:
+ sections.append(f"Hermes requested model: {model}")
+
+ tool_specs: list[dict[str, Any]] = []
+ for tool in tools or []:
+ if not isinstance(tool, dict):
+ continue
+ fn = tool.get("function") or {}
+ if not isinstance(fn, dict):
+ continue
+ name = fn.get("name")
+ if not isinstance(name, str) or not name.strip():
+ continue
+ tool_specs.append(
+ {
+ "name": name.strip(),
+ "description": fn.get("description", ""),
+ "parameters": fn.get("parameters", {}),
+ }
+ )
+ if tool_specs:
+ sections.append(
+ "Available tools. To use one, emit a single {...} "
+ "object with id/type/function{name,arguments}; arguments must be a JSON string.\n"
+ + json.dumps(tool_specs, ensure_ascii=False)
+ )
+ if tool_choice is not None:
+ sections.append(f"Tool choice hint: {json.dumps(tool_choice, ensure_ascii=False)}")
+
+ transcript: list[str] = []
+ for message in messages or []:
+ if not isinstance(message, dict):
+ continue
+ role = str(message.get("role") or "context").strip().lower()
+ if role not in {"system", "user", "assistant", "tool"}:
+ role = "context"
+ rendered = _render_message_content(message.get("content"))
+ if rendered:
+ transcript.append(f"{role.title()}:\n{rendered}")
+ if transcript:
+ sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript))
+
+ sections.append("Continue from the latest user message.")
+ return "\n\n".join(s.strip() for s in sections if s and s.strip())
+
+
+def _render_message_content(content: Any) -> str:
+ if content is None:
+ return ""
+ if isinstance(content, str):
+ return content.strip()
+ if isinstance(content, dict):
+ text = content.get("text")
+ if isinstance(text, str):
+ return text.strip()
+ inner = content.get("content")
+ if isinstance(inner, str):
+ return inner.strip()
+ return json.dumps(content, ensure_ascii=True)
+ if isinstance(content, list):
+ parts: list[str] = []
+ for item in content:
+ if isinstance(item, str):
+ parts.append(item)
+ elif isinstance(item, dict):
+ text = item.get("text")
+ if isinstance(text, str) and text.strip():
+ parts.append(text.strip())
+ return "\n".join(parts).strip()
+ return str(content).strip()
+
+
+def _has_flag(args: list[str], *flags: str) -> bool:
+ return any(arg in flags or any(arg.startswith(flag + "=") for flag in flags) for arg in args)
+
+
+class _GrokChatCompletions:
+ def __init__(self, client: "GrokCliClient"):
+ self._client = client
+
+ def create(self, **kwargs: Any) -> Any:
+ return self._client._create_chat_completion(**kwargs)
+
+
+class _GrokChatNamespace:
+ def __init__(self, client: "GrokCliClient"):
+ self.completions = _GrokChatCompletions(client)
+
+
+class GrokCliClient:
+ """Minimal OpenAI-client-compatible facade for `grok --single`."""
+
+ def __init__(
+ self,
+ *,
+ api_key: str | None = None,
+ base_url: str | None = None,
+ default_headers: dict[str, str] | None = None,
+ command: str | None = None,
+ args: list[str] | None = None,
+ grok_command: str | None = None,
+ grok_args: list[str] | None = None,
+ cwd: str | None = None,
+ **_: Any,
+ ):
+ self.api_key = api_key or "grok-build"
+ self.base_url = base_url or GROK_CLI_MARKER_BASE_URL
+ self._default_headers = dict(default_headers or {})
+ self._command = grok_command or command or _resolve_command()
+ self._args = list(grok_args or args or _resolve_args())
+ self._cwd = str(Path(cwd or os.getcwd()).resolve())
+ self.chat = _GrokChatNamespace(self)
+ self.is_closed = False
+ self._active_process: subprocess.Popen[str] | None = None
+ self._active_process_lock = threading.Lock()
+
+ def close(self) -> None:
+ proc: subprocess.Popen[str] | None
+ with self._active_process_lock:
+ proc = self._active_process
+ self._active_process = None
+ self.is_closed = True
+ if proc is None:
+ return
+ try:
+ proc.terminate()
+ proc.wait(timeout=2)
+ except Exception:
+ try:
+ proc.kill()
+ except Exception:
+ pass
+
+ def _create_chat_completion(
+ self,
+ *,
+ model: str | None = None,
+ messages: list[dict[str, Any]] | None = None,
+ timeout: Any = None,
+ tools: list[dict[str, Any]] | None = None,
+ tool_choice: Any = None,
+ **_: Any,
+ ) -> Any:
+ prompt_text = _format_messages_as_prompt(
+ messages or [],
+ model=model,
+ tools=tools,
+ tool_choice=tool_choice,
+ )
+ response_text = self._run_prompt(
+ prompt_text,
+ model=model or "grok-build",
+ timeout_seconds=_normalize_timeout(timeout),
+ )
+ tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text)
+
+ usage = SimpleNamespace(
+ prompt_tokens=0,
+ completion_tokens=0,
+ total_tokens=0,
+ prompt_tokens_details=SimpleNamespace(cached_tokens=0),
+ )
+ assistant_message = SimpleNamespace(
+ content=cleaned_text,
+ tool_calls=tool_calls,
+ reasoning=None,
+ reasoning_content=None,
+ reasoning_details=None,
+ )
+ finish_reason = "tool_calls" if tool_calls else "stop"
+ choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason)
+ return SimpleNamespace(choices=[choice], usage=usage, model=model or "grok-build")
+
+ def _run_prompt(self, prompt_text: str, *, model: str, timeout_seconds: float) -> str:
+ cmd = [self._command] + list(self._args)
+ if not _has_flag(cmd, "--model", "-m"):
+ cmd.extend(["--model", model])
+ prompt_file_path: str | None = None
+ if not _has_flag(cmd, "--single", "-p", "--prompt", "--prompt-file", "--prompt-json"):
+ with tempfile.NamedTemporaryFile(
+ "w",
+ encoding="utf-8",
+ suffix=".txt",
+ prefix="hermes-grok-",
+ delete=False,
+ ) as prompt_file:
+ prompt_file.write(prompt_text)
+ prompt_file_path = prompt_file.name
+ cmd.extend(["--prompt-file", prompt_file_path])
+
+ try:
+ proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ cwd=self._cwd,
+ env=_build_subprocess_env(),
+ )
+ except FileNotFoundError as exc:
+ if prompt_file_path:
+ try:
+ os.unlink(prompt_file_path)
+ except OSError:
+ pass
+ raise RuntimeError(
+ f"Could not start Grok CLI command '{self._command}'. "
+ "Install xAI's Grok CLI or set HERMES_GROK_BUILD_COMMAND/GROK_CLI_PATH."
+ ) from exc
+
+ self.is_closed = False
+ with self._active_process_lock:
+ self._active_process = proc
+
+ try:
+ stdout, stderr = proc.communicate(timeout=timeout_seconds)
+ except subprocess.TimeoutExpired as exc:
+ self.close()
+ raise TimeoutError(f"Timed out waiting for Grok CLI after {timeout_seconds:.0f}s.") from exc
+ finally:
+ with self._active_process_lock:
+ if self._active_process is proc:
+ self._active_process = None
+ if prompt_file_path:
+ try:
+ os.unlink(prompt_file_path)
+ except OSError:
+ pass
+
+ if proc.returncode != 0:
+ stderr_tail = "\n".join((stderr or "").splitlines()[-20:]).strip()
+ detail = stderr_tail or (stdout or "").strip() or f"exit code {proc.returncode}"
+ raise RuntimeError(f"Grok CLI returned exit code {proc.returncode}: {detail}")
+
+ return (stdout or "").strip()
diff --git a/agent/model_metadata.py b/agent/model_metadata.py
index 41e229416c9d9..012152f1cc4f3 100644
--- a/agent/model_metadata.py
+++ b/agent/model_metadata.py
@@ -45,7 +45,7 @@ def _resolve_requests_verify() -> bool | str:
# Only these are stripped — Ollama-style "model:tag" colons (e.g. "qwen3.5:27b")
# are preserved so the full model name reaches cache lookups and server queries.
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
- "openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
+ "openrouter", "nous", "openai-codex", "grok-build", "copilot", "copilot-acp",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "novita",
"qwen-oauth",
@@ -208,6 +208,7 @@ def _strip_provider_prefix(model: str) -> str:
# via a custom provider. Values sourced from models.dev (2026-04).
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
+ "grok-build": 512000, # Grok Build CLI; static default until CLI exposes metadata
"grok-code-fast": 256000, # grok-code-fast-1
"grok-4-1-fast": 2000000, # grok-4-1-fast-(non-)reasoning
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
@@ -1477,6 +1478,14 @@ def get_model_context_length(
# local servers actually know about. Ollama "model:tag" colons are preserved.
model = _strip_provider_prefix(model)
+ # Grok Build is a local CLI transport, not an OpenAI-compatible HTTP
+ # endpoint. The ``grok-cli://local`` marker should not go through the
+ # custom-endpoint probe path and accidentally inherit the generic fallback.
+ provider_key = (provider or "").strip().lower()
+ base_url_key = (base_url or "").strip().lower()
+ if provider_key == "grok-build" or base_url_key.startswith("grok-cli://"):
+ return DEFAULT_CONTEXT_LENGTHS["grok-build"]
+
# 1. Check persistent cache (model+provider)
# LM Studio is excluded — its loaded context length is transient (the
# user can reload the model with a different context_length at any time
diff --git a/agent/transports/hermes_tools_mcp_server.py b/agent/transports/hermes_tools_mcp_server.py
index 37f2d6179d117..e7d983203c18f 100644
--- a/agent/transports/hermes_tools_mcp_server.py
+++ b/agent/transports/hermes_tools_mcp_server.py
@@ -19,6 +19,7 @@
- vision_analyze — image inspection by vision model
- image_generate — image generation
- skill_view, skills_list — Hermes' skill library
+ - obsidian_read_tasks — structured Obsidian task reads
- text_to_speech — TTS
- kanban_* (complete/block/comment/ — kanban worker + orchestrator
heartbeat/show/list/create/ handoff (stateless: read env var,
@@ -82,6 +83,7 @@
"image_generate",
"skill_view",
"skills_list",
+ "obsidian_read_tasks",
"text_to_speech",
# Kanban worker handoff tools — gated on HERMES_KANBAN_TASK env var
# (set by the kanban dispatcher when spawning a worker). Without these
diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py
index 0fffb82d0b949..5b3b2ed9cabf4 100644
--- a/gateway/platforms/email.py
+++ b/gateway/platforms/email.py
@@ -65,6 +65,15 @@
# Supported image extensions for inline detection
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
+
+def _env_bool(name: str, default: bool) -> bool:
+ """Parse common boolean environment values."""
+ value = os.getenv(name)
+ if value is None:
+ return default
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
def _send_imap_id(imap: "imaplib.IMAP4") -> None:
"""Send RFC 2971 IMAP ID command identifying this client.
@@ -255,6 +264,9 @@ def __init__(self, config: PlatformConfig):
self._smtp_host = os.getenv("EMAIL_SMTP_HOST", "")
self._smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587"))
self._poll_interval = int(os.getenv("EMAIL_POLL_INTERVAL", "15"))
+ self._use_ssl = _env_bool("EMAIL_USE_SSL", True)
+ self._imap_starttls = _env_bool("EMAIL_IMAP_STARTTLS", False)
+ self._smtp_starttls = _env_bool("EMAIL_SMTP_STARTTLS", self._use_ssl)
# Skip attachments — configured via config.yaml:
# platforms:
@@ -273,6 +285,23 @@ def __init__(self, config: PlatformConfig):
logger.info("[Email] Adapter initialized for %s", self._address)
+ def _open_imap(self) -> "imaplib.IMAP4":
+ """Open IMAP using configured transport security."""
+ if self._use_ssl:
+ return imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30)
+
+ imap = imaplib.IMAP4(self._imap_host, self._imap_port, timeout=30)
+ if self._imap_starttls:
+ imap.starttls(ssl_context=ssl.create_default_context())
+ return imap
+
+ def _open_smtp(self) -> smtplib.SMTP:
+ """Open SMTP using configured transport security."""
+ smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
+ if self._smtp_starttls:
+ smtp.starttls(context=ssl.create_default_context())
+ return smtp
+
def _trim_seen_uids(self) -> None:
"""Keep only the most recent UIDs to prevent unbounded memory growth.
@@ -297,7 +326,7 @@ async def connect(self) -> bool:
"""Connect to the IMAP server and start polling for new messages."""
try:
# Test IMAP connection
- imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30)
+ imap = self._open_imap()
imap.login(self._address, self._password)
_send_imap_id(imap)
# Mark all existing messages as seen so we only process new ones
@@ -316,8 +345,7 @@ async def connect(self) -> bool:
try:
# Test SMTP connection
- smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
- smtp.starttls(context=ssl.create_default_context())
+ smtp = self._open_smtp()
smtp.login(self._address, self._password)
smtp.quit()
logger.info("[Email] SMTP connection test passed.")
@@ -365,7 +393,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:
"""Fetch new (unseen) messages from IMAP. Runs in executor thread."""
results = []
try:
- imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30)
+ imap = self._open_imap()
try:
imap.login(self._address, self._password)
_send_imap_id(imap)
@@ -548,9 +576,8 @@ def _send_email(
msg.attach(MIMEText(body, "plain", "utf-8"))
- smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
+ smtp = self._open_smtp()
try:
- smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
@@ -670,9 +697,8 @@ def _send_email_with_attachments(
except Exception as e:
logger.warning("[Email] Failed to attach %s: %s", file_path, e)
- smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
+ smtp = self._open_smtp()
try:
- smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
@@ -749,9 +775,8 @@ def _send_email_with_attachment(
part.add_header("Content-Disposition", f"attachment; filename={fname}")
msg.attach(part)
- smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
+ smtp = self._open_smtp()
try:
- smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index 6cabb61570d79..18946c6c71a6e 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -84,6 +84,7 @@
DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
+DEFAULT_GROK_BUILD_BASE_URL = "grok-cli://local"
DEFAULT_OLLAMA_CLOUD_BASE_URL = "https://ollama.com/v1"
STEPFUN_STEP_PLAN_INTL_BASE_URL = "https://api.stepfun.ai/step_plan/v1"
STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1"
@@ -215,6 +216,13 @@ class ProviderConfig:
inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL,
base_url_env_var="COPILOT_ACP_BASE_URL",
),
+ "grok-build": ProviderConfig(
+ id="grok-build",
+ name="Grok Build CLI",
+ auth_type="external_process",
+ inference_base_url=DEFAULT_GROK_BUILD_BASE_URL,
+ base_url_env_var="HERMES_GROK_BUILD_BASE_URL",
+ ),
"gemini": ProviderConfig(
id="gemini",
name="Google AI Studio",
@@ -1384,6 +1392,8 @@ def resolve_provider(
"x-ai": "xai", "x.ai": "xai", "grok": "xai",
"xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth",
"grok-oauth": "xai-oauth", "xai-grok-oauth": "xai-oauth",
+ "grok-build": "grok-build", "grokbuild": "grok-build",
+ "grok-cli": "grok-build", "xai-build": "grok-build",
"kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
"step": "stepfun", "stepfun-coding-plan": "stepfun",
@@ -4674,27 +4684,22 @@ def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]:
if not pconfig or pconfig.auth_type != "external_process":
return {"configured": False}
- command = (
- os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
- or os.getenv("COPILOT_CLI_PATH", "").strip()
- or "copilot"
- )
- raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
- args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
+ command, args = _external_process_command_and_args(provider_id)
base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
if not base_url:
base_url = pconfig.inference_base_url
resolved_command = shutil.which(command) if command else None
+ configured = bool(resolved_command or _external_process_allows_without_command(provider_id, base_url))
return {
- "configured": bool(resolved_command or base_url.startswith("acp+tcp://")),
+ "configured": configured,
"provider": provider_id,
"name": pconfig.name,
"command": command,
"args": args,
"resolved_command": resolved_command,
"base_url": base_url,
- "logged_in": bool(resolved_command or base_url.startswith("acp+tcp://")),
+ "logged_in": configured,
}
@@ -4715,10 +4720,10 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
return get_gemini_oauth_auth_status()
if target == "minimax-oauth":
return get_minimax_oauth_auth_status()
- if target == "copilot-acp":
+ pconfig = PROVIDER_REGISTRY.get(target)
+ if pconfig and pconfig.auth_type == "external_process":
return get_external_process_provider_status(target)
# API-key providers
- pconfig = PROVIDER_REGISTRY.get(target)
if pconfig and pconfig.auth_type == "api_key":
return get_api_key_provider_status(target)
# AWS SDK providers (Bedrock) — check via boto3 credential chain
@@ -4776,6 +4781,67 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
}
+def _grok_cli_default_command() -> str:
+ """Return a useful default command for the Grok CLI.
+
+ The official installer places binaries under ~/.grok/bin and often adds a
+ ~/.local/bin shim, but non-interactive services do not always inherit that
+ PATH. Prefer explicit installed paths before falling back to PATH lookup.
+ """
+ for candidate in (
+ os.path.expanduser("~/.grok/bin/grok"),
+ os.path.expanduser("~/.local/bin/grok"),
+ ):
+ if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK):
+ return candidate
+ return "grok"
+
+
+def _external_process_command_and_args(provider_id: str) -> tuple[str, list[str]]:
+ """Resolve provider-specific subprocess command defaults."""
+ if provider_id == "grok-build":
+ command = (
+ os.getenv("HERMES_GROK_BUILD_COMMAND", "").strip()
+ or os.getenv("GROK_CLI_PATH", "").strip()
+ or _grok_cli_default_command()
+ )
+ raw_args = os.getenv("HERMES_GROK_BUILD_ARGS", "").strip()
+ if raw_args:
+ args = shlex.split(raw_args)
+ else:
+ effort = (
+ os.getenv("HERMES_GROK_BUILD_EFFORT", "").strip()
+ or os.getenv("GROK_BUILD_EFFORT", "").strip()
+ or "xhigh"
+ )
+ args = [
+ "--no-memory",
+ "--disable-web-search",
+ "--max-turns",
+ "1",
+ "--output-format",
+ "plain",
+ "--effort",
+ effort,
+ ]
+ return command, args
+
+ command = (
+ os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
+ or os.getenv("COPILOT_CLI_PATH", "").strip()
+ or "copilot"
+ )
+ raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
+ args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
+ return command, args
+
+
+def _external_process_allows_without_command(provider_id: str, base_url: str) -> bool:
+ if provider_id == "copilot-acp":
+ return base_url.startswith("acp+tcp://")
+ return False
+
+
def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, Any]:
"""Resolve runtime details for local subprocess-backed providers."""
pconfig = PROVIDER_REGISTRY.get(provider_id)
@@ -4790,25 +4856,25 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str,
if not base_url:
base_url = pconfig.inference_base_url
- command = (
- os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
- or os.getenv("COPILOT_CLI_PATH", "").strip()
- or "copilot"
- )
- raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
- args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
+ command, args = _external_process_command_and_args(provider_id)
resolved_command = shutil.which(command) if command else None
- if not resolved_command and not base_url.startswith("acp+tcp://"):
+ if not resolved_command and not _external_process_allows_without_command(provider_id, base_url):
+ command_label = "Grok CLI" if provider_id == "grok-build" else "Copilot CLI"
+ env_hint = (
+ "HERMES_GROK_BUILD_COMMAND/GROK_CLI_PATH"
+ if provider_id == "grok-build"
+ else "HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH"
+ )
raise AuthError(
- f"Could not find the Copilot CLI command '{command}'. "
- "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH.",
+ f"Could not find the {command_label} command '{command}'. "
+ f"Install the CLI or set {env_hint}.",
provider=provider_id,
- code="missing_copilot_cli",
+ code=f"missing_{provider_id.replace('-', '_')}_cli",
)
return {
"provider": provider_id,
- "api_key": "copilot-acp",
+ "api_key": provider_id,
"base_url": base_url.rstrip("/"),
"command": resolved_command or command,
"args": args,
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 41c4a23f93288..2c02762a79f97 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -1988,6 +1988,8 @@ def _lookup_ref(name: str, provider_key: str, model: str) -> str:
_model_flow_openai_codex(config, current_model)
elif selected_provider == "xai-oauth":
_model_flow_xai_oauth(config, current_model)
+ elif selected_provider == "grok-build":
+ _model_flow_grok_build(config, current_model)
elif selected_provider == "qwen-oauth":
_model_flow_qwen_oauth(config, current_model)
elif selected_provider == "minimax-oauth":
@@ -4372,6 +4374,62 @@ def _model_flow_copilot_acp(config, current_model=""):
print(f"Default model set to: {selected} (via {pconfig.name})")
+def _model_flow_grok_build(config, current_model=""):
+ """Grok Build flow using the local Grok CLI."""
+ from hermes_cli.auth import (
+ PROVIDER_REGISTRY,
+ _prompt_model_selection,
+ _save_model_choice,
+ deactivate_provider,
+ get_external_process_provider_status,
+ resolve_external_process_provider_credentials,
+ )
+ from hermes_cli.config import load_config, save_config
+ from hermes_cli.models import _PROVIDER_MODELS
+
+ del config
+
+ provider_id = "grok-build"
+ pconfig = PROVIDER_REGISTRY[provider_id]
+ status = get_external_process_provider_status(provider_id)
+ resolved_command = status.get("resolved_command") or status.get("command") or "grok"
+ effective_base = status.get("base_url") or pconfig.inference_base_url
+
+ print(" Grok Build delegates Hermes turns to the local `grok` CLI.")
+ print(" It uses your existing Grok/Grok Build subscription login.")
+ print(f" Command: {resolved_command}")
+ print(f" Backend marker: {effective_base}")
+ print()
+
+ try:
+ creds = resolve_external_process_provider_credentials(provider_id)
+ except Exception as exc:
+ print(f" ⚠ {exc}")
+ print(" Install xAI's Grok CLI or set HERMES_GROK_BUILD_COMMAND/GROK_CLI_PATH.")
+ return
+
+ effective_base = creds.get("base_url") or effective_base
+ model_list = _PROVIDER_MODELS.get(provider_id, ["grok-build"])
+ selected = _prompt_model_selection(model_list, current_model=current_model)
+ if not selected:
+ print("No change.")
+ return
+
+ _save_model_choice(selected)
+ cfg = load_config()
+ model = cfg.get("model")
+ if not isinstance(model, dict):
+ model = {"default": model} if model else {}
+ cfg["model"] = model
+ model["provider"] = provider_id
+ model["base_url"] = effective_base
+ model["api_mode"] = "chat_completions"
+ save_config(cfg)
+ deactivate_provider()
+
+ print(f"Default model set to: {selected} (via {pconfig.name})")
+
+
def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple:
"""Shared API-key entry point for ``hermes setup`` / ``hermes model``.
@@ -9580,7 +9638,7 @@ def _build_provider_choices() -> list[str]:
except Exception:
# Fallback: static list guarantees the CLI always works
return [
- "auto", "openrouter", "nous", "openai-codex", "xai-oauth", "copilot-acp", "copilot",
+ "auto", "openrouter", "nous", "openai-codex", "xai-oauth", "grok-build", "copilot-acp", "copilot",
"anthropic", "gemini", "google-gemini-cli", "xai", "bedrock", "azure-foundry",
"ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn",
"stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee",
diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py
index 0e74db718d936..df36e42d16fc9 100644
--- a/hermes_cli/model_normalize.py
+++ b/hermes_cli/model_normalize.py
@@ -80,6 +80,7 @@
_STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
"copilot",
"copilot-acp",
+ "grok-build",
"openai-codex",
})
@@ -470,4 +471,3 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
# ---------------------------------------------------------------------------
# Batch / convenience helpers
# ---------------------------------------------------------------------------
-
diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py
index fec1f33d09254..6851ec064f3d8 100644
--- a/hermes_cli/model_switch.py
+++ b/hermes_cli/model_switch.py
@@ -1346,14 +1346,11 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
if not has_creds:
continue
- if hermes_slug in {"openai-codex", "copilot", "copilot-acp"}:
- # Use live OAuth-backed discovery so the gateway /model picker
- # matches what the user's authenticated Codex/Copilot backend
- # actually serves — including ChatGPT-Pro-only Codex slugs
- # (e.g. gpt-5.3-codex-spark) that aren't in the static curated
- # catalog. ``provider_model_ids()`` falls back to the curated
- # list when the live endpoint is unreachable, so this is safe
- # for unauthenticated and offline cases too.
+ if hermes_slug in {"openai-codex", "grok-build", "copilot", "copilot-acp"}:
+ # Use provider-aware discovery/fallbacks so the gateway /model
+ # picker matches authenticated backends where possible, while
+ # subprocess-backed providers (ACP/Grok Build) still expose their
+ # curated local model IDs.
model_ids = provider_model_ids(hermes_slug)
# For aws_sdk providers (bedrock), use live discovery so the list
# reflects the active region (eu.*, ap.*) not the static us.* list.
diff --git a/hermes_cli/models.py b/hermes_cli/models.py
index ded3f448f87cc..2ced96f5114bd 100644
--- a/hermes_cli/models.py
+++ b/hermes_cli/models.py
@@ -201,6 +201,9 @@ def _xai_curated_models() -> list[str]:
],
"openai-codex": _codex_curated_models(),
"xai-oauth": _xai_curated_models(),
+ "grok-build": [
+ "grok-build",
+ ],
"copilot-acp": [
"copilot-acp",
],
@@ -930,6 +933,7 @@ class ProviderEntry(NamedTuple):
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
ProviderEntry("alibaba", "Qwen Cloud", "Qwen Cloud / DashScope Coding (Qwen + multi-provider)"),
ProviderEntry("xai-oauth", "xAI Grok OAuth (SuperGrok Subscription)", "xAI Grok OAuth (SuperGrok Subscription)"),
+ ProviderEntry("grok-build", "Grok Build CLI", "Grok Build CLI (uses local `grok` subscription login)"),
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models — pro, omni, flash)"),
ProviderEntry("tencent-tokenhub", "Tencent TokenHub", "Tencent TokenHub (Hy3 Preview — direct API via tokenhub.tencentmaas.com)"),
ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models — build.nvidia.com or local NIM)"),
@@ -1049,6 +1053,10 @@ class ProviderEntry(NamedTuple):
"amazon": "bedrock",
"grok": "xai",
"grok-oauth": "xai-oauth",
+ "grok-build": "grok-build",
+ "grokbuild": "grok-build",
+ "grok-cli": "grok-build",
+ "xai-build": "grok-build",
"xai-oauth": "xai-oauth",
"x-ai-oauth": "xai-oauth",
"xai-grok-oauth": "xai-oauth",
@@ -2184,6 +2192,8 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
return get_codex_model_ids(access_token=access_token)
if normalized == "xai-oauth":
return list(_PROVIDER_MODELS.get("xai-oauth", _PROVIDER_MODELS.get("xai", [])))
+ if normalized == "grok-build":
+ return list(_PROVIDER_MODELS.get("grok-build", []))
if normalized in {"copilot", "copilot-acp"}:
try:
live = _fetch_github_models(_resolve_copilot_catalog_api_key())
@@ -3463,7 +3473,7 @@ def validate_requested_model(
}
# Providers with non-standard catalog validation — /v1/models probing is not the right path.
- if normalized in {"openai-codex", "xai-oauth"}:
+ if normalized in {"openai-codex", "xai-oauth", "grok-build"}:
try:
catalog_models = provider_model_ids(normalized)
except Exception:
@@ -3490,7 +3500,7 @@ def validate_requested_model(
suggestion_text = ""
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
- provider_label = "OpenAI Codex" if normalized == "openai-codex" else "xAI Grok OAuth (SuperGrok Subscription)"
+ provider_label = _PROVIDER_LABELS.get(normalized, normalized)
return {
"accepted": True,
"persist": True,
diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py
index 9243b3f6f8492..74f30013d2120 100644
--- a/hermes_cli/providers.py
+++ b/hermes_cli/providers.py
@@ -90,6 +90,12 @@ class HermesOverlay:
base_url_override="acp://copilot",
base_url_env_var="COPILOT_ACP_BASE_URL",
),
+ "grok-build": HermesOverlay(
+ transport="openai_chat",
+ auth_type="external_process",
+ base_url_override="grok-cli://local",
+ base_url_env_var="HERMES_GROK_BUILD_BASE_URL",
+ ),
"github-copilot": HermesOverlay(
transport="openai_chat",
extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN"),
@@ -251,6 +257,10 @@ class ProviderDef:
"x.ai": "xai",
"grok": "xai",
"grok-oauth": "xai-oauth",
+ "grok-build": "grok-build",
+ "grokbuild": "grok-build",
+ "grok-cli": "grok-build",
+ "xai-build": "grok-build",
"xai-oauth": "xai-oauth",
"x-ai-oauth": "xai-oauth",
"xai-grok-oauth": "xai-oauth",
@@ -371,6 +381,7 @@ class ProviderDef:
_LABEL_OVERRIDES: Dict[str, str] = {
"nous": "Nous Portal",
"openai-codex": "OpenAI Codex",
+ "grok-build": "Grok Build CLI",
"copilot-acp": "GitHub Copilot ACP",
"stepfun": "StepFun Step Plan",
"xiaomi": "Xiaomi MiMo",
diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py
index c0baf14db924b..e42cb885fb74f 100644
--- a/hermes_cli/runtime_provider.py
+++ b/hermes_cli/runtime_provider.py
@@ -1220,6 +1220,19 @@ def resolve_runtime_provider(
"requested_provider": requested_provider,
}
+ if provider == "grok-build":
+ creds = resolve_external_process_provider_credentials(provider)
+ return {
+ "provider": "grok-build",
+ "api_mode": "chat_completions",
+ "base_url": creds.get("base_url", "").rstrip("/"),
+ "api_key": creds.get("api_key", ""),
+ "command": creds.get("command", ""),
+ "args": list(creds.get("args") or []),
+ "source": creds.get("source", "process"),
+ "requested_provider": requested_provider,
+ }
+
# Anthropic (native Messages API)
if provider == "anthropic":
# Allow base URL override from config.yaml model.base_url, but only
diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py
index 50e198b9dc7fb..4737ca86004dc 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -72,6 +72,9 @@ def _supports_same_provider_pool_setup(provider: str) -> bool:
# Default model lists per provider — used as fallback when the live
# /models endpoint can't be reached.
_DEFAULT_PROVIDER_MODELS = {
+ "grok-build": [
+ "grok-build",
+ ],
"copilot-acp": [
"copilot-acp",
],
@@ -931,6 +934,7 @@ def setup_model_provider(config: dict, *, quick: bool = False):
"nous-api": "Nous Portal API key",
"copilot": "GitHub Copilot",
"copilot-acp": "GitHub Copilot ACP",
+ "grok-build": "Grok Build CLI",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"kimi-coding-cn": "Kimi / Moonshot (China)",
diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py
index 377194589ea10..a7ce6445ead06 100644
--- a/hermes_cli/tools_config.py
+++ b/hermes_cli/tools_config.py
@@ -56,6 +56,7 @@
("browser", "🌐 Browser Automation", "navigate, click, type, scroll"),
("terminal", "💻 Terminal & Processes", "terminal, process"),
("file", "📁 File Operations", "read, write, patch, search"),
+ ("obsidian", "📝 Obsidian", "structured task reader"),
("code_execution", "⚡ Code Execution", "execute_code"),
("vision", "👁️ Vision / Image Analysis", "vision_analyze"),
("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"),
diff --git a/run_agent.py b/run_agent.py
index 85c1128d68e5f..8127a9edd14ea 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -1337,9 +1337,10 @@ def __init__(
if (
api_mode is None
and self.api_mode == "chat_completions"
- and self.provider != "copilot-acp"
+ and self.provider not in {"copilot-acp", "grok-build"}
and not str(self.base_url or "").lower().startswith("acp://copilot")
and not str(self.base_url or "").lower().startswith("acp+tcp://")
+ and not str(self.base_url or "").lower().startswith("grok-cli://")
and not self._is_azure_openai_url()
and (
self._is_direct_openai_url()
@@ -1657,7 +1658,7 @@ def __init__(
client_kwargs = {"api_key": api_key, "base_url": base_url}
if _provider_timeout is not None:
client_kwargs["timeout"] = _provider_timeout
- if self.provider == "copilot-acp":
+ if self.provider in {"copilot-acp", "grok-build"}:
client_kwargs["command"] = self.acp_command
client_kwargs["args"] = self.acp_args
effective_base = base_url
@@ -6726,6 +6727,17 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo
self._client_log_context(),
)
return client
+ if self.provider == "grok-build" or str(client_kwargs.get("base_url", "")).startswith("grok-cli://"):
+ from agent.grok_cli_client import GrokCliClient
+
+ client = GrokCliClient(**client_kwargs)
+ logger.info(
+ "Grok CLI client created (%s, shared=%s) %s",
+ reason,
+ shared,
+ self._client_log_context(),
+ )
+ return client
if self.provider == "google-gemini-cli" or str(client_kwargs.get("base_url", "")).startswith("cloudcode-pa://"):
from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient
@@ -12943,6 +12955,8 @@ def _stop_spinner():
self.provider == "copilot-acp"
or str(self.base_url or "").lower().startswith("acp://copilot")
or str(self.base_url or "").lower().startswith("acp+tcp://")
+ or self.provider == "grok-build"
+ or str(self.base_url or "").lower().startswith("grok-cli://")
):
_use_streaming = False
elif not self._has_stream_consumers():
diff --git a/tests/agent/test_grok_cli_client.py b/tests/agent/test_grok_cli_client.py
new file mode 100644
index 0000000000000..e5211c831c581
--- /dev/null
+++ b/tests/agent/test_grok_cli_client.py
@@ -0,0 +1,66 @@
+from agent.grok_cli_client import GrokCliClient
+
+
+def test_grok_cli_client_runs_single_prompt(monkeypatch):
+ calls = []
+
+ class FakeProcess:
+ returncode = 0
+
+ def communicate(self, timeout=None):
+ return "GROK_OK\n", ""
+
+ def fake_popen(cmd, **kwargs):
+ calls.append((cmd, kwargs))
+ return FakeProcess()
+
+ monkeypatch.setattr("agent.grok_cli_client.subprocess.Popen", fake_popen)
+
+ client = GrokCliClient(
+ command="/usr/local/bin/grok",
+ args=["--no-memory", "--output-format", "plain"],
+ )
+ response = client.chat.completions.create(
+ model="grok-build",
+ messages=[{"role": "user", "content": "Reply with GROK_OK"}],
+ timeout=5,
+ )
+
+ assert response.choices[0].message.content == "GROK_OK"
+ cmd, kwargs = calls[0]
+ assert cmd[0] == "/usr/local/bin/grok"
+ assert "--prompt-file" in cmd
+ assert "--model" in cmd
+ assert "grok-build" in cmd
+ assert kwargs["stdout"] is not None
+ assert kwargs["stderr"] is not None
+
+
+def test_grok_cli_client_preserves_tool_calls(monkeypatch):
+ raw = (
+ '{"id":"call_1","type":"function",'
+ '"function":{"name":"terminal","arguments":"{\\"cmd\\":\\"pwd\\"}"}}'
+ ""
+ )
+
+ class FakeProcess:
+ returncode = 0
+
+ def communicate(self, timeout=None):
+ return raw, ""
+
+ monkeypatch.setattr(
+ "agent.grok_cli_client.subprocess.Popen",
+ lambda *args, **kwargs: FakeProcess(),
+ )
+
+ client = GrokCliClient(command="/usr/local/bin/grok", args=["--no-memory"])
+ response = client.chat.completions.create(
+ model="grok-build",
+ messages=[{"role": "user", "content": "Use terminal"}],
+ )
+
+ message = response.choices[0].message
+ assert response.choices[0].finish_reason == "tool_calls"
+ assert message.content == ""
+ assert message.tool_calls[0].function.name == "terminal"
diff --git a/tests/agent/test_model_metadata_local_ctx.py b/tests/agent/test_model_metadata_local_ctx.py
index f449255c0736b..ce5340f35cea0 100644
--- a/tests/agent/test_model_metadata_local_ctx.py
+++ b/tests/agent/test_model_metadata_local_ctx.py
@@ -519,6 +519,35 @@ def test_connection_error_returns_none(self):
class TestGetModelContextLengthLocalFallback:
"""get_model_context_length uses local server query before falling back to 2M."""
+ def test_grok_build_cli_uses_explicit_context_default(self):
+ """grok-cli://local is a CLI marker, not a custom HTTP endpoint."""
+ from agent.model_metadata import get_model_context_length
+
+ with patch("agent.model_metadata.get_cached_context_length") as mock_cache, \
+ patch("agent.model_metadata._resolve_endpoint_context_length") as mock_endpoint:
+ result = get_model_context_length(
+ "grok-build",
+ base_url="grok-cli://local",
+ provider="grok-build",
+ )
+
+ assert result == 512000
+ mock_cache.assert_not_called()
+ mock_endpoint.assert_not_called()
+
+ def test_grok_build_context_config_override_wins(self):
+ """Explicit user config still beats the provider default."""
+ from agent.model_metadata import get_model_context_length
+
+ result = get_model_context_length(
+ "grok-build",
+ base_url="grok-cli://local",
+ provider="grok-build",
+ config_context_length=512000,
+ )
+
+ assert result == 512000
+
def test_local_endpoint_unknown_model_queries_server(self):
"""Unknown model on local endpoint gets ctx from server, not 2M default."""
from agent.model_metadata import get_model_context_length
diff --git a/tests/agent/transports/test_hermes_tools_mcp_server.py b/tests/agent/transports/test_hermes_tools_mcp_server.py
index 3c11cb3f81dd7..b998d66496493 100644
--- a/tests/agent/transports/test_hermes_tools_mcp_server.py
+++ b/tests/agent/transports/test_hermes_tools_mcp_server.py
@@ -48,6 +48,7 @@ def test_expected_hermes_specific_tools_listed(self):
"vision_analyze",
"image_generate",
"skill_view",
+ "obsidian_read_tasks",
):
assert required in EXPOSED_TOOLS, f"missing {required!r}"
diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py
index 81859230ab792..f78691b454ab1 100644
--- a/tests/hermes_cli/test_api_key_providers.py
+++ b/tests/hermes_cli/test_api_key_providers.py
@@ -31,6 +31,7 @@ class TestProviderRegistry:
@pytest.mark.parametrize("provider_id,name,auth_type", [
("copilot-acp", "GitHub Copilot ACP", "external_process"),
+ ("grok-build", "Grok Build CLI", "external_process"),
("copilot", "GitHub Copilot", "api_key"),
("huggingface", "Hugging Face", "api_key"),
("zai", "Z.AI / GLM", "api_key"),
@@ -120,6 +121,7 @@ def test_huggingface_env_vars(self):
def test_base_urls(self):
assert PROVIDER_REGISTRY["copilot"].inference_base_url == "https://api.githubcopilot.com"
assert PROVIDER_REGISTRY["copilot-acp"].inference_base_url == "acp://copilot"
+ assert PROVIDER_REGISTRY["grok-build"].inference_base_url == "grok-cli://local"
assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4"
assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1"
assert PROVIDER_REGISTRY["stepfun"].inference_base_url == STEPFUN_STEP_PLAN_INTL_BASE_URL
@@ -393,6 +395,21 @@ def test_get_auth_status_dispatches_to_external_process(self, monkeypatch):
assert status["configured"] is True
assert status["provider"] == "copilot-acp"
+ def test_grok_build_status_detects_local_cli(self, monkeypatch):
+ monkeypatch.setenv("HERMES_GROK_BUILD_COMMAND", "grok")
+ monkeypatch.setenv("HERMES_GROK_BUILD_ARGS", "--no-memory --max-turns 1")
+ monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/opt/bin/{command}")
+
+ status = get_external_process_provider_status("grok-build")
+
+ assert status["configured"] is True
+ assert status["logged_in"] is True
+ assert status["provider"] == "grok-build"
+ assert status["command"] == "grok"
+ assert status["resolved_command"].endswith("/grok")
+ assert status["args"] == ["--no-memory", "--max-turns", "1"]
+ assert status["base_url"] == "grok-cli://local"
+
def test_non_api_key_provider(self):
status = get_api_key_provider_status("nous")
assert status["configured"] is False
@@ -491,6 +508,20 @@ def test_resolve_copilot_acp_with_local_cli(self, monkeypatch):
assert creds["args"] == ["--acp", "--stdio"]
assert creds["source"] == "process"
+ def test_resolve_grok_build_with_local_cli(self, monkeypatch):
+ monkeypatch.setenv("HERMES_GROK_BUILD_COMMAND", "grok")
+ monkeypatch.setenv("HERMES_GROK_BUILD_ARGS", "--no-memory --disable-web-search")
+ monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
+
+ creds = resolve_external_process_provider_credentials("grok-build")
+
+ assert creds["provider"] == "grok-build"
+ assert creds["api_key"] == "grok-build"
+ assert creds["base_url"] == "grok-cli://local"
+ assert creds["command"] == "/usr/local/bin/grok"
+ assert creds["args"] == ["--no-memory", "--disable-web-search"]
+ assert creds["source"] == "process"
+
def test_resolve_kimi_with_key(self, monkeypatch):
monkeypatch.setenv("KIMI_API_KEY", "kimi-secret-key")
creds = resolve_api_key_provider_credentials("kimi-coding")
@@ -712,6 +743,22 @@ def test_runtime_copilot_acp_uses_process_runtime(self, monkeypatch):
assert result["command"] == "/usr/local/bin/copilot"
assert result["args"] == ["--acp", "--stdio", "--debug"]
+ def test_runtime_grok_build_uses_process_runtime(self, monkeypatch):
+ monkeypatch.setenv("HERMES_GROK_BUILD_COMMAND", "grok")
+ monkeypatch.setenv("HERMES_GROK_BUILD_ARGS", "--no-memory --max-turns 1")
+ monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
+
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+
+ result = resolve_runtime_provider(requested="grok-build")
+
+ assert result["provider"] == "grok-build"
+ assert result["api_mode"] == "chat_completions"
+ assert result["api_key"] == "grok-build"
+ assert result["base_url"] == "grok-cli://local"
+ assert result["command"] == "/usr/local/bin/grok"
+ assert result["args"] == ["--no-memory", "--max-turns", "1"]
+
# =============================================================================
# _has_any_provider_configured tests
diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py
index 03c0fcca3d47a..f94d59cd40cb4 100644
--- a/tests/hermes_cli/test_model_validation.py
+++ b/tests/hermes_cli/test_model_validation.py
@@ -163,6 +163,7 @@ def test_known_aliases(self):
assert normalize_provider("moonshot") == "kimi-coding"
assert normalize_provider("step") == "stepfun"
assert normalize_provider("github-copilot") == "copilot"
+ assert normalize_provider("grok-cli") == "grok-build"
def test_case_insensitive(self):
assert normalize_provider("OpenRouter") == "openrouter"
@@ -175,6 +176,7 @@ def test_known_labels_and_auto(self):
assert provider_label("stepfun") == "StepFun Step Plan"
assert provider_label("copilot") == "GitHub Copilot"
assert provider_label("copilot-acp") == "GitHub Copilot ACP"
+ assert provider_label("grok-build") == "Grok Build CLI"
assert provider_label("auto") == "Auto"
def test_unknown_provider_preserves_original_name(self):
@@ -222,6 +224,9 @@ def test_copilot_acp_reuses_copilot_catalog(self):
patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):
assert provider_model_ids("copilot-acp") == ["gpt-5.4", "claude-sonnet-4.6"]
+ def test_grok_build_uses_static_model(self):
+ assert provider_model_ids("grok-build") == ["grok-build"]
+
def test_copilot_falls_back_to_curated_defaults_without_stale_opus(self):
with patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
patch("hermes_cli.models._fetch_github_models", return_value=None):
diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py
index c493f91509a7e..70fe5d511a92b 100644
--- a/tests/run_agent/test_run_agent.py
+++ b/tests/run_agent/test_run_agent.py
@@ -4324,6 +4324,36 @@ def test_aiagent_uses_copilot_acp_client():
assert mock_acp_client.call_args.kwargs["args"] == ["--acp", "--stdio"]
+def test_aiagent_uses_grok_cli_client():
+ with (
+ patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
+ patch("run_agent.check_toolset_requirements", return_value={}),
+ patch("run_agent.OpenAI") as mock_openai,
+ patch("agent.grok_cli_client.GrokCliClient") as mock_grok_client,
+ ):
+ grok_client = MagicMock()
+ mock_grok_client.return_value = grok_client
+
+ agent = AIAgent(
+ api_key="grok-build",
+ base_url="grok-cli://local",
+ provider="grok-build",
+ command="/usr/local/bin/grok",
+ args=["--no-memory", "--max-turns", "1"],
+ quiet_mode=True,
+ skip_context_files=True,
+ skip_memory=True,
+ )
+
+ assert agent.client is grok_client
+ mock_openai.assert_not_called()
+ mock_grok_client.assert_called_once()
+ assert mock_grok_client.call_args.kwargs["base_url"] == "grok-cli://local"
+ assert mock_grok_client.call_args.kwargs["api_key"] == "grok-build"
+ assert mock_grok_client.call_args.kwargs["command"] == "/usr/local/bin/grok"
+ assert mock_grok_client.call_args.kwargs["args"] == ["--no-memory", "--max-turns", "1"]
+
+
def test_quiet_spinner_allowed_with_explicit_print_fn(agent):
agent._print_fn = lambda *_a, **_kw: None
with patch.object(run_agent.sys.stdout, "isatty", return_value=False):
diff --git a/tests/tools/test_obsidian_tools.py b/tests/tools/test_obsidian_tools.py
new file mode 100644
index 0000000000000..180a76a9f8e8c
--- /dev/null
+++ b/tests/tools/test_obsidian_tools.py
@@ -0,0 +1,62 @@
+import json
+
+from tools.obsidian_tools import obsidian_read_tasks_tool
+
+
+def test_obsidian_read_tasks_returns_only_active_tasks_by_default(tmp_path, monkeypatch):
+ vault = tmp_path / "vault"
+ vault.mkdir()
+ (vault / "Tasks.md").write_text(
+ "# Inbox\n"
+ "- [ ] Pay bill 📅 2026-05-20\n"
+ "- [/] Call contractor\n"
+ "- [x] Old done\n"
+ "# Home\n"
+ "- [-] Cancelled thing\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("OBSIDIAN_VAULT_PATH", str(vault))
+
+ result = json.loads(obsidian_read_tasks_tool(path="Tasks.md", include_done=False, limit=10))
+
+ assert result["path"] == "Tasks.md"
+ assert result["total_tasks"] == 4
+ assert result["active_tasks"] == 2
+ assert result["matched_tasks"] == 2
+ assert result["returned_tasks"] == 2
+ assert result["status_counts"] == {"open": 1, "active": 1, "done": 1, "cancelled": 1}
+ assert [task["status"] for task in result["tasks"]] == ["open", "active"]
+ assert result["tasks"][0]["line"] == 2
+ assert result["tasks"][0]["section"] == "Inbox"
+ assert result["tasks"][0]["due"] == "2026-05-20"
+
+
+def test_obsidian_read_tasks_include_done_and_limit(tmp_path, monkeypatch):
+ vault = tmp_path / "vault"
+ vault.mkdir()
+ (vault / "Tasks.md").write_text(
+ "- [ ] One\n"
+ "- [x] Two\n"
+ "- [-] Three\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("OBSIDIAN_VAULT_PATH", str(vault))
+
+ result = json.loads(obsidian_read_tasks_tool(include_done=True, limit=2))
+
+ assert result["matched_tasks"] == 3
+ assert result["returned_tasks"] == 2
+ assert result["truncated"] is True
+ assert [task["text"] for task in result["tasks"]] == ["One", "Two"]
+
+
+def test_obsidian_read_tasks_rejects_paths_outside_vault(tmp_path, monkeypatch):
+ vault = tmp_path / "vault"
+ vault.mkdir()
+ monkeypatch.setenv("OBSIDIAN_VAULT_PATH", str(vault))
+
+ result = json.loads(obsidian_read_tasks_tool(path="../outside.md"))
+
+ assert "error" in result
+ assert "inside Obsidian vault" in result["error"]
+ assert result["tasks"] == []
diff --git a/tools/obsidian_tools.py b/tools/obsidian_tools.py
new file mode 100644
index 0000000000000..8577ea3401e5f
--- /dev/null
+++ b/tools/obsidian_tools.py
@@ -0,0 +1,300 @@
+#!/usr/bin/env python3
+"""Obsidian tools.
+
+Structured task reads for Markdown task lists in an Obsidian vault. This is
+intentionally read-only: agents should use it instead of raw-reading Tasks.md
+when they need a compact, line-addressable view of the user's task list.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+from collections import OrderedDict
+from pathlib import Path
+from typing import Any
+
+from hermes_constants import get_default_hermes_root
+from tools.registry import registry
+
+_TASK_RE = re.compile(r"^(?P\s*)[-*+]\s+\[(?P.)\]\s*(?P.*)$")
+_HEADING_RE = re.compile(r"^(?P#{1,6})\s+(?P.+?)\s*$")
+_ISO_DATE_RE = re.compile(r"(?": "active",
+ "x": "done",
+ "X": "done",
+ "-": "cancelled",
+ "~": "cancelled",
+ "_": "cancelled",
+}
+
+_STATUSES = ("open", "active", "done", "cancelled")
+
+
+def check_obsidian_requirements() -> bool:
+ """The reader uses only the local filesystem; no external dependency."""
+ return True
+
+
+def _load_obsidian_config_path() -> str | None:
+ """Best-effort config lookup without making the tool import fragile."""
+ try:
+ from hermes_cli.config import load_config
+
+ cfg = load_config() or {}
+ obsidian_cfg = cfg.get("obsidian") or {}
+ if isinstance(obsidian_cfg, dict):
+ value = obsidian_cfg.get("vault_path") or obsidian_cfg.get("path")
+ if value:
+ return str(value)
+ except Exception:
+ return None
+ return None
+
+
+def _candidate_vault_paths() -> list[Path]:
+ candidates: list[Path] = []
+
+ env_path = os.environ.get("OBSIDIAN_VAULT_PATH", "").strip()
+ if env_path:
+ candidates.append(Path(env_path).expanduser())
+
+ cfg_path = _load_obsidian_config_path()
+ if cfg_path:
+ candidates.append(Path(cfg_path).expanduser())
+
+ # In Hermes profile mode HOME may be profile-scoped
+ # (~/.hermes/profiles//home). The shared Hermes root still lets us
+ # recover the real OS home in standard installs: /home/user/.hermes ->
+ # /home/user.
+ try:
+ root = get_default_hermes_root()
+ if root.name == ".hermes":
+ candidates.append(root.parent / "Documents" / "Obsidian Vault")
+ except Exception:
+ pass
+
+ candidates.append(Path.home() / "Documents" / "Obsidian Vault")
+
+ # Preserve order while dropping duplicates.
+ deduped: list[Path] = []
+ seen: set[str] = set()
+ for candidate in candidates:
+ key = str(candidate)
+ if key not in seen:
+ seen.add(key)
+ deduped.append(candidate)
+ return deduped
+
+
+def _resolve_vault() -> Path:
+ candidates = _candidate_vault_paths()
+ for candidate in candidates:
+ if candidate.exists() and candidate.is_dir():
+ return candidate.resolve()
+ # Return the highest-precedence candidate for a useful error message.
+ if candidates:
+ return candidates[0].resolve()
+ return (Path.home() / "Documents" / "Obsidian Vault").resolve()
+
+
+def _safe_note_path(vault: Path, path: str) -> Path:
+ requested = (path or "Tasks.md").strip() or "Tasks.md"
+ raw = Path(requested).expanduser()
+ if raw.is_absolute():
+ candidate = raw.resolve()
+ else:
+ candidate = (vault / raw).resolve()
+
+ try:
+ candidate.relative_to(vault)
+ except ValueError:
+ raise ValueError(f"path must stay inside Obsidian vault: {requested}")
+ return candidate
+
+
+def _status_for_marker(marker: str) -> str:
+ return _MARKER_STATUS.get(marker, "open")
+
+
+def _extract_due(text: str) -> str | None:
+ """Extract a likely due date from common Obsidian Tasks syntax."""
+ match = _ISO_DATE_RE.search(text)
+ if not match:
+ return None
+ # Prefer dates near a due marker, but fall back to the first ISO date so
+ # historical task notes remain useful rather than returning nothing.
+ lower = text.lower()
+ if any(marker in text or marker in lower for marker in _DUE_MARKERS):
+ return match.group(1)
+ return match.group(1)
+
+
+def _new_counts() -> dict[str, int]:
+ return {status: 0 for status in _STATUSES}
+
+
+def _parse_tasks(content: str, *, include_done: bool) -> tuple[list[dict[str, Any]], dict[str, int], OrderedDict[str, dict[str, int]], int]:
+ tasks: list[dict[str, Any]] = []
+ status_counts = _new_counts()
+ section_counts: OrderedDict[str, dict[str, int]] = OrderedDict()
+ current_section = "(top)"
+ current_level = 0
+ total_tasks = 0
+
+ for line_no, line in enumerate(content.splitlines(), start=1):
+ heading = _HEADING_RE.match(line)
+ if heading:
+ current_level = len(heading.group("hashes"))
+ current_section = heading.group("title").strip()
+ section_counts.setdefault(current_section, _new_counts())
+ continue
+
+ match = _TASK_RE.match(line)
+ if not match:
+ continue
+
+ total_tasks += 1
+ marker = match.group("marker")
+ text = match.group("text").strip()
+ status = _status_for_marker(marker)
+ if status not in status_counts:
+ status = "open"
+ status_counts[status] += 1
+ section_counts.setdefault(current_section, _new_counts())[status] += 1
+
+ if not include_done and status in {"done", "cancelled"}:
+ continue
+
+ tasks.append(
+ {
+ "line": line_no,
+ "section": current_section,
+ "heading_level": current_level,
+ "status": status,
+ "marker": marker,
+ "text": text,
+ "due": _extract_due(text),
+ }
+ )
+
+ return tasks, status_counts, section_counts, total_tasks
+
+
+def obsidian_read_tasks_tool(path: str = "Tasks.md", include_done: bool = False, limit: int = 50) -> str:
+ """Read Markdown tasks from an Obsidian note as structured JSON."""
+ try:
+ vault = _resolve_vault()
+ note_path = _safe_note_path(vault, path)
+ if not vault.exists():
+ return json.dumps(
+ {
+ "error": f"Obsidian vault not found: {vault}",
+ "path": path or "Tasks.md",
+ "tasks": [],
+ },
+ ensure_ascii=False,
+ )
+ if not note_path.exists() or not note_path.is_file():
+ rel = str(note_path.relative_to(vault)) if note_path.is_relative_to(vault) else str(note_path)
+ return json.dumps(
+ {
+ "error": f"Obsidian note not found: {rel}",
+ "path": rel,
+ "tasks": [],
+ },
+ ensure_ascii=False,
+ )
+
+ content = note_path.read_text(encoding="utf-8")
+ tasks, status_counts, section_counts, total_tasks = _parse_tasks(content, include_done=bool(include_done))
+
+ try:
+ max_items = int(limit)
+ except (TypeError, ValueError):
+ max_items = 50
+ if max_items < 0:
+ max_items = 0
+
+ returned = tasks[:max_items]
+ rel_path = str(note_path.relative_to(vault))
+ active_tasks = status_counts["open"] + status_counts["active"]
+ matched_tasks = len(tasks)
+ result = {
+ "path": rel_path,
+ "include_done": bool(include_done),
+ "limit": max_items,
+ "total_tasks": total_tasks,
+ "active_tasks": active_tasks,
+ "matched_tasks": matched_tasks,
+ "returned_tasks": len(returned),
+ "truncated": matched_tasks > len(returned),
+ "status_counts": status_counts,
+ "section_counts": [
+ {"section": section, **counts}
+ for section, counts in section_counts.items()
+ if any(counts.values())
+ ],
+ "summary": (
+ f"{len(returned)} of {matched_tasks} matching tasks returned from {rel_path}; "
+ f"{active_tasks} active/open tasks, {status_counts['done']} done, "
+ f"{status_counts['cancelled']} cancelled."
+ ),
+ "tasks": returned,
+ }
+ return json.dumps(result, ensure_ascii=False)
+ except Exception as exc:
+ return json.dumps({"error": str(exc), "path": path or "Tasks.md", "tasks": []}, ensure_ascii=False)
+
+
+OBSIDIAN_READ_TASKS_SCHEMA = {
+ "name": "obsidian_read_tasks",
+ "description": (
+ "Read Markdown tasks from an Obsidian note as structured JSON. Use this "
+ "instead of raw-reading Tasks.md when you need Charlie's task list. By "
+ "default it returns only active/open tasks, grouped with section and line metadata."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Vault-relative note path to parse. Defaults to Tasks.md.",
+ "default": "Tasks.md",
+ },
+ "include_done": {
+ "type": "boolean",
+ "description": "Include done and cancelled tasks in the returned tasks array.",
+ "default": False,
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Maximum number of matching tasks to return.",
+ "default": 50,
+ "minimum": 0,
+ },
+ },
+ "required": [],
+ },
+}
+
+
+registry.register(
+ name="obsidian_read_tasks",
+ toolset="obsidian",
+ schema=OBSIDIAN_READ_TASKS_SCHEMA,
+ handler=lambda args, **kw: obsidian_read_tasks_tool(
+ path=args.get("path", "Tasks.md"),
+ include_done=args.get("include_done", False),
+ limit=args.get("limit", 50),
+ ),
+ check_fn=check_obsidian_requirements,
+ emoji="📝",
+)
diff --git a/toolsets.py b/toolsets.py
index 8ec45f11a2fb7..ebfe10de8003e 100644
--- a/toolsets.py
+++ b/toolsets.py
@@ -35,6 +35,8 @@
"terminal", "process",
# File manipulation
"read_file", "write_file", "patch", "search_files",
+ # Obsidian structured note/task readers
+ "obsidian_read_tasks",
# Vision + image generation
"vision_analyze", "image_generate",
# Skills
@@ -176,6 +178,12 @@
"tools": ["read_file", "write_file", "patch", "search_files"],
"includes": []
},
+
+ "obsidian": {
+ "description": "Obsidian vault tools: structured task reads from Markdown notes",
+ "tools": ["obsidian_read_tasks"],
+ "includes": []
+ },
"tts": {
"description": "Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, OpenAI, or xAI",
diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md
index 80eaf3589ca23..75dbcc822c69c 100644
--- a/website/docs/getting-started/quickstart.md
+++ b/website/docs/getting-started/quickstart.md
@@ -114,6 +114,7 @@ Good defaults:
| **NVIDIA NIM** | Nemotron models via build.nvidia.com or local NIM | Set `NVIDIA_API_KEY` (optional: `NVIDIA_BASE_URL`) |
| **GitHub Copilot** | GitHub Copilot subscription (GPT-5.x, Claude, Gemini, etc.) | OAuth via `hermes model`, or `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` |
| **GitHub Copilot ACP** | Copilot ACP agent backend (spawns local `copilot` CLI) | `hermes model` (requires `copilot` CLI + `copilot login`) |
+| **Grok Build CLI** | Grok Build subscription backend (spawns local `grok` CLI) | `hermes model` (requires Grok CLI + `grok login`) |
| **Vercel AI Gateway** | Vercel AI Gateway routing | Set `AI_GATEWAY_API_KEY` |
| **Custom Endpoint** | VLLM, SGLang, Ollama, or any OpenAI-compatible API | Set base URL + API key |
diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md
index 248d17c5fac7d..7810793055217 100644
--- a/website/docs/integrations/providers.md
+++ b/website/docs/integrations/providers.md
@@ -18,6 +18,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
| **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) |
| **GitHub Copilot** | `hermes model` (OAuth device code flow, `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token`) |
| **GitHub Copilot ACP** | `hermes model` (spawns local `copilot --acp --stdio`) |
+| **Grok Build CLI** | `hermes model` (spawns local `grok` CLI; requires an existing Grok Build login) |
| **Anthropic** | `hermes model` (Claude Max + extra usage credits via OAuth; also supports Anthropic API key or manual setup-token — see note below) |
| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` |
| **NovitaAI** | `NOVITA_API_KEY` in `~/.hermes/.env` (provider: `novita`, 200+ models, Model API, Agent Sandbox, GPU Cloud) |
@@ -250,18 +251,37 @@ hermes chat --provider copilot-acp --model copilot-acp
# Requires the GitHub Copilot CLI in PATH and an existing `copilot login` session
```
-**Permanent config:**
+**`grok-build` — Grok Build CLI backend**. Spawns the local Grok CLI in headless mode and sends prompts via a temporary prompt file:
+
+```bash
+hermes chat --provider grok-build --model grok-build
+# Requires the Grok CLI in PATH or ~/.grok/bin and an existing `grok login` session
+```
+
+Hermes auto-detects `~/.grok/bin/grok`, `~/.local/bin/grok`, or `grok` in `PATH`. Override the command with `HERMES_GROK_BUILD_COMMAND` or `GROK_CLI_PATH`. By default Hermes runs Grok Build with `--no-memory --disable-web-search --max-turns 1 --output-format plain --effort xhigh`; override with `HERMES_GROK_BUILD_ARGS` or just the effort with `HERMES_GROK_BUILD_EFFORT`.
+
+**Copilot permanent config:**
```yaml
model:
provider: "copilot"
default: "gpt-5.4"
```
+**Grok Build permanent config:**
+```yaml
+model:
+ provider: "grok-build"
+ default: "grok-build"
+```
+
| Environment variable | Description |
|---------------------|-------------|
| `COPILOT_GITHUB_TOKEN` | GitHub token for Copilot API (first priority) |
| `HERMES_COPILOT_ACP_COMMAND` | Override the Copilot CLI binary path (default: `copilot`) |
| `HERMES_COPILOT_ACP_ARGS` | Override ACP args (default: `--acp --stdio`) |
+| `HERMES_GROK_BUILD_COMMAND` / `GROK_CLI_PATH` | Override the Grok CLI binary path |
+| `HERMES_GROK_BUILD_ARGS` | Override Grok CLI args for the `grok-build` provider |
+| `HERMES_GROK_BUILD_EFFORT` | Override the default Grok Build effort when args are not set |
### First-Class API-Key Providers
@@ -1446,7 +1466,7 @@ fallback_model:
When activated, the fallback swaps the model and provider mid-session without losing your conversation. The chain is tried entry-by-entry; activation is one-shot per session.
-Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `ai-gateway`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`.
+Supported providers: `openrouter`, `nous`, `openai-codex`, `grok-build`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `ai-gateway`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`.
:::tip
Fallback is configured exclusively through `config.yaml` — or interactively via `hermes fallback`. For full details on when it triggers, how the chain advances, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/docs/user-guide/features/fallback-providers).
diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md
index 3b5b7d2e925a2..c289e72c5ba6b 100644
--- a/website/docs/reference/cli-commands.md
+++ b/website/docs/reference/cli-commands.md
@@ -92,7 +92,7 @@ Common options:
| `-q`, `--query "..."` | One-shot, non-interactive prompt. |
| `-m`, `--model ` | Override the model for this run. |
| `-t`, `--toolsets ` | Enable a comma-separated set of toolsets. |
-| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `novita`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |
+| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `grok-build`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `novita`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |
| `-s`, `--skills ` | Preload one or more skills for the session (can be repeated or comma-separated). |
| `-v`, `--verbose` | Verbose output. |
| `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. |
diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md
index 56fe8a1371512..ab729a7fbd4b4 100644
--- a/website/docs/reference/environment-variables.md
+++ b/website/docs/reference/environment-variables.md
@@ -105,7 +105,10 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| Variable | Description |
|----------|-------------|
-| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `novita`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (browser OAuth login for SuperGrok subscribers — no API key required; see [xAI Grok OAuth guide](../guides/xai-grok-oauth.md)), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `tencent-tokenhub` (default: `auto`) |
+| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `grok-build`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `novita`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (browser OAuth login for SuperGrok subscribers — no API key required; see [xAI Grok OAuth guide](../guides/xai-grok-oauth.md)), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `tencent-tokenhub` (default: `auto`) |
+| `HERMES_GROK_BUILD_COMMAND` / `GROK_CLI_PATH` | Override the `grok` executable used by the `grok-build` provider. Defaults to `~/.grok/bin/grok`, `~/.local/bin/grok`, then `grok` in `PATH`. |
+| `HERMES_GROK_BUILD_ARGS` | Override Grok Build CLI arguments. Defaults to `--no-memory --disable-web-search --max-turns 1 --output-format plain --effort xhigh`. |
+| `HERMES_GROK_BUILD_EFFORT` | Override only the default Grok Build effort (`low`, `medium`, `high`, `xhigh`, or `max`) when `HERMES_GROK_BUILD_ARGS` is not set. |
| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) |
| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL |
| `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) |
diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md
index 72528796d5574..6067f3825b1e5 100644
--- a/website/docs/user-guide/features/fallback-providers.md
+++ b/website/docs/user-guide/features/fallback-providers.md
@@ -53,6 +53,7 @@ Both `provider` and `model` are **required**. If either is missing, the fallback
| OpenAI Codex | `openai-codex` | `hermes model` (ChatGPT OAuth) |
| GitHub Copilot | `copilot` | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `GITHUB_TOKEN` |
| GitHub Copilot ACP | `copilot-acp` | External process (editor integration) |
+| Grok Build CLI | `grok-build` | External process (`grok` CLI + Grok Build login) |
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` or Claude Code credentials |
| z.ai / GLM | `zai` | `GLM_API_KEY` |
| Kimi / Moonshot | `kimi-coding` | `KIMI_API_KEY` |