After a curl or wget an automated-client filter answered 403 or 429, gives the model the browser User-Agent to retry with, and the two cases where it must not.

Some sites turn away curl and wget just because they look like bots, and the model then tends to give up on a perfectly good URL. This mod catches that moment: when a request is refused by a bot filter, it tells the model to try once more with a browser User-Agent, and it also tells it the two cases where it must not.
curl or wget it reads both output streams and looks for the status a bot filter answers with, 403 or 429, in any of the shapes these tools print it: HTTP/2 403, 403 Forbidden, curl: (22) ... error: 403, status: 403, 429 Too Many Requests, Rate limit.-A, --user-agent, -U or a User-Agent header), it stays quiet. That advice has already been tried.ua-fallback: example.com answered 403, which is an automated-client filter, not a broken URL. Retry the same request once with a browser User-Agent: -A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'. If that is refused too, one of OpenAI File Downloader, XaiImageApiFetch/1.0, Claude-User may pass. Do not do this while testing an application, an API, an auth flow or a client of your own: a changed User-Agent hides the access-control or compatibility problem you are measuring. A reply you get with another User-Agent is not proof the resource works for ordinary clients, and it is never a way around authentication or a permission.
The last two sentences are in every note on purpose. The retry is for public content only. When you are testing your own app, the request has to go out with its real client, and a success under a borrowed User-Agent tells you nothing about how ordinary clients fare.
ua-fallback: example.com answered 403; a browser User-Agent may pass
429 yellow, 403 red, the retry hint faint), and a faint line under it reads not while testing your own app, auth flow or client. Without the sidebar, the line lands in the transcript as above./ua-fallback on or off, and the hosts that refused this session /ua-fallback on | off turns it on or off; it is on after install
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install ua-fallback@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=ua-fallback}, tool.call{tool=Bash} ❯ ./register.ts calls: $.command.register, $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand), $.ui.log (via toPerson)
Reach L1: it only reads the session.
curl -s that prints only the body gives it nothing to read, so no note.curl --fail and wget exit non-zero on a 403) is read from its error text, and both you and the model get the finding. The note goes after the error text the model sees, which stays as it is.hooks/fetch.ts, and it ages. A site that insists on a current browser version may still refuse it.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 118 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { hasUserAgent, hostOf, isFetch, logText, noteText, sectionKey, sidebarLines, statusIn, statusText, urlOf } from './fetch.ts'
3
4const ENABLED_KEY = 'enabled'
5
6const USAGE = 'expects nothing (the status), on or off'
7
8/** The on/off setting and the hosts already reported, so one host speaks once per session. */
9type State = { enabled: boolean; noted: Set<string> }
10
11/**
12 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
13 * transcript line. The model's note is another channel and does not repeat this text.
14 */
15async function toPerson($: EngineInterface, url: string, status: string): Promise<void> {
16 try {
17 const taken = await $.sidebar.set({
18 consumer: 'ua-fallback',
19 key: sectionKey(hostOf(url)),
20 title: 'request filtered',
21 lines: sidebarLines(url, status),
22 until: 'stream',
23 })
24 if (taken) return
25 } catch {
26 // The sidebar mod is not installed.
27 }
28 $.ui.log(logText(url, status))
29}
30
31/**
32 * What a Bash call printed. An answered call carries its two streams in `result`; a call that exited
33 * non-zero is an error result, whose `result` is the error text and never the tool's record, so its
34 * output is read from `text`, the error as the model reads it.
35 */
36function outputOf(r: ToolCallResult<'Bash'>): string {
37 if (r.isError === true) return r.text ?? (typeof r.result === 'string' ? r.result : '')
38 const out = r.result as { stdout?: unknown; stderr?: unknown } | undefined
39 const stdout = typeof out?.stdout === 'string' ? out.stdout : ''
40 const stderr = typeof out?.stderr === 'string' ? out.stderr : ''
41 return `${stdout}\n${stderr}`
42}
43
44/** The URL and status of one finished fetch command a bot filter refused, for a host not reported yet. */
45function findingOf(state: State, command: string, output: string): { url: string; status: string } | undefined {
46 if (!isFetch(command) || hasUserAgent(command)) return undefined
47 const url = urlOf(command)
48 if (url === undefined) return undefined
49 const status = statusIn(output)
50 if (status === undefined || state.noted.has(hostOf(url))) return undefined
51 return { url, status }
52}
53
54/**
55 * Reads one finished fetch command and answers the model's note, if a bot filter refused it. The
56 * setting is read only for a finding, so a Bash call that is no refused fetch never reads the store.
57 */
58async function measure($: EngineInterface, state: State, command: string, output: string): Promise<string | undefined> {
59 const found = findingOf(state, command, output)
60 if (found === undefined) return undefined
61 await readSettings($, state)
62 if (!state.enabled) return undefined
63 const { url, status } = found
64 state.noted.add(hostOf(url))
65 // The note goes to the model, the line to the person: neither reads the other's channel.
66 await toPerson($, url, status)
67 return noteText(url, status)
68}
69
70/**
71 * Reads the on/off setting from the store, which every window shares, so a change made in another
72 * window applies here at the next hook that acts on it.
73 */
74async function readSettings($: EngineInterface, state: State): Promise<void> {
75 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
76}
77
78async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
79 const arg = args.trim()
80 await readSettings($, state)
81 if (arg === 'on' || arg === 'off') {
82 state.enabled = arg === 'on'
83 await $.store.set(ENABLED_KEY, state.enabled)
84 return state.enabled ? 'on: a filtered request brings the fallback User-Agent to the model' : 'off: requests are not read'
85 }
86 if (arg !== '' && arg !== 'status') return USAGE
87 return statusText(state.enabled, [...state.noted])
88}
89
90export const register: Register = on => {
91 const state: State = { enabled: true, noted: new Set() }
92
93 on('session.start', async ($, e, next) => {
94 const r = await next(e)
95 await readSettings($, state)
96 await $.command.register({
97 name: 'ua-fallback',
98 description: 'The fallback User-Agent after a curl or wget a filter refused: status, on, off (ua-fallback)',
99 argumentHint: '[on | off]',
100 immediate: true,
101 })
102 return r
103 })
104
105 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
106 on('command.run', { command: 'ua-fallback' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
107
108 // A denied call fetched nothing. A failed one is still read, because `curl --fail` and `wget` exit
109 // non-zero on a 403 and its status is exactly the finding. The note rides after the model's own error
110 // text, which stays as it is.
111 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
112 const r = await next(e)
113 if (r.deny !== undefined) return r
114 const note = await measure($, state, e.command, outputOf(r))
115 return note === undefined ? r : { ...r, context: [...(r.context ?? []), note] }
116 })
117}
118hooks/fetch.ts 106 lines1/** Which fetch command a bot filter refused, and what to retry it with. */
2
3/** A fetch command this mod reads. */
4const FETCH = /(^|[\s;&|(])(curl|wget)\b/
5
6/** The command already carries a User-Agent of its own, so the advice is spent. */
7const HAS_UA = /(\s-A\s|\s--user-agent[=\s]|\s-U\s|-H\s*['"]?User-Agent|--header[=\s]['"]?User-Agent)/i
8
9/** The first http or https URL of the command. */
10const URL_IN = /https?:\/\/[^\s'"<>|)]+/
11
12/** The status of a filtered request, as a command's own output names it. */
13const STATUS: readonly { when: RegExp; status: '403' | '429' }[] = [
14 { when: /HTTP\/[\d.]+\s+403\b/, status: '403' },
15 { when: /HTTP\/[\d.]+\s+429\b/, status: '429' },
16 { when: /\b403 Forbidden\b/i, status: '403' },
17 { when: /\b429 Too Many Requests\b/i, status: '429' },
18 { when: /error\s*:?\s*403\b/i, status: '403' },
19 { when: /error\s*:?\s*429\b/i, status: '429' },
20 { when: /\bstatus(?: code)?[":\s=]+403\b/i, status: '403' },
21 { when: /\bstatus(?: code)?[":\s=]+429\b/i, status: '429' },
22 { when: /\bRate limit\b/i, status: '429' },
23]
24
25/** The browser User-Agent the note offers first, a current desktop Chrome on macOS. */
26export const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
27
28/** The three agent User-Agents the note offers after the browser one. */
29export const AGENT_UAS = 'OpenAI File Downloader, XaiImageApiFetch/1.0, Claude-User'
30
31export function isFetch(command: string): boolean {
32 return FETCH.test(command)
33}
34
35export function hasUserAgent(command: string): boolean {
36 return HAS_UA.test(command)
37}
38
39/** The URL the command fetched, or undefined when it names none. */
40export function urlOf(command: string): string | undefined {
41 return URL_IN.exec(command)?.[0]
42}
43
44/** The host of a URL, which is what one finding is kept per. */
45export function hostOf(url: string): string {
46 return /^https?:\/\/([^/?#]+)/.exec(url)?.[1] ?? url
47}
48
49/** The status a bot filter answered with, or undefined when the output names none. */
50export function statusIn(text: string): '403' | '429' | undefined {
51 return STATUS.find(s => s.when.test(text))?.status
52}
53
54/**
55 * The note the model reads: the retry to try, then the two limits of the retry. A success with another
56 * User-Agent is not proof the resource works for ordinary clients, and a request sent to test an
57 * application's own access control must keep its real client, or the test measures the wrong thing.
58 */
59export function noteText(url: string, status: string): string {
60 return [
61 `ua-fallback: ${hostOf(url)} answered ${status}, which is an automated-client filter, not a broken URL.`,
62 `Retry the same request once with a browser User-Agent: -A '${BROWSER_UA}'.`,
63 `If that is refused too, one of ${AGENT_UAS} may pass.`,
64 'Do not do this while testing an application, an API, an auth flow or a client of your own: a changed User-Agent hides the access-control or compatibility problem you are measuring.',
65 'A reply you get with another User-Agent is not proof the resource works for ordinary clients, and it is never a way around authentication or a permission.',
66 ].join(' ')
67}
68
69/** The transcript line the person reads: the finding alone, without the instruction. The engine adds the mod name. */
70export function logText(url: string, status: string): string {
71 return `${hostOf(url)} answered ${status}; a browser User-Agent may pass`
72}
73
74/** How the sidebar colours a line or a part of one. */
75type Tone = 'ok' | 'warn' | 'error' | 'dim'
76export type Part = { text: string; kind?: Tone }
77/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
78export type Line = { text: string; kind?: Tone; parts?: Part[] }
79
80const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
81
82/** A line made of parts, its `text` their texts joined. */
83const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
84
85/**
86 * The sidebar lines of one finding: the host and status, the status yellow for a rate limit and red for
87 * a refusal, and the retry faint; the limit of the retry faint under it.
88 */
89export function sidebarLines(url: string, status: string): Line[] {
90 return [
91 partsLine([part(`${hostOf(url)} answered `, undefined), part(status, status === '429' ? 'warn' : 'error'), part('; a browser User-Agent may pass', 'dim')]),
92 { text: 'not while testing your own app, auth flow or client', kind: 'dim' },
93 ]
94}
95
96/** A sidebar section key: the subject cut to what the sidebar takes. */
97export function sectionKey(text: string): string {
98 return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'fetch'
99}
100
101/** The `/ua-fallback` answer: the setting and the hosts this session was refused by. */
102export function statusText(enabled: boolean, hosts: readonly string[]): string {
103 const seen = hosts.length === 0 ? 'no filtered request yet' : `filtered: ${hosts.join(' · ')}`
104 return `${enabled ? 'on' : 'off'} · ${seen}`
105}
106