diff --git a/src/reverse_api/auto_engineer.py b/src/reverse_api/auto_engineer.py
index 35fed9f..30ad314 100644
--- a/src/reverse_api/auto_engineer.py
+++ b/src/reverse_api/auto_engineer.py
@@ -65,6 +65,7 @@ class AutoPromptEngineer(Protocol):
agent_provider: str
mcp_run_id: str
headless: bool
+ start_url: str | None
prompt: str
scripts_dir: Path
har_path: Path
@@ -91,6 +92,7 @@ def __init__(
# `headless` is auto-engineer specific: for MCP providers it configures the MCP
# server's browser launch; for `agent-browser` it only adjusts prompt wording.
headless = kwargs.pop("headless", False)
+ start_url = kwargs.pop("start_url", None)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"
@@ -105,6 +107,7 @@ def __init__(
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.headless = headless
+ self.start_url = start_url
def _build_auto_prompts(self: AutoPromptEngineer) -> tuple[str, str]:
"""Build (system_prompt, user_message) for auto mode.
@@ -145,6 +148,7 @@ def _build_auto_prompts(self: AutoPromptEngineer) -> tuple[str, str]:
template_kwargs = {
"prompt": self.prompt,
"scripts_dir": str(self.scripts_dir),
+ "start_url_block": "\n" + load("auto/start_url", start_url=self.start_url) if self.start_url else "",
}
if self.agent_provider != "chrome-mcp":
template_kwargs["har_path"] = str(self.har_path)
@@ -367,6 +371,7 @@ class OpenCodeAutoEngineer(OpenCodeEngineer):
def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agent_provider: str = "auto", **kwargs: Any) -> None:
"""Initialize OpenCode-backed agent engineer."""
headless = kwargs.pop("headless", False)
+ start_url = kwargs.pop("start_url", None)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"
@@ -381,6 +386,7 @@ def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agen
self.agent_provider = agent_provider
self.mcp_name: str | None = None
self.headless = headless
+ self.start_url = start_url
def _get_active_prompts(self) -> tuple[str, str]:
return ClaudeAutoEngineer._build_auto_prompts(self)
@@ -618,6 +624,7 @@ def __init__(
from .copilot_engineer import CopilotEngineer
headless = kwargs.pop("headless", False)
+ start_url = kwargs.pop("start_url", None)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"
@@ -632,6 +639,7 @@ def __init__(
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.headless = headless
+ self.start_url = start_url
def start_sync(self) -> None:
self._engineer.start_sync()
@@ -658,6 +666,7 @@ async def analyze_and_generate(self) -> dict[str, Any] | None:
eng.agent_provider = self.agent_provider
eng.mcp_run_id = self.mcp_run_id
eng.headless = self.headless
+ eng.start_url = self.start_url
if self.agent_provider == "agent-browser":
ab_setup = ensure_agent_browser_runtime()
diff --git a/src/reverse_api/browser.py b/src/reverse_api/browser.py
index 7df1d8e..1249c06 100644
--- a/src/reverse_api/browser.py
+++ b/src/reverse_api/browser.py
@@ -15,7 +15,7 @@
from rich.console import Console
from rich.status import Status
-from .utils import get_har_dir, get_timestamp
+from .utils import get_har_dir, get_timestamp, normalize_url
console = Console()
@@ -233,15 +233,6 @@ def _inject_stealth(self, page: Page) -> None:
"""Inject stealth scripts into page before any other scripts run."""
page.add_init_script(STEALTH_JS)
- @staticmethod
- def _normalize_url(url: str) -> str:
- """Prepend https:// when the user typed a bare host (e.g.
- ``jobs.example.com/x``); Playwright's goto rejects scheme-less URLs."""
- url = url.strip()
- if url and "://" not in url:
- return "https://" + url
- return url
-
def _abort_playwright(self) -> None:
"""Quietly tear down the browser/Playwright after a failed start.
@@ -440,7 +431,7 @@ def start(self, start_url: str | None = None) -> Path:
console.print()
if start_url:
- start_url = self._normalize_url(start_url)
+ start_url = normalize_url(start_url)
self._playwright = sync_playwright().start()
diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py
index 575318a..174098c 100644
--- a/src/reverse_api/cli.py
+++ b/src/reverse_api/cli.py
@@ -41,6 +41,7 @@
get_history_path,
get_messages_path,
get_timestamp,
+ normalize_url,
resolve_run,
)
@@ -1222,6 +1223,9 @@ def agent(
Agent mode runs an integrated capture + reverse-engineering pipeline.
"""
+ if url:
+ url = normalize_url(url)
+
if dry_run:
# --dry-run is fundamentally about emitting machine-parseable validation
# results, so it implies --json (and therefore --no-interactive).
@@ -1539,6 +1543,9 @@ def run_auto_capture(
if not prompt:
raise click.UsageError("a prompt is required")
+ if url:
+ url = normalize_url(url)
+
if agent_provider == "chrome-mcp" and not headless:
from .chrome_mcp import check_auto_connect
@@ -1636,6 +1643,7 @@ def run_auto_capture(
output_language=output_language,
interactive=interactive,
headless=headless,
+ start_url=url,
)
elif sdk == "copilot":
from .auto_engineer import CopilotAutoEngineer
@@ -1651,6 +1659,7 @@ def run_auto_capture(
output_language=output_language,
interactive=interactive,
headless=headless,
+ start_url=url,
)
elif sdk == "cursor":
from .cursor_engineer import CursorAutoEngineer
@@ -1667,6 +1676,7 @@ def run_auto_capture(
output_language=output_language,
interactive=interactive,
headless=headless,
+ start_url=url,
cursor_model=model or config_manager.get("cursor_model", "composer-2.5"),
cursor_web_search=bool(config_manager.get("cursor_web_search", True)),
cursor_setting_sources=_cursor_src,
@@ -1685,6 +1695,7 @@ def run_auto_capture(
output_language=output_language,
interactive=interactive,
headless=headless,
+ start_url=url,
)
# CopilotAutoEngineer wraps the CopilotEngineer that owns the session.
diff --git a/src/reverse_api/copilot_engineer.py b/src/reverse_api/copilot_engineer.py
index 7ba5f40..7f10a57 100644
--- a/src/reverse_api/copilot_engineer.py
+++ b/src/reverse_api/copilot_engineer.py
@@ -28,6 +28,7 @@ class CopilotEngineer(BaseEngineer):
agent_provider: str
mcp_run_id: str
headless: bool
+ start_url: str | None
def __init__(
self,
diff --git a/src/reverse_api/cursor_engineer.py b/src/reverse_api/cursor_engineer.py
index 6ea6fbf..a6829d5 100644
--- a/src/reverse_api/cursor_engineer.py
+++ b/src/reverse_api/cursor_engineer.py
@@ -497,6 +497,7 @@ def __init__(
**kwargs: Any,
) -> None:
headless = kwargs.pop("headless", False)
+ start_url = kwargs.pop("start_url", None)
from .utils import get_har_dir
har_dir = get_har_dir(run_id, output_dir)
@@ -512,6 +513,7 @@ def __init__(
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.headless = headless
+ self.start_url = start_url
def _cursor_mcp_servers(self) -> dict[str, Any]:
if self.agent_provider == "agent-browser":
diff --git a/src/reverse_api/prompts/auto/start_url.md b/src/reverse_api/prompts/auto/start_url.md
new file mode 100644
index 0000000..0a3c1c6
--- /dev/null
+++ b/src/reverse_api/prompts/auto/start_url.md
@@ -0,0 +1,5 @@
+
+{start_url}
+
+
+Start the mission by opening this URL.
diff --git a/src/reverse_api/prompts/auto/user_agent_browser.md b/src/reverse_api/prompts/auto/user_agent_browser.md
index dec2b9a..4139594 100644
--- a/src/reverse_api/prompts/auto/user_agent_browser.md
+++ b/src/reverse_api/prompts/auto/user_agent_browser.md
@@ -1,7 +1,7 @@
{prompt}
-
+{start_url_block}
{scripts_dir}
@@ -59,7 +59,7 @@ Start HAR recording **before** navigation and confirm it succeeded (abort if thi
export AGENT_BROWSER_SESSION="{agent_browser_session}"
{agent_browser_shell} network har start
# verify exit code 0; if non-zero, diagnose with `{agent_browser_shell} doctor` and stop
-{agent_browser_shell} open https://example.com
+{agent_browser_shell} open "" # the start URL, or the site the mission names
{agent_browser_shell} snapshot -i --json
# … iterate …
```
diff --git a/src/reverse_api/prompts/auto/user_chrome_mcp.md b/src/reverse_api/prompts/auto/user_chrome_mcp.md
index d32bc54..c2024eb 100644
--- a/src/reverse_api/prompts/auto/user_chrome_mcp.md
+++ b/src/reverse_api/prompts/auto/user_chrome_mcp.md
@@ -1,7 +1,7 @@
{prompt}
-
+{start_url_block}
{scripts_dir}
diff --git a/src/reverse_api/prompts/auto/user_playwright.md b/src/reverse_api/prompts/auto/user_playwright.md
index f9c9a22..d341331 100644
--- a/src/reverse_api/prompts/auto/user_playwright.md
+++ b/src/reverse_api/prompts/auto/user_playwright.md
@@ -1,7 +1,7 @@
{prompt}
-
+{start_url_block}
{scripts_dir}
diff --git a/src/reverse_api/utils.py b/src/reverse_api/utils.py
index 3915208..142cf7d 100644
--- a/src/reverse_api/utils.py
+++ b/src/reverse_api/utils.py
@@ -64,6 +64,15 @@
)
+def normalize_url(url: str) -> str:
+ """Prepend https:// when the user typed a bare host (e.g.
+ ``jobs.example.com/x``); Playwright's goto rejects scheme-less URLs."""
+ url = url.strip()
+ if url and "://" not in url:
+ return "https://" + url
+ return url
+
+
def build_sdk_env() -> dict[str, str]:
"""Environment overrides for Claude Agent SDK subprocesses.
diff --git a/tests/test_auto_engineer.py b/tests/test_auto_engineer.py
index d2ef7db..c5ac131 100644
--- a/tests/test_auto_engineer.py
+++ b/tests/test_auto_engineer.py
@@ -314,6 +314,55 @@ def test_opencode_delegates_prompts(self, tmp_path):
assert "npx" in user_p
+class TestStartUrlPrompt:
+ """The agent --url value reaches the user message."""
+
+ URL = "https://jobs.ashbyhq.com/openai"
+
+ def _user_prompt(self, tmp_path, agent_provider, **kwargs):
+ with patch("reverse_api.auto_engineer.get_har_dir", return_value=tmp_path / "har"):
+ with patch("reverse_api.base_engineer.get_scripts_dir", return_value=tmp_path / "scripts"):
+ with patch("reverse_api.base_engineer.MessageStore"):
+ with patch("reverse_api.agent_browser.agent_browser_npx_package", return_value="agent-browser@test"):
+ eng = ClaudeAutoEngineer(
+ run_id="test123",
+ prompt="fetch all open job postings",
+ model="claude-sonnet-4-6",
+ output_dir=str(tmp_path),
+ agent_provider=agent_provider,
+ **kwargs,
+ )
+ return eng._build_auto_prompts()[1]
+
+ @pytest.mark.parametrize("agent_provider", ["auto", "chrome-mcp", "agent-browser"])
+ def test_start_url_follows_mission(self, tmp_path, agent_provider):
+ user_p = self._user_prompt(tmp_path, agent_provider, start_url=self.URL)
+ assert f"\n{self.URL}\n" in user_p
+ assert user_p.index("") < user_p.index("") < user_p.index("")
+
+ @pytest.mark.parametrize("agent_provider", ["auto", "chrome-mcp", "agent-browser"])
+ def test_no_start_url_block_without_url(self, tmp_path, agent_provider):
+ user_p = self._user_prompt(tmp_path, agent_provider)
+ assert "" not in user_p
+ assert "\n\n" in user_p
+
+ def test_opencode_renders_start_url(self, tmp_path):
+ with patch("reverse_api.auto_engineer.get_har_dir", return_value=tmp_path / "har"):
+ with patch("reverse_api.base_engineer.get_scripts_dir", return_value=tmp_path / "scripts"):
+ with patch("reverse_api.base_engineer.MessageStore"):
+ with patch("reverse_api.opencode_engineer.OpenCodeUI"):
+ eng = OpenCodeAutoEngineer(
+ run_id="test123",
+ prompt="fetch all open job postings",
+ output_dir=str(tmp_path),
+ opencode_provider="anthropic",
+ opencode_model="claude-opus-4-6",
+ start_url=self.URL,
+ )
+ _sys_p, user_p = eng._get_active_prompts()
+ assert f"\n{self.URL}\n" in user_p
+
+
class TestChromeMcpConfig:
"""Test MCP configuration selection."""
diff --git a/tests/test_cli_agent_json.py b/tests/test_cli_agent_json.py
index 12c797d..44ae1fd 100644
--- a/tests/test_cli_agent_json.py
+++ b/tests/test_cli_agent_json.py
@@ -699,3 +699,55 @@ def test_direct_interrupt_after_claude_result_keeps_partial_run(tmp_path, output
payload = json.loads(result.stdout.strip().splitlines()[-1])
assert payload["error"] == "interrupted"
assert payload["script_path"] == "completed.py"
+
+
+@pytest.mark.parametrize(
+ "sdk,engine_path",
+ [
+ ("claude", "reverse_api.auto_engineer.ClaudeAutoEngineer"),
+ ("opencode", "reverse_api.auto_engineer.OpenCodeAutoEngineer"),
+ ("copilot", "reverse_api.auto_engineer.CopilotAutoEngineer"),
+ ("cursor", "reverse_api.cursor_engineer.CursorAutoEngineer"),
+ ],
+)
+def test_agent_url_reaches_engine(tmp_path, sdk, engine_path):
+ from unittest.mock import AsyncMock
+
+ from reverse_api.config import ConfigManager
+
+ config = ConfigManager(tmp_path / "config.json")
+ config.config.update({"sdk": sdk, "agent_provider": "auto", "real_time_sync": False})
+ engine = MagicMock()
+ engine.analyze_and_generate = AsyncMock(return_value=None)
+ with (
+ patch("reverse_api.cli.config_manager", config),
+ patch("reverse_api.cli.session_manager", SessionManager(tmp_path / "history.json")),
+ patch(engine_path, return_value=engine) as engine_cls,
+ ):
+ CliRunner().invoke(
+ main,
+ ["agent", "-p", "fetch job postings", "-u", "https://jobs.ashbyhq.com/openai", "--headless", "-o", str(tmp_path), "--json"],
+ )
+ assert engine_cls.call_args.kwargs["start_url"] == "https://jobs.ashbyhq.com/openai"
+
+
+def test_agent_bare_host_url_gets_https_scheme(tmp_path):
+ from unittest.mock import AsyncMock
+
+ from reverse_api.config import ConfigManager
+
+ config = ConfigManager(tmp_path / "config.json")
+ config.config.update({"sdk": "claude", "agent_provider": "auto", "real_time_sync": False})
+ engine = MagicMock()
+ engine.analyze_and_generate = AsyncMock(return_value=None)
+ with (
+ patch("reverse_api.cli.config_manager", config),
+ patch("reverse_api.cli.session_manager", SessionManager(tmp_path / "history.json")),
+ patch("reverse_api.auto_engineer.ClaudeAutoEngineer", return_value=engine) as engine_cls,
+ ):
+ result = CliRunner().invoke(
+ main,
+ ["agent", "-p", "fetch job postings", "-u", "jobs.ashbyhq.com/openai", "--headless", "-o", str(tmp_path), "--json"],
+ )
+ assert engine_cls.call_args.kwargs["start_url"] == "https://jobs.ashbyhq.com/openai"
+ assert json.loads(result.stdout.strip().splitlines()[-1])["url"] == "https://jobs.ashbyhq.com/openai"
diff --git a/tests/test_cli_followups.py b/tests/test_cli_followups.py
index e3ebaa2..79e32d6 100644
--- a/tests/test_cli_followups.py
+++ b/tests/test_cli_followups.py
@@ -434,6 +434,23 @@ def test_dry_run_bad_url_is_misuse(self, tmp_path):
assert payload["error_kind"] == "misuse"
assert any(c["name"] == "url" and c["status"] == "error" for c in payload["checks"])
+ def test_dry_run_bare_host_gets_https_scheme(self, tmp_path):
+ """A bare host passes the url check, as it does on a real run."""
+ from reverse_api.cli import agent as agent_cmd
+
+ runner = CliRunner()
+ with patch("reverse_api.cli.config_manager") as cm:
+ cm.get.side_effect = lambda key, default=None: {
+ "agent_provider": "auto",
+ "sdk": "claude",
+ "output_dir": str(tmp_path),
+ }.get(key, default)
+ result = runner.invoke(agent_cmd, ["--dry-run", "-p", "x", "-u", "jobs.example.com/x"])
+
+ payload = json.loads(result.stdout.strip())
+ assert payload["url"] == "https://jobs.example.com/x"
+ assert any(c["name"] == "url" and c["status"] == "ok" for c in payload["checks"])
+
def test_dry_run_unwritable_output_dir_is_config_invalid(self, tmp_path):
"""Unwritable output_dir → error_kind=config_invalid, not misuse."""
from reverse_api.cli import agent as agent_cmd
diff --git a/tests/test_manual_browser_startup_failure.py b/tests/test_manual_browser_startup_failure.py
index be267d5..48c5fe1 100644
--- a/tests/test_manual_browser_startup_failure.py
+++ b/tests/test_manual_browser_startup_failure.py
@@ -34,8 +34,3 @@ def test_playwright_stopped_when_start_raises(self, tmp_path):
fake_pw.stop.assert_called_once()
assert browser._playwright is None
-
- def test_scheme_added_to_bare_host(self):
- assert ManualBrowser._normalize_url("jobs.example.com/x") == "https://jobs.example.com/x"
- assert ManualBrowser._normalize_url("http://a.com") == "http://a.com"
- assert ManualBrowser._normalize_url(" https://b.com ") == "https://b.com"
diff --git a/tests/test_prompts.py b/tests/test_prompts.py
index e01f3a5..25d913e 100644
--- a/tests/test_prompts.py
+++ b/tests/test_prompts.py
@@ -219,6 +219,7 @@ def test_auto_user_playwright_loads(self):
prompt="browse example.com",
scripts_dir="/tmp/scripts",
har_path="/tmp/recording.har",
+ start_url_block="",
)
assert "browse example.com" in text
assert "browser_navigate" in text
@@ -229,6 +230,7 @@ def test_auto_user_chrome_mcp_loads(self):
"auto/user_chrome_mcp",
prompt="browse example.com",
scripts_dir="/tmp/scripts",
+ start_url_block="",
)
assert "browse example.com" in text
assert "navigate_page" in text
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 316696f..57558cf 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -870,6 +870,15 @@ def test_unrelated_error(self):
assert not is_context_overflow_error("rate limit exceeded")
+class TestNormalizeUrl:
+ def test_scheme_added_to_bare_host(self):
+ from reverse_api.utils import normalize_url
+
+ assert normalize_url("jobs.example.com/x") == "https://jobs.example.com/x"
+ assert normalize_url("http://a.com") == "http://a.com"
+ assert normalize_url(" https://b.com ") == "https://b.com"
+
+
class TestBuildSdkSettings:
"""Claude SDK sessions are isolated from the user's Claude Code setup by default."""