Your open pump.fun positions in a pane while Claude works, and a markdown trade journal that fills itself. Read-only, no keys, no trading

Two things for people who trade on pump.fun and use Claude Code.
A pane that shows your open positions and how much each is up or down, and opens by itself while Claude is working on something else. And a markdown trade journal that fills itself from on-chain data, with a skill that teaches Claude to help you keep it honest.
It is read-only. It uses your public wallet address, no private key, and it cannot sign, send or sell anything. There are no sell buttons and there will not be.
/positions opens the pane. It also opens on its own when a turn runs longer than 10 seconds, and closes when the turn ends. A header names the columns: Token, Value, PnL $, PnL %, Wallet. Each row is a position on any chain pump.fun tracks, with its unrealized profit or loss against your cost basis, in green or red, and the wallet it sits in. A total closes the list. Dollar values are hidden until you press Reveal, so a screen share shows percentages only. It refreshes while it is open.
/journal checks your wallet against the journal and prints what is new, what disagrees, and what is open. /journal write appends the new closed trades to this month's file, rebuilds an open-positions snapshot and saves the threads under your own callouts. /journal write 7 looks back 7 days instead of 30.
The pumpfun-journal skill is what Claude follows while you do this. The main rule: it never invents a thesis, an outcome or a mistake. Those lines stay not recorded until you say something.


These are demo positions (demo setting, or PUMPFUN_DEMO=1 claude): made up, no wallet needed. A first look takes one command.
You need Claude Code 2.1.287 or later (the version that introduced mods) and Python 3.
claude plugin marketplace add KamiFin/pumpfun-utilities
claude plugin install pumpfun-utilities@pumpfun-utilities
claude plugin configure pumpfun-utilities@pumpfun-utilities
Then start a new session. If the pane never appears, mods may be switched off for installed plugins in your account's rollout. Setting CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the env block of ~/.claude/settings.json turns them on.
Nothing is hardcoded. Everything personal is a plugin setting, filled in with claude plugin configure or /config.
| Setting | Required | What it is |
|---|---|---|
wallet | yes, unless demo | Your main wallet address. Public, read-only. |
demo | no | Show made-up positions in the pane, with no wallet and no network call. For a first look or a screenshot that shows nothing personal. |
mainLabel | no | The name shown for your main wallet in the Wallet column, for example your pump.fun username. Default main. |
extraWallets | no | Other wallets as label=address,label=address. |
journalDir | no | Folder for the journal. Empty means ~/pumpfun-journal. Point it at an Obsidian vault if you like. |
heliusKey | no | A Helius API key, only for the rewards section of /journal. Marked sensitive, so it goes to secure storage, not settings.json. |
refreshSeconds | no | Pane refresh, default 30. Below about 20 pump.fun starts answering 429. |
minSeconds | no | How long a turn runs before the pane opens, default 10. |
minUsd | no | Hide positions worth less than this, default 2. |
The script also runs on its own, outside Claude Code, with the same settings as environment variables: PUMPFUN_WALLET, PUMPFUN_EXTRA_WALLETS, PUMPFUN_JOURNAL_DIR, PUMPFUN_HELIUS_KEY.
PUMPFUN_WALLET=<address> python3 scripts/pumpfun_journal.py # dry run
PUMPFUN_WALLET=<address> python3 scripts/pumpfun_journal.py --write # append new trades
One markdown file per month, one block per closed position, identified by ca: <mint>. It is append-only: the script never edits a block you or Claude wrote. A block looks like this:
## 2026-10-03 EXAMPLE "Example Coin" (Solana, pump.fun) [CLOSED, auto-populated: 2 buy(s), 1 sell(s)]
ca: <mint>
source: pump.fun trades API, auto-populated by pumpfun_journal.py, not manually verified
size: $150.22
entry: $41.2K mc
exit: $38.9K mc
pnl: $-13.72 (-9.1%)
thesis: not recorded
outcome: not recorded
mistake: not recorded
Entry and exit are rebuilt from the on-chain buy and sell legs, not from the API's own aggregate, which hides rebuys. Rewards and airdrops are reported separately and never mixed into trade profit. skills/pumpfun-journal/patterns-template.md is a starting point for a rules file you can check open positions against.
The data comes from pump.fun's frontend API. It is public and needs no key, but it is not documented, can change without notice and rate-limits you. If it breaks, this breaks. Check pump.fun's terms of use before you rely on it.
A Claude Code mod runs with the same access Claude Code has on your machine. Read the source before you load one. This one is a few hundred lines, makes requests only to pump.fun and, if you set a key, Helius, and writes only to your journal folder.
This is a journal and a viewer, not advice. Nothing here tells you what to buy or sell.
claude plugin validate .
claude plugin test .
python3 -m unittest discover -s tests
The mod is in hooks/, the journal script in scripts/, the skill in skills/. Contributions that keep it read-only are welcome.
MIT.
hooks/register.tsx 193 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { columns, headerCells, parsePositions, rowCells, totalCells } from './parse'
5
6const PANE = 'positions-pane'
7
8const rows = atom({ plugin: 'pumpfun-utilities', key: 'rows' } as const, [])
9const failed = atom({ plugin: 'pumpfun-utilities', key: 'failed' } as const, [])
10const isBusy = atom({ plugin: 'pumpfun-utilities', key: 'isBusy' } as const, false)
11const problem = atom({ plugin: 'pumpfun-utilities', key: 'problem' } as const, null)
12const updatedAt = atom({ plugin: 'pumpfun-utilities', key: 'updatedAt' } as const, '')
13const isRevealed = atom({ plugin: 'pumpfun-utilities', key: 'isRevealed' } as const, false)
14const isOpen = atom({ plugin: 'pumpfun-utilities', key: 'isOpen' } as const, false)
15const isAuto = atom({ plugin: 'pumpfun-utilities', key: 'isAuto' } as const, false)
16const turn = atom({ plugin: 'pumpfun-utilities', key: 'turn' } as const, '')
17
18// Top-level on purpose: the validator only lets `$` be passed to functions declared here.
19// The script is read-only in this mode: no journal, no snapshot, no wallet address printed.
20type Settings = { env: Record<string, string>; minUsd: number; mainLabel: string }
21
22const SCRIPT = 'scripts/pumpfun_journal.py'
23
24async function refresh($: EngineInterface, cfg: Settings) {
25 // A run can outlast the interval; never stack a second one on top of it.
26 if (await read($, isBusy)) return
27
28 await update($, isBusy, () => true)
29
30 try {
31 const out = await $.process.run(
32 ['python3', `${$.plugin.root}/${SCRIPT}`, '--open-json', '--min-usd', String(cfg.minUsd)],
33 { env: cfg.env, timeoutMs: 45_000 },
34 )
35 const parsed = out.exitCode === 0 ? parsePositions(out.stdout) : null
36
37 if (parsed) {
38 await update($, rows, () => parsed.rows)
39 await update($, failed, () => parsed.failed)
40 await update($, problem, () => null)
41 await update($, updatedAt, () => new Date().toTimeString().slice(0, 5))
42 } else {
43 await update($, problem, () => `positions failed (exit ${out.exitCode}): ${out.stderr.trim().slice(0, 100)}`)
44 }
45 } catch {
46 await update($, problem, () => 'python3 or the journal script is not reachable')
47 } finally {
48 await update($, isBusy, () => false)
49 }
50}
51
52async function show($: EngineInterface, cfg: Settings, isAutomatic: boolean) {
53 await update($, isOpen, () => true)
54 await update($, isAuto, () => isAutomatic)
55 await $.ui.open({ id: PANE, title: 'Positions' })
56 await refresh($, cfg)
57}
58
59// The pane opens only if the turn that armed the timer is still the running one.
60async function openIfStillWorking($: EngineInterface, cfg: Settings, id: string) {
61 if ((await read($, turn)) === id && !(await read($, isOpen))) {
62 await show($, cfg, true)
63 }
64}
65
66export const register: Register = (on, options) => {
67 // Settings reach the script as environment variables, so no secret sits in a command line.
68 const env: Record<string, string> = { PUMPFUN_WALLET: String(options.wallet ?? '') }
69
70 if (options.demo) env.PUMPFUN_DEMO = '1'
71
72 if (options.extraWallets) env.PUMPFUN_EXTRA_WALLETS = String(options.extraWallets)
73 if (options.journalDir) env.PUMPFUN_JOURNAL_DIR = String(options.journalDir)
74 if (options.heliusKey) env.PUMPFUN_HELIUS_KEY = String(options.heliusKey)
75
76 const afterMs = Math.max(1, Number(options.minSeconds)) * 1000
77 const cfg: Settings = { env, minUsd: Number(options.minUsd), mainLabel: String(options.mainLabel || 'main') }
78 const everyMs = Math.max(5, Number(options.refreshSeconds)) * 1000
79
80 on('session.start', async ($, e, next) => {
81 await $.command.register({
82 name: 'positions',
83 description: 'Open the positions pane: open Solana positions and their profit or loss',
84 })
85 // Polls only while the pane is open, so an idle session makes no calls.
86 await $.command.register({
87 name: 'journal',
88 description: 'Check the pump.fun journal: /journal for a dry run, /journal write to append new trades, optional number of days',
89 })
90 $.clock.every(everyMs, async () => {
91 if (await read($, isOpen)) {
92 await refresh($, cfg)
93 }
94 })
95
96 return next(e)
97 })
98
99 on('turn.start', async ($, e, next) => {
100 await update($, turn, () => e.turnId)
101 $.clock.after(afterMs, () => openIfStillWorking($, cfg, e.turnId))
102
103 return next(e)
104 })
105
106 on('turn.complete', async ($, e, next) => {
107 if (!e.agentId) {
108 await update($, turn, () => '')
109
110 if ((await read($, isOpen)) && (await read($, isAuto))) {
111 await update($, isOpen, () => false)
112 await $.ui.close({ id: PANE })
113 }
114 }
115
116 return next(e)
117 })
118
119 on('command.run', { command: 'positions' }, async $ => {
120 await show($, cfg, false)
121
122 return { text: 'Positions pane opened.' }
123 })
124
125 on('command.run', { command: 'journal' }, async ($, e) => {
126 const words = e.args.split(/\s+/).filter(Boolean)
127 const days = words.find(word => /^\d{1,3}$/.test(word))
128 const args = [`${$.plugin.root}/${SCRIPT}`, ...(words.includes('write') ? ['--write'] : [])]
129
130 if (days) args.push('--since-days', days)
131
132 const out = await $.process.run(['python3', ...args], { env: cfg.env, timeoutMs: 600_000 })
133 const text = out.exitCode === 0 ? out.stdout : `journal failed (exit ${out.exitCode}): ${out.stderr.trim()}`
134
135 // The tail holds the verdict (NEW, MISMATCH, what was written); a long run is cut at the front.
136 return { text: text.length > 8000 ? `...\n${text.slice(-8000)}` : text }
137 })
138
139 on('ui.close', { id: PANE }, async ($, e, next) => {
140 await update($, isOpen, () => false)
141
142 return next(e)
143 })
144
145 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
146 const { Box, Button, Text } = $.ui.resolve(e)
147 const list = await read($, rows)
148 const trouble = await read($, problem)
149 const at = await read($, updatedAt)
150 const missing = await read($, failed)
151 const revealed = await read($, isRevealed)
152 const sum = { upnl: list.reduce((acc, row) => acc + row.upnl, 0) }
153 const tint = (n: number) => (n >= 0 ? 'green' : 'red')
154 const cols = columns(revealed)
155 // One row of fixed-width boxes; numbers sit at the right edge of their box.
156 const table = (cells: string[], color: string | undefined, isBold: boolean) => (
157 <Box columnGap={1}>
158 {cells.map((cell, i) => (
159 <Box key={cols[i].label} width={cols[i].width} justifyContent={cols[i].isRight ? 'flex-end' : 'flex-start'}>
160 <Text color={color} dimColor={color === undefined} bold={isBold}>
161 {cell}
162 </Text>
163 </Box>
164 ))}
165 </Box>
166 )
167
168 return (
169 <Box flexDirection="column">
170 {trouble && <Text color="red">{trouble}</Text>}
171 {missing.length > 0 && <Text color="yellow">Could not read: {missing.join(', ')}</Text>}
172 {list.length === 0 && !trouble && <Text dimColor>No open positions above the dust floor.</Text>}
173 {list.length > 0 && table(headerCells(revealed), undefined, false)}
174 {list.map(row => (
175 <Box key={`${row.wallet}-${row.chain}-${row.symbol}`}>
176 {table(rowCells(row, revealed, cfg.mainLabel), tint(row.upnl), false)}
177 </Box>
178 ))}
179 {list.length > 0 && table(totalCells(list, revealed), tint(sum.upnl), true)}
180 <Box>
181 <Text dimColor>updated {at || 'never'}, every {Math.round(everyMs / 1000)}s </Text>
182 <Button
183 key="reveal"
184 label={revealed ? 'Hide values' : 'Reveal values'}
185 onPress={() => update($, isRevealed, v => !v)}
186 />
187 <Button key="close" label="Close" onPress={() => $.ui.close({ id: PANE })} />
188 </Box>
189 </Box>
190 )
191 })
192}
193hooks/parse.ts 94 lines1import type { Position } from '../types'
2
3// Reads the JSON `python3 scripts/pumpfun-trades.py --open-json` prints:
4// {rows: [{symbol, mint, chain, wallet, value, cost, upnl, mc}], failed: [wallet labels]}.
5// Anything else is a failed run, not an empty list.
6export function parsePositions(text: string): { rows: Position[]; failed: string[] } | null {
7 let data: any
8
9 try {
10 data = JSON.parse(text)
11 } catch {
12 return null
13 }
14
15 if (!data || !Array.isArray(data.rows)) {
16 return null
17 }
18
19 const rows = data.rows
20 .filter((row: any) => row && typeof row.symbol === 'string' && typeof row.value === 'number')
21 .map((row: any) => ({
22 symbol: row.symbol,
23 chain: typeof row.chain === 'string' ? row.chain : '',
24 wallet: typeof row.wallet === 'string' ? row.wallet : '',
25 value: row.value,
26 cost: typeof row.cost === 'number' ? row.cost : 0,
27 upnl: typeof row.upnl === 'number' ? row.upnl : row.value - (row.cost ?? 0),
28 }))
29 const failed = Array.isArray(data.failed) ? data.failed.map(String) : []
30
31 return { rows, failed }
32}
33
34// The wallet column: the script calls the main wallet "main"; the user's own name for it is a setting.
35export function walletName(row: Position, mainLabel: string): string {
36 return row.wallet === 'main' || row.wallet === '' ? mainLabel : row.wallet
37}
38
39export type Column = { label: string; width?: number; isRight: boolean }
40
41// Columns are drawn as boxes of a fixed width in character cells, not as space-padded text: a
42// surface with a proportional font (the desktop app) lines boxes up where padding would not.
43// With values hidden only the percentage shows.
44export function columns(revealed: boolean): Column[] {
45 const wallet = { label: 'Wallet', isRight: false }
46 const token = { label: 'Token', width: 10, isRight: false }
47 const pct = { label: 'PnL %', width: 9, isRight: true }
48
49 return revealed
50 ? [token, { label: 'Value', width: 9, isRight: true }, { label: 'PnL $', width: 10, isRight: true }, pct, wallet]
51 : [token, pct, wallet]
52}
53
54function pick(revealed: boolean, symbol: string, value: string, pnl: string, pct: string, wallet: string): string[] {
55 return revealed ? [symbol, value, pnl, pct, wallet] : [symbol, pct, wallet]
56}
57
58export function headerCells(revealed: boolean): string[] {
59 return columns(revealed).map(column => column.label)
60}
61
62export function rowCells(row: Position, revealed: boolean, mainLabel: string): string[] {
63 return pick(revealed, row.symbol, money(row.value), money(row.upnl), percent(row.upnl, row.cost), walletName(row, mainLabel))
64}
65
66export function totalCells(rows: Position[], revealed: boolean): string[] {
67 const sum = totals(rows)
68
69 return pick(revealed, 'Total', money(sum.value), money(sum.upnl), percent(sum.upnl, sum.cost), '')
70}
71
72export function money(n: number): string {
73 const sign = n < 0 ? '-' : ''
74 const abs = Math.abs(n)
75
76 return `${sign}$${abs >= 1000 ? Math.round(abs).toLocaleString('en-US') : abs.toFixed(abs >= 100 ? 0 : 2)}`
77}
78
79// Profit or loss against what was paid; a position with no cost basis has no percent.
80export function percent(upnl: number, cost: number): string {
81 if (cost <= 0) return 'n/a'
82
83 const value = (upnl / cost) * 100
84
85 return `${value >= 0 ? '+' : ''}${value.toFixed(1)}%`
86}
87
88export function totals(rows: Position[]) {
89 const value = rows.reduce((sum, row) => sum + row.value, 0)
90 const cost = rows.reduce((sum, row) => sum + row.cost, 0)
91
92 return { value, cost, upnl: value - cost }
93}
94types/index.d.ts 25 lines1export type Position = {
2 symbol: string
3 chain: string
4 wallet: string
5 value: number
6 cost: number
7 upnl: number
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'pumpfun-utilities': {
13 rows: Position[]
14 failed: string[]
15 isBusy: boolean
16 problem: string | null
17 updatedAt: string
18 isRevealed: boolean
19 isOpen: boolean
20 isAuto: boolean
21 turn: string
22 }
23 }
24}
25