feat(http): tell the API which AI agent is running the CLI - #25
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When an artificial intelligence (AI) coding agent runs the CLI, the CLI now names that agent in an
X-ZenRows-Clientheader. 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 onlyzenrows-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 versionedUser-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.tsholds 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. AZENROWS_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 makefetchthrow.ZENROWS_TELEMETRY=offand configtelemetry: "off"suppress the header, override included, the same switch that already gates signup attribution. Scrape, browser, batch, usage and the three agent-account calls spreadagentClientHeader()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
CURSOR_AGENTcursorCOPILOT_AGENTvscodeCODEX_THREAD_ID,CODEX_SANDBOXcodexGEMINI_CLIgemini-cliCLAUDE_CODE_CHILD_SESSIONclaude-codeNot 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, notCLAUDECODE. Claude Code's IDE extensions exportCLAUDECODEinto every integrated terminal, so a person typingzenrowsthere would read as Claude Code. The docs say only Claude Code itself setsCLAUDE_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 ascursor.How the check confirmed it
npm run typecheck && npm test: 273 tests, 273 pass, 0 fail (206 before).tests/agent-client.test.ts:detectAgentClient: <case>(22-row environment table, includingCLAUDECODE=1alone 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 clientssends 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.jsoncannot change the result. Three more tests cover a workspace other than the cwd's:zenrows init --workspace <dir> --no-telemetryrun from outside<dir>sends no header on its smoke fetch, and the same holds foragentClientHeader({ projectRoot })and signup discovery.tests/setup.tsclears 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:
batch: sends X-ZenRows-Client when run by an agentZENROWS_TELEMETRY=off suppresses it, and both opt-out tests for each of the seven clientsCLAUDECODEread as Claude Code againa terminal in an IDE with Claude Code's extension is not Claude CoderunFetchdrops the workspace rootinit's smoke fetch honours --workspace telemetry offprojectRootCursor agent over Copilot agentEnd to end, against a local Zenrows stack whose server already reads the header: the built CLI ran
zenrows fetchsix times, and the stored request rows read back as below. These runs predate the switch toCLAUDE_CODE_CHILD_SESSIONand usedCLAUDECODE=1; only the variable name changed, the request path did not.CLAUDECODE=1claude-codeCODEX_THREAD_ID=abcother(server has nocodexname yet)GEMINI_CLI=1other(server has nogemini-cliname yet)CLAUDECODE=1 ZENROWS_TELEMETRY=offCLAUDECODE=1 ZENROWS_CLIENT=cursorcursorHow to try it
The server logs
X-ZenRows-Client: claude-codeandUser-Agent: zenrows-cli/1.3.0. Run it again withoutCLAUDE_CODE_CHILD_SESSION(or with onlyCLAUDECODE=1), or withZENROWS_TELEMETRY=off, and the header is gone. WithZENROWS_CLIENT=$'bad\r\nX: 1'the request still succeeds and carries no header.Provenance
fetch,node:test, no dependencyenvobject,process.envas the defaultdetectClientinsrc/core/provenance.tsattributionEnabled()src/core/agent-account.tsCOPILOT_AGENTsent asvscodeCLAUDE_CODE_CHILD_SESSION, notCLAUDECODECLAUDECODEis also set in IDE terminals,CLAUDE_CODE_CHILD_SESSIONis notCLI_USER_AGENTconstantKnown gaps, not changed here
zenrows policy set telemetry offwritespolicy.json, andattributionEnabled()never reads it: only configtelemetryandZENROWS_TELEMETRYswitch attribution off. This was already true before this change, and it now applies to the new header as well.User-Agent: zenrows-cli/<version>, where they used to send a barezenrows-cli. A CDN or WAF rule that matches the bare string exactly would stop matching. I searched the repositories and found no such rule.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
--workspacetelemetry off, covered signal precedence, and detected Claude Code fromCLAUDE_CODE_CHILD_SESSIONso an IDE terminal is not mislabelled.Deviations
X-ZR-Clientheader, whichsrc/core/provenance.tsstill derives with looser rules. Changing that is a separate contract with the signup endpoint.zenrows statussends 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