A Links pane on the right of Claude Code listing your project's artifacts and important links, read from dashboard/links.json in the project; /links prints…

A Links pane on the right of Claude Code with your project's artifacts and important links, so you never hunt for them in the chat history.
dashboard/links.json in the project you are working in and re-reads it every 30 seconds, so a link Claude adds shows up on its own./links opens the pane and prints the list in the transcript.Put a dashboard/links.json in your project. Groups of items, each with a title, a URL and a short note:
{
"groups": [
{ "title": "Hub", "items": [
{ "title": "Command Center", "url": "https://claude.ai/artifact/...", "note": "private; today, to-do, calendar" }
]},
{ "title": "Repo", "items": [
{ "title": "GitHub", "url": "https://github.com/you/your-repo", "note": "private, branch main" }
]}
]
}
A project without the file shows "Could not read dashboard/links.json" and nothing else; add the file and the pane fills within half a minute.
/plugin install project-links --marketplace RenegadeRocks/claude-mods
Press y to add the marketplace the first time and pick the user scope. The pane opens at the start of every session on a wide enough terminal; /links opens it anywhere.
hooks/register.tsx 95 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { LinkGroup } from '../types'
5
6// A pane on the right listing the project's artifacts and important links.
7// Source of truth: dashboard/links.json in the repo (Claude updates it when an
8// artifact is created). The pane re-reads the file every 30 seconds.
9
10const PANE = 'project-links'
11const FILE = 'dashboard/links.json'
12
13const groups = atom({ plugin: 'project-links', key: 'groups' } as const, [] as LinkGroup[])
14const loadedAt = atom({ plugin: 'project-links', key: 'loadedAt' } as const, '')
15const error = atom({ plugin: 'project-links', key: 'error' } as const, '')
16const raw = atom({ plugin: 'project-links', key: 'raw' } as const, '')
17
18// The file is read relative to the working directory; when a command has moved the shell into a
19// sub-folder, walk up to four levels to find the project's own links file.
20async function readLinks($: any): Promise<string> {
21 let lastErr: any = null
22 for (const prefix of ['', '../', '../../', '../../../', '../../../../']) {
23 try { return await $.fs.read(prefix + FILE) } catch (e) { lastErr = e }
24 }
25 throw lastErr
26}
27
28async function load($: any) {
29 try {
30 const text: string = await readLinks($)
31 const before = await read($, raw)
32 if (text === before) return
33 const parsed = JSON.parse(text) as { groups?: LinkGroup[] }
34 await update($, groups, () => Array.isArray(parsed.groups) ? parsed.groups : [])
35 await update($, raw, () => text)
36 await update($, loadedAt, () => new Date().toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }))
37 await update($, error, () => '')
38 } catch (e: any) {
39 await update($, error, () => 'Could not read ' + FILE + ': ' + (e && e.message ? e.message : String(e)))
40 }
41}
42
43export const register: Register = on => {
44 on('session.start', async ($, e, next) => {
45 await $.command.register({ name: 'links', description: 'Open the project links pane and list the links' })
46 await load($)
47 void $.ui.open({ id: PANE, title: 'Links' })
48 $.clock.every(30000, () => { void load($) })
49 return next(e)
50 })
51
52 on('command.run', { command: 'links' }, async $ => {
53 await load($)
54 await $.ui.open({ id: PANE, title: 'Links' })
55 const list = await read($, groups)
56 const lines: string[] = []
57 for (const g of list) {
58 lines.push(g.title)
59 for (const it of g.items) lines.push(' ' + it.title + ': ' + it.url)
60 }
61 return { text: lines.length ? lines.join('\n') : 'No links yet; dashboard/links.json is empty.' }
62 })
63
64 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
65 const { Box, Text, Link, Button } = $.ui.resolve(e)
66 const list = await read($, groups)
67 const when = await read($, loadedAt)
68 const err = await read($, error)
69 const wide = (e.viewport?.columns ?? 40) >= 60
70
71 return (
72 <Box flexDirection="column">
73 {err !== '' && <Text color="red">{err}</Text>}
74 {list.length === 0 && err === '' && <Text dimColor>No links yet.</Text>}
75 {list.map(g => (
76 <Box key={g.title} flexDirection="column" marginBottom={1}>
77 <Text bold>{g.title}</Text>
78 {g.items.map(it => (
79 <Box key={it.url} flexDirection="column">
80 <Box flexDirection="row">
81 <Link href={it.url} label={it.title} />
82 <Text> </Text>
83 <Button onPress={press => { void $.ui.copy({ text: it.url, surface: press.surface }); $.ui.toast('Copied: ' + it.title) }}>copy</Button>
84 </Box>
85 {wide && it.note ? <Text dimColor> {it.note}</Text> : null}
86 </Box>
87 ))}
88 </Box>
89 ))}
90 <Text dimColor>{when ? 'from dashboard/links.json · ' + when : ''} · /links to print</Text>
91 </Box>
92 )
93 })
94}
95types/index.d.ts 9 lines1export type LinkItem = { title: string; url: string; note?: string }
2export type LinkGroup = { title: string; items: LinkItem[] }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'project-links': { groups: LinkGroup[]; loadedAt: string; error: string; raw: string }
7 }
8}
9