Skip to content

feat(http): tell the API which AI agent is running the CLI - #25

Merged
AnderRV merged 1 commit into
mainfrom
feat/cli-agent-client-header
Oct 5, 2026
Merged

AnderRV merged 1 commit into
mainfrom
feat/cli-agent-client-header

Conversation

@AnderRV

@AnderRV AnderRV commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Summary

When an artificial intelligence (AI) coding agent runs the CLI, the CLI now names that agent in an X-ZenRows-Client header. The dashboard Activity Log can then show which agent drove a CLI request. The CLI sends nothing when it detects no agent.

Before: every CLI request said only User-Agent: zenrows-cli/<version>, and batch, usage and the account calls said only zenrows-cli. A request that Claude Code ran looked the same as one a person typed.

After: a command Claude Code runs also carries X-ZenRows-Client: claude-code. A request from a plain terminal carries no extra header. Every client sends the versioned User-Agent.

What the task asked for

The Activity Log already records which client sent a request (CLI, MCP, an SDK). This change lets it say which agent ran the CLI. The ticket links this PR.

How

src/core/agent-client.ts holds the whole decision. It checks a short list of environment variables. Each agent sets its variable in the shells it runs commands in. The first match gives a fixed name, and the module never sends the variable's value. A ZENROWS_CLIENT=<name> override wins over detection. An override that is not a plain name (letters, digits, ., _, -, at most 32 characters) sends nothing, so a bad value cannot make fetch throw. ZENROWS_TELEMETRY=off and config telemetry: "off" suppress the header, override included, the same switch that already gates signup attribution. Scrape, browser, batch, usage and the three agent-account calls spread agentClientHeader() into their headers.

This depends on an upcoming API change that stores the header. Until it ships the API ignores the header, so this PR is safe to merge first.

Detected agents

Agent Variable Sent as Source
Cursor (agent terminals) CURSOR_AGENT cursor Cursor docs: Terminal
GitHub Copilot, agent mode in VS Code COPILOT_AGENT vscode microsoft/vscode#316267, toolTerminalCreator.ts
OpenAI Codex CLI CODEX_THREAD_ID, CODEX_SANDBOX codex shell_environment.rs, spawn.rs
Gemini CLI GEMINI_CLI gemini-cli Gemini CLI docs: commands, shellExecutionService.ts
Claude Code (v2.1.172+) CLAUDE_CODE_CHILD_SESSION claude-code Claude Code docs: environment variables

Not detected, because none of them documents a variable for its shells today: Windsurf, Aider, Copilot CLI and the Copilot cloud agent.

Claude Code is detected from CLAUDE_CODE_CHILD_SESSION, not CLAUDECODE. Claude Code's IDE extensions export CLAUDECODE into every integrated terminal, so a person typing zenrows there would read as Claude Code. The docs say only Claude Code itself sets CLAUDE_CODE_CHILD_SESSION, in what its Bash, PowerShell and Monitor tools, hooks and status line launch. Claude Code before v2.1.172, and the CLI running as a stdio MCP server under Claude Code, send no name rather than a wrong one.

Cursor's docs say to use CURSOR_AGENT "to detect when Cursor is running", but they do not say whether Cursor sets it only in agent terminals or also in terminals a person types in. If it is the latter, a person in Cursor's terminal reads as cursor.

How the check confirmed it

npm run typecheck && npm test: 273 tests, 273 pass, 0 fail (206 before).

  • Encoded by tests/agent-client.test.ts: detectAgentClient: <case> (22-row environment table, including CLAUDECODE=1 alone in an IDE terminal sending nothing and one precedence row per neighbouring pair of signals), the override wins over a detected agent, an invalid override sends nothing, ZENROWS_TELEMETRY=off suppresses it, and for each of the seven clients sends X-ZenRows-Client when run by an agent, sends no X-ZenRows-Client when no agent is detected, ZENROWS_TELEMETRY=off sends no X-ZenRows-Client, config telemetry "off" sends no X-ZenRows-Client, sends a User-Agent carrying the CLI version. Each check runs from a fresh workspace, so a developer's own .zenrows/config.json cannot change the result. Three more tests cover a workspace other than the cwd's: zenrows init --workspace <dir> --no-telemetry run from outside <dir> sends no header on its smoke fetch, and the same holds for agentClientHeader({ projectRoot }) and signup discovery.
  • tests/setup.ts clears the detected variables, so the suite gives the same result inside an agent's shell.

Each check failed once with the behaviour broken on purpose, then passed after the restore:

Break Result
Detection never matches 18 fail: every agent row, and all seven "sends when run by an agent"
Batch client drops the header 1 fails: batch: sends X-ZenRows-Client when run by an agent
Override ignored 3 fail: the three override tests
Telemetry opt-out ignored 15 fail: ZENROWS_TELEMETRY=off suppresses it, and both opt-out tests for each of the seven clients
CLAUDECODE read as Claude Code again 1 fails: a terminal in an IDE with Claude Code's extension is not Claude Code
runFetch drops the workspace root 1 fails: init's smoke fetch honours --workspace telemetry off
Header ignores projectRoot 3 fail: the three workspace-root tests
Claude Code moved to the front of the list 3 fail: Codex and Gemini CLI over Claude Code
Cursor and Copilot swapped 1 fails: Cursor agent over Copilot agent

End to end, against a local Zenrows stack whose server already reads the header: the built CLI ran zenrows fetch six times, and the stored request rows read back as below. These runs predate the switch to CLAUDE_CODE_CHILD_SESSION and used CLAUDECODE=1; only the variable name changed, the request path did not.

Environment Stored client
CLAUDECODE=1 claude-code
CODEX_THREAD_ID=abc other (server has no codex name yet)
GEMINI_CLI=1 other (server has no gemini-cli name yet)
none empty
CLAUDECODE=1 ZENROWS_TELEMETRY=off empty
CLAUDECODE=1 ZENROWS_CLIENT=cursor cursor

How to try it

npm ci && npm run build
# Point the CLI at any server that logs request headers (a request bin, or a local echo server):
CLAUDE_CODE_CHILD_SESSION=1 ZENROWS_API_KEY=test ZENROWS_API_BASE=http://127.0.0.1:18099/v1/ \
  node dist/bin/zenrows.js fetch https://example.com/ --manual --no-signup

The server logs X-ZenRows-Client: claude-code and User-Agent: zenrows-cli/1.3.0. Run it again without CLAUDE_CODE_CHILD_SESSION (or with only CLAUDECODE=1), or with ZENROWS_TELEMETRY=off, and the header is gone. With ZENROWS_CLIENT=$'bad\r\nX: 1' the request still succeeds and carries no header.

Provenance

Choice From
Native fetch, node:test, no dependency docs/contributing.md, "Stack"
Pure function over an env object, process.env as the default existing detectClient in src/core/provenance.ts
Gate on attributionEnabled() existing signup attribution in src/core/agent-account.ts
Override sends nothing when invalid, rather than falling back to detection judgment: the user asked not to send the detected name
COPILOT_AGENT sent as vscode judgment: the name the server already uses for VS Code's agent
Claude Code from CLAUDE_CODE_CHILD_SESSION, not CLAUDECODE Claude Code docs: CLAUDECODE is also set in IDE terminals, CLAUDE_CODE_CHILD_SESSION is not
One shared CLI_USER_AGENT constant judgment: five copies of the string existed

Known gaps, not changed here

  • zenrows policy set telemetry off writes policy.json, and attributionEnabled() never reads it: only config telemetry and ZENROWS_TELEMETRY switch attribution off. This was already true before this change, and it now applies to the new header as well.
  • Batch, usage and the agent-account calls now send User-Agent: zenrows-cli/<version>, where they used to send a bare zenrows-cli. A CDN or WAF rule that matches the bare string exactly would stop matching. I searched the repositories and found no such rule.
  • Batch, browser, usage, signup and account status have no workspace root to pass, so they read attribution from the cwd's workspace, the same one their commands load config from.

Review

Independent review: no blocking findings; added request-path opt-out tests, ignored =0/=false, documented the status probe exclusion, isolated the tests from a developer's workspace config, honoured --workspace telemetry off, covered signal precedence, and detected Claude Code from CLAUDE_CODE_CHILD_SESSION so an IDE terminal is not mislabelled.

Deviations

  • The task asked for a tracker link and the name of the server-side PR. This repository is public, so both stay in the ticket instead, and the ticket links this PR.
  • The new header does not replace the older signup-only X-ZR-Client header, which src/core/provenance.ts still derives with looser rules. Changing that is a separate contract with the signup endpoint.
  • zenrows status sends an unauthenticated reachability probe with no headers at all. It does not reach the Activity Log, so it stays as it is. Batch result downloads go to short-lived storage links, not to a Zenrows API, so they get no header.

Record owed

None from this PR. The upcoming API change defines the header contract, and its record belongs with that change.

🤖 Generated with Claude Code

Requests from the CLI only said "zenrows-cli", so the Activity Log could not
show that Claude Code, Cursor or another agent drove them. The CLI now sends
X-ZenRows-Client with the agent's name when an agent's own shell variable is
set (CLAUDE_CODE_CHILD_SESSION, CURSOR_AGENT, COPILOT_AGENT,
CODEX_THREAD_ID/CODEX_SANDBOX, GEMINI_CLI), or the user's ZENROWS_CLIENT
override. Nothing is sent when no agent is detected or when
ZENROWS_TELEMETRY=off (or config telemetry "off").

Claude Code is detected from CLAUDE_CODE_CHILD_SESSION, not CLAUDECODE: the
IDE extensions export CLAUDECODE into every integrated terminal, so a person
typing `zenrows` there would read as Claude Code.

agentClientHeader() takes the workspace root, so `zenrows init --workspace
<dir> --no-telemetry` run outside <dir> honours that workspace's opt-out.

Batch, usage and the agent-account calls now send the versioned User-Agent
the scrape and browser clients already sent.

Tests cover a 22-row detection table with precedence for every neighbouring
pair of signals, the override, and, for every API client, the header with an
agent, no header without one, and both telemetry opt-outs. Each check runs
from a fresh workspace so a developer's own .zenrows config cannot change it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qxnmqucWraLJLseY6HcRx
@AnderRV AnderRV self-assigned this Oct 5, 2026
@ZenRows ZenRows deleted a comment from linear Bot Oct 5, 2026
@AnderRV
AnderRV marked this pull request as ready for review October 5, 2026 08:46
@AnderRV
AnderRV merged commit 69f7b53 into main Oct 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant