SLOPSHOPPER

clickback

Get paid for the one-line sponsored messages Clickback shows above your prompt after a turn. Ads go to you, never to Claude. Needs Claude Code 2.1.287 or later.

newbandpromptnetworktimer
v0.1.0no licenseupdated 2026-10-04simonhamp/clickback-plugin/plugins/clickback
A shopper browsing a rack in a slop shop
README

Clickback for Claude Code

Clickback pays you for the one-line sponsored messages it shows you above the prompt after Claude finishes a turn:

 Sponsored · Shipfast: Deploy Laravel apps in 30 seconds → shipfast.example.com
❯

The host is a link to the ad. The ad goes to you, not to Claude. It doesn't cost you any tokens and Claude never sees it. It goes away when you send your next prompt.

It works in Claude Code in a terminal and in the Code tab of the Claude Desktop app. It needs Claude Code 2.1.287 or later, the first version with mods. On an older version you get the account tools below but no ads. Check with claude --version.

Install

You need a Clickback account and an API token from your dashboard at https://clickback.dev.

In Claude Code:

/plugin marketplace add simonhamp/clickback-plugin
/plugin install clickback@clickback

To try a local checkout instead, point the first command at the root of this repository: /plugin marketplace add /path/to/clickback-plugin.

When you enable the plugin, Claude Code asks for:

  • Clickback API token: stored in your system keychain, not in settings.json.
  • Clickback server: leave it as https://clickback.dev.

Then restart Claude Code or run /reload-plugins.

What it installs

  • A mod (hooks/clickback.ts). Mods are small modules that run inside a Claude Code session. After a turn ends, this one asks Clickback for an ad and draws it in the band above the prompt. The request runs after the turn has finished, so it never holds up Claude, and it gives up after 3 seconds. Clickback also spaces ads out, so most turns show nothing. It skips claude -p, the Agent SDK and the VS Code panel, where nothing it draws would be seen. Run claude plugin validate on this folder to see exactly which events it hooks and what it calls.
  • An MCP server at <server>/mcp with account tools you can use by asking Claude: earnings, get_profile, update_profile, recent_ads and report_ad. There is no tool that shows ads.
  • A skill that tells Claude when to use those tools, e.g. "report that ad", "how much have I made?" or "update my Clickback profile".

Privacy

The mod sends Clickback your token, the session id, surface=mod, and agent=claude-code (or claude-code-desktop in the Desktop app). Your prompts, Claude's replies, the transcript and your working directory never leave your machine. It adds nothing to what Claude reads and never changes Claude's messages or tool calls.

The MCP tools only read and change your own Clickback account.

Turning it off

Collapse the band for a while with ctrl+x ctrl+a. To stop ads, disable the plugin in /plugin, or uninstall it with /plugin uninstall clickback@clickback.

Testing

claude plugin validate plugins/clickback
claude plugin test plugins/clickback

To try it against a local server:

claude --plugin-dir ./plugins/clickback \
  --settings '{"pluginConfigs":{"clickback@inline":{"options":{"api_token":"1|...","base_url":"http://clickback.test"}}}}'

A local http:// tracking link is drawn as plain text, because Claude Code only makes https:// (and http://localhost) links clickable.

scripts/stop-hook.sh is the old Stop hook that printed the ad as Stop says: .... It isn't registered any more. It stays here for agents that only have shell hooks, and its tests live with the Clickback app.

Source 2 files
hooks/clickback.ts 341 lines
1// Clickback mod: shows you one sponsored line above the prompt after Claude
2// finishes a turn, and pays you for it.
3//
4// Display only. The ad is drawn in the AbovePrompt band and kept in $.state.
5// It never goes to the model: this module adds no prompt context, rewrites no
6// message and touches no tool call.
7//
8// Privacy: it sends Clickback your token, the session id, surface=mod and which
9// app you are in. Your prompts, Claude's replies, the transcript and your
10// working directory never leave this machine.
11//
12// It never gets in the way: the request runs after the turn has finished, gives
13// up after a few seconds, and any failure (no token, no ad, a slow or broken
14// server, a response it doesn't understand) shows nothing.
15
16import type {
17  EngineInterface,
18  HttpResponse,
19  On,
20  PluginOptions,
21  RenderSurface,
22} from 'claude-code'
23
24import type { ClickbackAd } from '../types'
25
26/** The ad on screen, held by the host so a hot reload keeps it. */
27const AD = { plugin: 'clickback', key: 'ad' } as const
28
29/** How many prompts this session has submitted so far. */
30const PROMPTS = { plugin: 'clickback', key: 'prompts' } as const
31
32export const LABEL = 'Sponsored'
33export const SURFACE = 'mod'
34export const TIMEOUT_MS = 3000
35export const DEFAULT_BASE_URL = 'https://clickback.dev'
36
37export type Config = { token: string; base: string }
38
39type Served = Omit<ClickbackAd, 'prompt'>
40
41/** Only one serve request at a time. A reload resets it, which is harmless. */
42let isServing = false
43
44export function register(on: On, options: PluginOptions) {
45  const config = configOf(options)
46
47  on('turn.complete', async ($, e, next) => {
48    const result = await next(e)
49
50    // Main-loop turns that finished normally, like the Stop hook. Subagents,
51    // interrupts, refusals and API errors get nothing. The request runs after
52    // this hook has returned, so the session never waits for Clickback.
53    if (config !== undefined && e.agentId === undefined && e.reason === 'answer') {
54      void serve($, config)
55    }
56
57    return result
58  })
59
60  on('prompt.submit', async ($, e, next) => {
61    await clearSeen($)
62
63    return next(e)
64  })
65
66  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
67    // Read first, so the band redraws when an ad arrives or is cleared.
68    const { value: ad } = await $.state.get(AD)
69
70    if (!ad || e.props.hasSurvey || e.props.isWorking || e.props.view.agentId !== undefined) {
71      return next(e)
72    }
73
74    const { Box, Text, Link } = $.ui.resolve(e)
75    const target = ad.isLinkable
76      ? Link({ href: ad.url, children: ad.host })
77      : `${ad.host} · ${ad.url}`
78
79    return Box({
80      flexDirection: 'column',
81      children: [
82        Box({
83          paddingX: 1,
84          children: Text({
85            dimColor: true,
86            wrap: 'truncate-end',
87            children: [`${LABEL} · ${ad.advertiser}: ${ad.headline} → `, target],
88          }),
89        }),
90        await next(e),
91      ],
92    })
93  })
94}
95
96/**
97 * Asks Clickback for an ad and keeps it for the band. Never throws.
98 */
99export async function serve($: EngineInterface, config: Config): Promise<void> {
100  if (isServing) {
101    return
102  }
103
104  isServing = true
105
106  try {
107    // Only where someone will see it: a terminal or the desktop app, the two
108    // surfaces that draw AbovePrompt. Empty under `claude -p` and the SDK.
109    const agent = agentFor(await $.session.surfaces())
110
111    if (agent === undefined) {
112      return
113    }
114
115    // An ad is still up, or one landed after the last prompt went in and hasn't
116    // been on screen yet. Show that one instead of paying for another.
117    const { value: held } = await $.state.get(AD)
118
119    if (held) {
120      return
121    }
122
123    const response = await within(
124      $,
125      TIMEOUT_MS,
126      $.http.fetch(`${config.base}/api/v1/ads/serve`, {
127        method: 'POST',
128        headers: {
129          Accept: 'application/json',
130          Authorization: `Bearer ${config.token}`,
131          'Content-Type': 'application/json',
132        },
133        body: JSON.stringify({
134          surface: SURFACE,
135          agent,
136          session_id: await $.session.id(),
137        }),
138      }),
139    )
140
141    if (response?.status !== 200) {
142      debug($, `serve answered ${response === undefined ? 'nothing in time' : response.status}`)
143
144      return
145    }
146
147    const ad = adOf(response.text)
148
149    if (ad === undefined) {
150      debug($, 'serve answered with something that is not an ad')
151
152      return
153    }
154
155    const { value: prompts = 0 } = await $.state.get(PROMPTS)
156
157    await $.state.set(AD, { ...ad, prompt: prompts })
158    debug($, `showing an ad from ${ad.host}`)
159  } catch (error) {
160    debug($, `serve failed: ${error instanceof Error ? error.message : String(error)}`)
161  } finally {
162    isServing = false
163  }
164}
165
166/**
167 * A line in the debug log (`claude --debug`), never the transcript.
168 */
169function debug($: EngineInterface, text: string): void {
170  try {
171    $.ui.log(text, { to: 'debug' })
172  } catch {
173    // Never in the way.
174  }
175}
176
177/**
178 * Takes the ad down when the next prompt goes in, unless it arrived after the
179 * last prompt, in which case it waits for the end of that turn.
180 */
181async function clearSeen($: EngineInterface): Promise<void> {
182  try {
183    const { value: prompts = 0 } = await $.state.get(PROMPTS)
184    const { value: ad } = await $.state.get(AD)
185
186    if (ad && ad.prompt <= prompts) {
187      await $.state.set(AD, null)
188    }
189
190    await $.state.set(PROMPTS, prompts + 1)
191  } catch {
192    // Never in the way.
193  }
194}
195
196/**
197 * The token and server from the plugin's userConfig, or undefined when they
198 * can't be ours.
199 */
200export function configOf(options: PluginOptions): Config | undefined {
201  const token = typeof options.api_token === 'string' ? options.api_token.replace(/\s+/g, '') : ''
202  const base = (typeof options.base_url === 'string' && options.base_url.trim() !== ''
203    ? options.base_url.trim()
204    : DEFAULT_BASE_URL
205  ).replace(/\/+$/, '')
206
207  // A Sanctum token is "<id>|<random>", maybe with a prefix.
208  if (!/^[A-Za-z0-9._|-]+$/.test(token)) {
209    return undefined
210  }
211
212  if (!/^https?:\/\/[^\s/?#]+[^\s?#]*$/.test(base)) {
213    return undefined
214  }
215
216  return { token, base }
217}
218
219/**
220 * What to report as the agent, or undefined where the band isn't drawn.
221 */
222export function agentFor(surfaces: readonly RenderSurface[]): string | undefined {
223  if (surfaces.includes('desktop')) {
224    return 'claude-code-desktop'
225  }
226
227  if (surfaces.includes('terminal')) {
228    return 'claude-code'
229  }
230
231  return undefined
232}
233
234/**
235 * The parts of a serve response the band needs, cleaned up, or undefined when
236 * the response isn't a whole ad.
237 */
238export function adOf(text: string): Served | undefined {
239  let data: unknown
240
241  try {
242    data = JSON.parse(text)
243  } catch {
244    return undefined
245  }
246
247  // The serve endpoint wraps the ad in "data", as Laravel resources do.
248  const fields = recordOf(recordOf(data)?.data)
249
250  if (fields === undefined) {
251    return undefined
252  }
253
254  const advertiser = clean(fields.advertiser, 60)
255  const headline = clean(fields.headline, 140)
256  const host = clean(fields.destination_host, 100)
257  const url = urlOf(fields.tracking_url)
258
259  if (advertiser === '' || headline === '' || host === '' || url === undefined) {
260    return undefined
261  }
262
263  return { advertiser, headline, host, url: url.href, isLinkable: isLinkable(url) }
264}
265
266function recordOf(value: unknown): Record<string, unknown> | undefined {
267  return typeof value === 'object' && value !== null && !Array.isArray(value)
268    ? (value as Record<string, unknown>)
269    : undefined
270}
271
272/**
273 * One line of plain text: no control or bidi characters, single spaces, at
274 * most `max` characters.
275 */
276export function clean(value: unknown, max: number): string {
277  if (typeof value !== 'string') {
278    return ''
279  }
280
281  const text = value
282    .replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ' ')
283    .replace(/\s+/g, ' ')
284    .trim()
285
286  return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
287}
288
289function urlOf(value: unknown): URL | undefined {
290  if (typeof value !== 'string') {
291    return undefined
292  }
293
294  try {
295    const url = new URL(value)
296
297    if (url.protocol !== 'https:' && url.protocol !== 'http:') {
298      return undefined
299    }
300
301    if (url.username !== '' || url.password !== '') {
302      return undefined
303    }
304
305    return url
306  } catch {
307    return undefined
308  }
309}
310
311/**
312 * Whether a `Link` will take it: https (or http://localhost), printable ASCII,
313 * at most 2048 characters. Anything else would refuse the whole band, so it is
314 * drawn as text instead (a local http://clickback.test server, say).
315 */
316function isLinkable(url: URL): boolean {
317  const isAllowedScheme = url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost')
318
319  return isAllowedScheme && url.href.length <= 2048 && /^[\x21-\x7e]+$/.test(url.href)
320}
321
322/**
323 * The response, or undefined once `ms` has passed or the request failed.
324 */
325function within($: EngineInterface, ms: number, request: Promise<HttpResponse>): Promise<HttpResponse | undefined> {
326  return new Promise(resolve => {
327    const timer = $.clock.after(ms, () => resolve(undefined))
328
329    request.then(
330      response => {
331        timer.cancel()
332        resolve(response)
333      },
334      () => {
335        timer.cancel()
336        resolve(undefined)
337      },
338    )
339  })
340}
341
types/index.d.ts 26 lines
1/**
2 * One served ad, as the mod keeps it for the session in `$.state` until the
3 * person has had a chance to see it.
4 */
5export type ClickbackAd = {
6  advertiser: string
7  headline: string
8  /** The advertiser's host, shown as the link's text. */
9  host: string
10  /** The Clickback tracking URL the link opens. */
11  url: string
12  /** Whether `url` is one a `Link` accepts (https, or http://localhost). */
13  isLinkable: boolean
14  /** How many prompts the session had submitted when the ad arrived. */
15  prompt: number
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    clickback: {
21      ad: ClickbackAd | null
22      prompts: number
23    }
24  }
25}
26