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.

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.
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:
settings.json.https://clickback.dev.Then restart Claude Code or run /reload-plugins.
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.<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.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.
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.
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.
hooks/clickback.ts 341 lines1// 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}
341types/index.d.ts 26 lines1/**
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