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. Connect each computer once, from…

newbandpromptnetworktimer
v0.3.0no licenseupdated 2026-10-15clickbackads/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 whole line is a link to the ad, so clicking anywhere on it opens it. 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. Check with claude --version.

Install

You need a Clickback account at https://clickback.dev. In Claude Code:

/plugin marketplace add clickbackads/plugin
/plugin install clickback@clickback

Connecting this computer

Until this computer is connected, the line above the prompt asks you to connect it:

 Clickback · Connect this computer to start earning: https://clickback.dev/link (code ABCD-EFGH)
  1. Click the line; the whole of it is a link to the Connect page. If your terminal doesn't make it clickable, go to the address and type the code.
  2. Sign in to Clickback if you need to, check the code matches, and press Connect.

The line goes within a few seconds, and ads start after your next turn. Every session on the computer shows the same code, and connecting once covers them all. A code lasts 15 minutes; after that, the next turn shows a new one.

To disconnect a computer, use the Connect your agent page of your Clickback dashboard. The next turn then asks you to connect again.

The account tools

The plugin also has an MCP server with tools you use by asking Claude. They sign in separately, with Claude Code's own /mcp:

  1. Run /mcp.
  2. Pick plugin:clickback:clickback and choose Authenticate.
  3. Your browser opens Clickback. Sign in if you need to, then press Connect.

In the Claude Desktop app, which has no /mcp panel, run claude mcp login plugin:clickback:clickback in a terminal instead. Ads don't need this sign-in.

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 first time the ad is drawn, it tells Clickback, and that's when you're paid for it. Requests run after the turn has finished, so they never hold up Claude, and each gives up after 3 seconds. It asks at most once a minute, counted across your terminal sessions (the Desktop app keeps its own count), and Clickback spaces ads out further for your whole account, 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 the account tools: earnings, get_profile, update_profile, recent_ads and report_ad.
  • 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".

What it stores and sends

The mod keeps these in the plugin's own store, a JSON file under ~/.claude/plugins/store/ that every session on the computer shares and other mods can't read:

  • token: the ad token you got by connecting. It fetches ads and confirms them, and nothing else. It can't read your earnings or change your account.
  • link: the code waiting for you to press Connect, while there is one.
  • linkPolledAt: when any session last checked that code, so open sessions take turns checking it rather than all asking at once.
  • lastAskedAt: when any session last asked for an ad.

It sends Clickback:

  • To connect: which app you're in (claude-code, or claude-code-desktop in the Desktop app), then the link's device code every few seconds until you press Connect. However many sessions are open, one of them checks each time.
  • For an ad: the ad token, surface=mod, which app you're in and the session id.
  • When it draws the ad: the ad token, to that ad's address on the Clickback server.

Privacy

Your prompts, Claude's replies, the transcript and your working directory never leave your machine. The mod adds nothing to what Claude reads, never changes Claude's messages and touches no tool call. Its requests are plain HTTP from the mod itself, not tool calls, so they never go through Claude or a permission prompt.

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

If no ads show

Most turns show nothing on purpose: the plugin asks at most once a minute and Clickback leaves a couple of minutes between ads.

The plugin never adds lines to your transcript. It writes what happened at each step to Claude Code's debug log, led by clickback: for example asking for an ad, got ad 41 from shipfast.example.com and confirmed ad 41 as shown, charged: yes, or why it didn't ask or what failed. Start Claude Code with claude --debug to write that log. The Desktop app doesn't write one.

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. If you're done with it for good, also disconnect the computer on the Connect your agent page, so its ad token stops working.

Testing

claude plugin validate plugins/clickback
claude plugin test plugins/clickback
Source 2 files
hooks/clickback.ts 901 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// Linking: the mod fetches ads over plain HTTP ($.http.fetch) with an ad token
9// of its own, kept in $.store. It isn't a tool call, so no permission mode
10// stops it. Until this computer has a token, the band shows a link and a code
11// to approve in the browser, and the mod polls until the person does. The
12// pending link sits in $.store too, so every session shows the same code, and
13// the sessions take turns polling it.
14//
15// Paying: the server only charges for an ad, and credits you, once the mod
16// says it drew it. The first time the band actually draws an ad, the mod
17// confirms it with one request to the ad's shown_url.
18//
19// It asks at most once a minute, across the Claude Code sessions that share
20// this plugin's $.store (the time of the last ask is kept there). Terminal
21// sessions share one store; the Desktop app loads the plugin its own way and
22// keeps another. A turn that ends sooner shows nothing new.
23//
24// Privacy: it sends Clickback the session id, surface=mod and which app you
25// are in. Your prompts, Claude's replies, the transcript and your working
26// directory never leave this machine.
27//
28// It never gets in the way: requests run on timers after the turn has
29// finished, give up after a few seconds, and any failure (no ad, a slow or
30// broken server, an answer it doesn't understand) shows no ad. The reason goes
31// to the debug log (`claude --debug`), never the transcript.
32
33import type { EngineInterface, On, PluginOptions, RenderSurface, Timer } from 'claude-code'
34
35import type { ClickbackAd, ClickbackLink } from '../types'
36
37/** The ad on screen, held by the host so a hot reload keeps it. */
38const AD = { plugin: 'clickback', key: 'ad' } as const
39
40/** How many prompts this session has submitted so far. */
41const PROMPTS = { plugin: 'clickback', key: 'prompts' } as const
42
43/** The line asking the person to connect this computer, while a link is pending. */
44const LINK = { plugin: 'clickback', key: 'link' } as const
45
46export const LABEL = 'Sponsored'
47export const CONNECT = 'Clickback · Connect this computer to start earning:'
48export const SURFACE = 'mod'
49export const TIMEOUT_MS = 3000
50
51/** At most one ad request this often, from every session on the computer. */
52export const INTERVAL_MS = 60_000
53
54/**
55 * The longest `Link` href, percent-encoded. A longer one keeps the whole band
56 * from drawing, so an ad or approval page with one isn't shown.
57 */
58export const MAX_HREF = 2048
59
60/** How many times a draw may try to confirm one ad. */
61export const CONFIRM_TRIES = 3
62
63/** The $.store key holding when any session last asked, in epoch milliseconds. */
64export const LAST_ASKED = 'lastAskedAt'
65
66/** The $.store key holding the ad token. */
67export const TOKEN = 'token'
68
69/** The $.store key holding the link waiting for approval, a PendingLink. */
70export const PENDING_LINK = 'link'
71
72/**
73 * The $.store key holding when any session last polled the pending link, as
74 * `{ deviceCode, at }` (epoch milliseconds), so open sessions take turns.
75 */
76export const LINK_POLLED = 'linkPolledAt'
77
78/**
79 * A poll goes out once the last one, from any session, is at least the
80 * link's interval less this old, so one session always gets through each
81 * interval though their timers drift.
82 */
83export const POLL_SLACK_MS = 1000
84
85/** A link waiting for the person to approve it, as kept in $.store. */
86export type PendingLink = {
87  deviceCode: string
88  userCode: string
89  /** verification_uri, shown in the connect line. */
90  uri: string
91  /** verification_uri_complete, which the link opens. */
92  url: string
93  /** When it stops working, in epoch milliseconds. */
94  expiresAt: number
95  /** How often to poll, in seconds. */
96  interval: number
97}
98
99type Served = Omit<ClickbackAd, 'prompt'>
100
101type Answer = { status: number; data: Record<string, unknown> | undefined }
102
103/** Where Clickback lives, from the `base_url` option, with no trailing slash. */
104let baseUrl = 'https://clickback.dev'
105
106/** Only one serve request at a time. A reload resets it, which is harmless. */
107let isServing = false
108
109/** Only one link started at a time. */
110let isLinking = false
111
112/** The timer polling the pending link. A reload stops it, and session.start starts another. */
113let polling: Timer | undefined
114
115/** Only one poll at a time. */
116let isPolling = false
117
118/**
119 * Each drawn ad's confirmation in this module instance, by shown_url: being
120 * sent, done, or how many tries have failed.
121 */
122const confirmations = new Map<string, 'sending' | 'done' | number>()
123
124export function register(on: On, options: PluginOptions) {
125  baseUrl = typeof options.base_url === 'string' ? options.base_url.replace(/\/+$/, '') : baseUrl
126
127  on('session.start', async ($, e, next) => {
128    const result = await next(e)
129
130    // Ask to connect straight away, not only after a turn, or pick up a link
131    // another session started. On a timer, where work that outlives an event
132    // belongs.
133    $.clock.after(0, () => void start($))
134
135    return result
136  })
137
138  on('turn.complete', async ($, e, next) => {
139    const result = await next(e)
140
141    // Main-loop turns that finished normally. Subagents, interrupts, refusals
142    // and API errors get nothing. The request runs on a timer, after this hook
143    // has returned, so the session never waits for Clickback.
144    if (e.agentId === undefined && e.reason === 'answer') {
145      $.clock.after(0, () => void serve($))
146    }
147
148    return result
149  })
150
151  on('prompt.submit', async ($, e, next) => {
152    await clearSeen($)
153
154    return next(e)
155  })
156
157  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
158    // Read first, so the band redraws when an ad or the connect line arrives or goes.
159    const { value: ad } = await $.state.get(AD)
160    const { value: link } = await $.state.get(LINK)
161
162    if ((!ad && !link) || e.props.hasSurvey || e.props.isWorking || e.props.view.agentId !== undefined) {
163      return next(e)
164    }
165
166    const { Box, Text, Link } = $.ui.resolve(e)
167
168    // The whole line is one Link, so a click anywhere on it opens the ad (or
169    // the approval page). Where a surface won't open the address (the Desktop
170    // app takes https and http://localhost) it draws the text plain and the
171    // rest of the band stands.
172    let line: ReturnType<typeof Link> | undefined
173
174    if (ad) {
175      line = Link({ href: ad.url, children: `${LABEL} · ${ad.advertiser}: ${ad.headline} → ${ad.host}` })
176    } else if (link) {
177      line = Link({ href: link.url, children: `${CONNECT} ${link.label} (code ${link.code})` })
178    }
179
180    const tree = Box({
181      flexDirection: 'column',
182      children: [
183        Box({
184          paddingX: 1,
185          // Wraps rather than cuts, so a long ad shows whole.
186          children: Text({ dimColor: true, wrap: 'wrap', children: line }),
187        }),
188        await next(e),
189      ],
190    })
191
192    if (ad) {
193      drawn($, ad)
194    }
195
196    return tree
197  })
198}
199
200/**
201 * On session start: where the band draws and this computer isn't linked,
202 * shows the connect line, reusing a link another session started. Never throws.
203 */
204async function start($: EngineInterface): Promise<void> {
205  try {
206    if (agentFor(await $.session.surfaces()) === undefined) {
207      return
208    }
209
210    if ((await tokenOf($)) !== undefined) {
211      // Linked, maybe by another session while this module reloaded.
212      await clearLine($)
213
214      return
215    }
216
217    await startLinking($)
218  } catch (error) {
219    debug($, `starting failed: ${messageOf(error)}`)
220  }
221}
222
223/**
224 * Asks Clickback for an ad with the ad token and keeps it for the band, or
225 * starts linking when there's no token. Never throws.
226 */
227export async function serve($: EngineInterface): Promise<void> {
228  if (isServing) {
229    return
230  }
231
232  isServing = true
233
234  try {
235    // Only where someone will see it: a terminal or the desktop app, the two
236    // surfaces that draw AbovePrompt. Empty under `claude -p` and the SDK.
237    const surfaces = await $.session.surfaces()
238    const agent = agentFor(surfaces)
239
240    if (agent === undefined) {
241      debug($, `nothing here draws the band (${surfaces.join(', ') || 'no surface'}), so not asking`)
242
243      return
244    }
245
246    // An ad is still up, or one landed after the last prompt went in and hasn't
247    // been on screen yet. Show that one instead of paying for another.
248    const { value: held } = await $.state.get(AD)
249
250    if (held) {
251      debug($, 'the last ad has not been seen yet, so not asking')
252
253      return
254    }
255
256    const token = await tokenOf($)
257
258    if (token === undefined) {
259      debug($, 'this computer is not linked yet, so not asking')
260      await startLinking($)
261
262      return
263    }
264
265    // Once a minute at most, counting every session on this computer.
266    const now = await $.clock.now()
267
268    if (!(await isDue($, now))) {
269      debug($, 'asked less than a minute ago, so not asking now')
270
271      return
272    }
273
274    // Claim the minute before asking, so another session finishing now waits.
275    // Two that read the store at the same moment can both ask; the server
276    // answers the second with nothing.
277    await $.store.set(LAST_ASKED, now)
278
279    debug($, 'asking for an ad')
280
281    const answer = await send($, 'asking for an ad', `${baseUrl}/api/ads/serve`, {
282      surface: SURFACE,
283      agent,
284      session_id: await $.session.id(),
285    }, token)
286
287    if (answer === undefined) {
288      return
289    }
290
291    if (answer.status === 401) {
292      debug($, 'the ad token was refused (HTTP 401), so linking again')
293      await forget($, token)
294      await startLinking($)
295
296      return
297    }
298
299    if (answer.status !== 200) {
300      debug($, `asking for an ad failed: HTTP ${answer.status}`)
301
302      return
303    }
304
305    if (answer.data?.ad === null) {
306      debug($, 'no ad this time')
307
308      return
309    }
310
311    const ad = adOf(answer.data)
312
313    if (ad === undefined) {
314      debug($, 'the answer had no ad the band could show')
315
316      return
317    }
318
319    const { value: prompts = 0 } = await $.state.get(PROMPTS)
320
321    await $.state.set(AD, { ...ad, prompt: prompts })
322    debug($, `got ad ${ad.impressionId} from ${ad.host}`)
323  } catch (error) {
324    debug($, `asking for an ad failed: ${messageOf(error)}`)
325  } finally {
326    isServing = false
327  }
328}
329
330/**
331 * Shows the connect line and polls until the link is approved, denied or
332 * expired. Reuses the link in $.store until it expires, so every session
333 * shows the same code; otherwise asks Clickback for a new one. Never throws.
334 */
335async function startLinking($: EngineInterface): Promise<void> {
336  if (isLinking || polling !== undefined) {
337    return
338  }
339
340  isLinking = true
341
342  try {
343    // Only where someone will see the code, and only while there's no token
344    // (another session may have stored one after a 401 here).
345    const agent = agentFor(await $.session.surfaces())
346
347    if (agent === undefined || (await tokenOf($)) !== undefined) {
348      return
349    }
350
351    const now = await $.clock.now()
352    let pending = pendingOf(await $.store.get(PENDING_LINK))
353
354    if (pending !== undefined && pending.expiresAt > now) {
355      debug($, `using the link already started, code ${pending.userCode}`)
356    } else {
357      debug($, 'linking this computer to Clickback')
358
359      const answer = await send($, 'starting a link', `${baseUrl}/api/link`, { agent })
360
361      if (answer === undefined) {
362        return
363      }
364
365      pending = answer.status === 201 ? linkOf(answer.data, now) : undefined
366
367      if (pending === undefined) {
368        debug($, answer.status === 201 ? 'starting a link failed: the answer was incomplete' : `starting a link failed: HTTP ${answer.status}`)
369
370        return
371      }
372
373      await $.store.set(PENDING_LINK, pending)
374    }
375
376    await showLine($, pending)
377    debug($, `showing the code ${pending.userCode}`)
378
379    polling = $.clock.every(pending.interval * 1000, () => void poll($))
380  } catch (error) {
381    debug($, `starting a link failed: ${messageOf(error)}`)
382  } finally {
383    isLinking = false
384  }
385}
386
387/**
388 * One poll of the pending link: stores the token once it's approved, and
389 * stops when it's denied or expired, or another session has linked. Never throws.
390 */
391async function poll($: EngineInterface): Promise<void> {
392  if (isPolling) {
393    return
394  }
395
396  isPolling = true
397
398  try {
399    if ((await tokenOf($)) !== undefined) {
400      debug($, 'another session linked this computer')
401      await stopLinking($)
402
403      return
404    }
405
406    const pending = pendingOf(await $.store.get(PENDING_LINK))
407
408    if (pending === undefined) {
409      // Another session dropped it. A later turn starts a new one.
410      debug($, 'the link is gone, so not polling any more')
411      await stopLinking($)
412
413      return
414    }
415
416    const now = await $.clock.now()
417
418    if (pending.expiresAt <= now) {
419      debug($, `code ${pending.userCode} expired before it was approved`)
420      await stopLinking($, pending)
421
422      return
423    }
424
425    // Another session may have started a newer link: show its code.
426    await showLine($, pending)
427
428    // Every open session polls on its own timer, and the server limits polls
429    // per IP address, so they take turns: skip this one if any session polled
430    // this link less than an interval ago. Stamp before asking, so the next
431    // session to tick sees it. Two that tick at the same moment can both ask.
432    if (!(await isPollDue($, pending, now))) {
433      return
434    }
435
436    await $.store.set(LINK_POLLED, { deviceCode: pending.deviceCode, at: now })
437
438    const answer = await send($, 'checking the link', `${baseUrl}/api/link/token`, { device_code: pending.deviceCode })
439
440    if (answer === undefined) {
441      return
442    }
443
444    if (answer.status === 202) {
445      debug($, `code ${pending.userCode} is still waiting to be approved`)
446
447      return
448    }
449
450    if (answer.status === 200) {
451      const token = answer.data?.token
452
453      if (typeof token !== 'string' || token === '') {
454        debug($, 'checking the link failed: the answer had no token')
455
456        return
457      }
458
459      await $.store.set(TOKEN, token)
460      await stopLinking($, pending)
461      debug($, 'linked this computer')
462
463      return
464    }
465
466    if (answer.status === 403) {
467      debug($, `code ${pending.userCode} was denied`)
468      await stopLinking($, pending)
469
470      return
471    }
472
473    if (answer.status === 410) {
474      // A link answers once. If another session got the token, this is it.
475      if ((await tokenOf($)) !== undefined) {
476        debug($, 'another session linked this computer')
477        await stopLinking($)
478      } else {
479        debug($, `code ${pending.userCode} expired before it was approved`)
480        await stopLinking($, pending)
481      }
482
483      return
484    }
485
486    debug($, `checking the link failed: HTTP ${answer.status}`)
487  } catch (error) {
488    debug($, `checking the link failed: ${messageOf(error)}`)
489  } finally {
490    isPolling = false
491  }
492}
493
494/**
495 * Stops polling and takes the connect line down. With `pending`, also drops
496 * that link from $.store, unless another session has already replaced it.
497 */
498async function stopLinking($: EngineInterface, pending?: PendingLink): Promise<void> {
499  polling?.cancel()
500  polling = undefined
501
502  if (pending !== undefined && pendingOf(await $.store.get(PENDING_LINK))?.deviceCode === pending.deviceCode) {
503    await $.store.delete(PENDING_LINK)
504    await $.store.delete(LINK_POLLED)
505  }
506
507  await clearLine($)
508}
509
510/**
511 * Whether it's this session's turn to poll `pending`: no session has polled
512 * it within its interval (less POLL_SLACK_MS). A stamp for another link, or
513 * one in the future (a clock that moved back), doesn't hold it back.
514 */
515async function isPollDue($: EngineInterface, pending: PendingLink, now: number): Promise<boolean> {
516  const polled = recordOf(await $.store.get(LINK_POLLED))
517  const at = Number(polled?.at)
518
519  if (polled?.deviceCode !== pending.deviceCode || !Number.isFinite(at) || at > now) {
520    return true
521  }
522
523  return now - at >= pending.interval * 1000 - POLL_SLACK_MS
524}
525
526/**
527 * Puts the connect line for `pending` in the band, unless it's already there.
528 */
529async function showLine($: EngineInterface, pending: PendingLink): Promise<void> {
530  const url = urlOf(pending.url)
531
532  if (url === undefined) {
533    return
534  }
535
536  const line: ClickbackLink = { url: url.href, label: pending.uri, code: pending.userCode }
537  const { value: shown } = await $.state.get(LINK)
538
539  if (shown?.url !== line.url || shown.code !== line.code || shown.label !== line.label) {
540    await $.state.set(LINK, line)
541  }
542}
543
544async function clearLine($: EngineInterface): Promise<void> {
545  const { value: shown } = await $.state.get(LINK)
546
547  if (shown) {
548    await $.state.set(LINK, null)
549  }
550}
551
552/**
553 * Called each time the band returns the ad's tree. The first time, confirms
554 * the ad was drawn. A render hook may not write $.state, and shouldn't wait
555 * on the network, so the request runs on a timer. A failed confirmation is
556 * tried again on a later draw, CONFIRM_TRIES times at most.
557 */
558function drawn($: EngineInterface, ad: ClickbackAd): void {
559  const tries = confirmations.get(ad.shownUrl) ?? 0
560
561  if (typeof tries !== 'number' || tries >= CONFIRM_TRIES) {
562    return
563  }
564
565  confirmations.set(ad.shownUrl, 'sending')
566
567  try {
568    $.clock.after(0, () => void confirm($, ad, tries))
569  } catch (error) {
570    confirmations.set(ad.shownUrl, tries)
571    debug($, `confirming ad ${ad.impressionId} failed: ${messageOf(error)}`)
572  }
573}
574
575/**
576 * Tells Clickback the ad was drawn, which is what it charges and credits for.
577 * Never throws.
578 */
579async function confirm($: EngineInterface, ad: ClickbackAd, tries: number): Promise<void> {
580  let outcome: 'done' | number = tries + 1
581
582  try {
583    const token = await tokenOf($)
584
585    if (token === undefined) {
586      debug($, `confirming ad ${ad.impressionId} failed: this computer is not linked`)
587
588      return
589    }
590
591    const answer = await send($, `confirming ad ${ad.impressionId}`, ad.shownUrl, {}, token)
592
593    if (answer === undefined) {
594      return
595    }
596
597    if (answer.status === 200) {
598      outcome = 'done'
599      debug($, `confirmed ad ${ad.impressionId} as shown, charged: ${answer.data?.charged === true ? 'yes' : 'no'}`)
600    } else if (answer.status === 410) {
601      outcome = 'done'
602      debug($, `ad ${ad.impressionId} expired before it was confirmed (HTTP 410)`)
603    } else if (answer.status === 404) {
604      outcome = 'done'
605      debug($, `Clickback doesn't know ad ${ad.impressionId} (HTTP 404)`)
606    } else if (answer.status === 401) {
607      outcome = 'done'
608      debug($, 'the ad token was refused (HTTP 401), so linking again')
609      await forget($, token)
610      await startLinking($)
611    } else {
612      debug($, `confirming ad ${ad.impressionId} failed: HTTP ${answer.status}`)
613    }
614  } catch (error) {
615    debug($, `confirming ad ${ad.impressionId} failed: ${messageOf(error)}`)
616  } finally {
617    confirmations.set(ad.shownUrl, outcome)
618  }
619}
620
621/**
622 * POSTs JSON to Clickback and reads the JSON answer, or logs why there is
623 * none and resolves undefined. Gives up after TIMEOUT_MS; a late answer is
624 * logged and ignored.
625 */
626async function send($: EngineInterface, what: string, url: string, body: Record<string, unknown>, token?: string): Promise<Answer | undefined> {
627  const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' }
628
629  if (token !== undefined) {
630    headers.Authorization = `Bearer ${token}`
631  }
632
633  const request = $.http.fetch(url, { method: 'POST', headers, body: JSON.stringify(body) })
634  const outcome = await within($, TIMEOUT_MS, request)
635
636  if ('error' in outcome) {
637    debug($, `${what} failed: ${outcome.error}`)
638
639    if (outcome.isLate) {
640      void late($, what, request)
641    }
642
643    return undefined
644  }
645
646  let data: unknown
647
648  try {
649    data = JSON.parse(outcome.value.text)
650  } catch {
651    data = undefined
652  }
653
654  return { status: outcome.value.status, data: recordOf(data) }
655}
656
657/**
658 * Logs how a request the mod stopped waiting for ended. Never throws.
659 */
660async function late($: EngineInterface, what: string, request: Promise<{ status: number }>): Promise<void> {
661  try {
662    const response = await request
663
664    debug($, `${what}: answered after the limit (HTTP ${response.status}), so it was ignored`)
665  } catch (error) {
666    debug($, `${what} failed: ${messageOf(error)}`)
667  }
668}
669
670/**
671 * Whether a minute has passed since any session on this computer last asked.
672 * A time in the future (a clock that moved back) doesn't block forever.
673 */
674async function isDue($: EngineInterface, now: number): Promise<boolean> {
675  const last = Number(await $.store.get(LAST_ASKED))
676
677  if (!Number.isFinite(last) || last > now) {
678    return true
679  }
680
681  return now - last >= INTERVAL_MS
682}
683
684async function tokenOf($: EngineInterface): Promise<string | undefined> {
685  const token = await $.store.get(TOKEN)
686
687  return typeof token === 'string' && token !== '' ? token : undefined
688}
689
690/**
691 * Deletes a token the server refused, unless another session has already
692 * stored a new one.
693 */
694async function forget($: EngineInterface, token: string): Promise<void> {
695  if ((await $.store.get(TOKEN)) === token) {
696    await $.store.delete(TOKEN)
697  }
698}
699
700/**
701 * A line in the debug log (`claude --debug`), led by the plugin's name. Never
702 * the transcript: the mod adds nothing there.
703 */
704function debug($: EngineInterface, text: string): void {
705  try {
706    $.ui.log(text, { to: 'debug' })
707  } catch {
708    // Never in the way.
709  }
710}
711
712function messageOf(error: unknown): string {
713  return error instanceof Error ? error.message : String(error)
714}
715
716/**
717 * Takes the ad down when the next prompt goes in, unless the ad arrived after
718 * the last prompt, in which case it waits for the end of that turn.
719 */
720async function clearSeen($: EngineInterface): Promise<void> {
721  try {
722    const { value: prompts = 0 } = await $.state.get(PROMPTS)
723    const { value: ad } = await $.state.get(AD)
724
725    if (ad && ad.prompt <= prompts) {
726      await $.state.set(AD, null)
727    }
728
729    await $.state.set(PROMPTS, prompts + 1)
730  } catch {
731    // Never in the way.
732  }
733}
734
735/**
736 * What to report as the agent, or undefined where the band isn't drawn.
737 */
738export function agentFor(surfaces: readonly RenderSurface[]): string | undefined {
739  if (surfaces.includes('desktop')) {
740    return 'claude-code-desktop'
741  }
742
743  if (surfaces.includes('terminal')) {
744    return 'claude-code'
745  }
746
747  return undefined
748}
749
750/**
751 * The parts of a serve answer the band needs, cleaned up, or undefined when
752 * there's no whole ad in it. The server answers `{"ad": {...}}`, or
753 * `{"ad": null}` when there's nothing to show. The ad is confirmed with the
754 * token at shown_url, so that has to be on the Clickback server itself.
755 */
756export function adOf(data: unknown, base = baseUrl): Served | undefined {
757  const fields = recordOf(recordOf(data)?.ad)
758
759  if (fields === undefined) {
760    return undefined
761  }
762
763  const advertiser = clean(fields.advertiser, 60)
764  const headline = clean(fields.headline, 140)
765  const host = clean(fields.destination_host, 100)
766  const url = urlOf(fields.tracking_url)
767  const shownUrl = urlOf(fields.shown_url)
768  const impressionId = typeof fields.impression_id === 'number' || typeof fields.impression_id === 'string'
769    ? clean(String(fields.impression_id), 40)
770    : ''
771
772  if (advertiser === '' || headline === '' || host === '' || url === undefined || impressionId === '') {
773    return undefined
774  }
775
776  if (shownUrl === undefined || shownUrl.origin !== urlOf(base)?.origin) {
777    return undefined
778  }
779
780  return { advertiser, headline, host, url: url.href, impressionId, shownUrl: shownUrl.href }
781}
782
783/**
784 * The link from a `POST /api/link` answer, or undefined when it isn't whole.
785 */
786function linkOf(data: unknown, now: number): PendingLink | undefined {
787  const fields = recordOf(data)
788  const expiresIn = Number(fields?.expires_in)
789  const interval = Number(fields?.interval ?? 5)
790
791  if (!(expiresIn > 0)) {
792    return undefined
793  }
794
795  return pendingOf({
796    deviceCode: fields?.device_code,
797    userCode: clean(fields?.user_code, 20),
798    uri: clean(fields?.verification_uri, 200),
799    url: fields?.verification_uri_complete,
800    expiresAt: now + expiresIn * 1000,
801    interval: Number.isFinite(interval) ? Math.min(Math.max(interval, 1), 60) : 5,
802  })
803}
804
805/**
806 * A pending link as kept in $.store, or undefined when it isn't one.
807 */
808function pendingOf(value: unknown): PendingLink | undefined {
809  const link = recordOf(value)
810
811  if (
812    link === undefined
813    || typeof link.deviceCode !== 'string' || link.deviceCode === ''
814    || typeof link.userCode !== 'string' || link.userCode === ''
815    || typeof link.uri !== 'string' || link.uri === ''
816    || urlOf(link.url) === undefined
817    || typeof link.expiresAt !== 'number' || !Number.isFinite(link.expiresAt)
818    || typeof link.interval !== 'number' || !(link.interval >= 1)
819  ) {
820    return undefined
821  }
822
823  return {
824    deviceCode: link.deviceCode,
825    userCode: link.userCode,
826    uri: link.uri,
827    url: String(link.url),
828    expiresAt: link.expiresAt,
829    interval: link.interval,
830  }
831}
832
833function recordOf(value: unknown): Record<string, unknown> | undefined {
834  return typeof value === 'object' && value !== null && !Array.isArray(value)
835    ? (value as Record<string, unknown>)
836    : undefined
837}
838
839/**
840 * One line of plain text: no control or bidi characters, single spaces, at
841 * most `max` characters.
842 */
843export function clean(value: unknown, max: number): string {
844  if (typeof value !== 'string') {
845    return ''
846  }
847
848  const text = value
849    .replace(/[\u0000-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g, ' ')
850    .replace(/\s+/g, ' ')
851    .trim()
852
853  return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
854}
855
856/**
857 * An http or https URL with no credentials, at most MAX_HREF characters once
858 * percent-encoded, or undefined. Every URL the mod links to or sends to.
859 */
860function urlOf(value: unknown): URL | undefined {
861  if (typeof value !== 'string') {
862    return undefined
863  }
864
865  try {
866    const url = new URL(value)
867
868    if (url.protocol !== 'https:' && url.protocol !== 'http:') {
869      return undefined
870    }
871
872    if (url.username !== '' || url.password !== '') {
873      return undefined
874    }
875
876    return url.href.length <= MAX_HREF ? url : undefined
877  } catch {
878    return undefined
879  }
880}
881
882/**
883 * The answer, or why there is none: it failed, or `ms` passed first (`isLate`).
884 */
885function within<T>($: EngineInterface, ms: number, request: Promise<T>): Promise<{ value: T } | { error: string; isLate?: true }> {
886  return new Promise(resolve => {
887    const timer = $.clock.after(ms, () => resolve({ error: `no answer within ${ms}ms`, isLate: true }))
888
889    request.then(
890      value => {
891        timer.cancel()
892        resolve({ value })
893      },
894      error => {
895        timer.cancel()
896        resolve({ error: messageOf(error) })
897      },
898    )
899  })
900}
901
types/index.d.ts 42 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 at the end of the line. */
9  host: string
10  /** The Clickback tracking URL the link opens, at most 2048 characters. */
11  url: string
12  /** How many prompts the session had submitted when the ad arrived. */
13  prompt: number
14  /** The impression's id on the server. */
15  impressionId: string
16  /** Where the mod confirms the ad was drawn, on the Clickback server. */
17  shownUrl: string
18}
19
20/**
21 * The line asking the person to connect this computer, while a link waits for
22 * approval. The device code stays in `$.store`, which other plugins can't read.
23 */
24export type ClickbackLink = {
25  /** verification_uri_complete: the approval page with the code filled in, which the link opens. */
26  url: string
27  /** verification_uri, shown in the line, so it can be typed with the code. */
28  label: string
29  /** The code the page shows, for the person to check, e.g. ABCD-EFGH. */
30  code: string
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    clickback: {
36      ad: ClickbackAd | null
37      prompts: number
38      link: ClickbackLink | null
39    }
40  }
41}
42