Turns PRs, issues, Google Drive files, ElevenLabs agents and other things Claude names in a reply into clickable links. Deterministic: no model calls.

A Claude Code mod that turns the things Claude names in a reply into clickable links: pull requests, issues, Google Drive files, ElevenLabs agents, and anything else a tool result gave a name and a URL for.
It is deterministic. It runs no model calls, so it adds no tokens or cost per reply, and it makes no network requests.
At the prompt of a terminal session:
/plugin install reply-links --marketplace orgonag/reply-links
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
From a shell:
claude plugin install reply-links --marketplace orgonag/reply-links
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with orgonag/reply-links, then Add plugin.
Needs a Claude Code build with mods (2.1.287 or later).
| Claude writes | Becomes a link to |
|---|---|
PR 306, PR #306, pull request 306 | The pull request in the session's GitHub repository |
#298 (two digits or more) | The issue or pull request with that number (GitHub redirects an issue link to a PR) |
owner/repo#12 | That repository's issue or pull request |
A name a tool returned with a URL, such as a Drive file's name and webViewLink | That URL, on its first mention in the reply |
A Google Drive item returned with an id and mimeType but no link | https://drive.google.com/open?id=<id> |
agent_… and conv_… ElevenLabs ids, and an agent's name from a tool result | The agent or conversation in the ElevenLabs dashboard |
| A Supabase project returned with its ref and region | The project's dashboard |
The repository comes from the session folder's origin remote. Outside a GitHub repository, the mod uses the last repository it saw in a GitHub URL in tool output (for example from gh pr view or gh pr create), and when a tool printed the exact URL for a number, that URL wins.
It never changes code blocks, inline code, links Claude already wrote, bare URLs, or HTML. #1 and other single-digit numbers stay text, and so does a number with no repository to point at.
The mod hooks one event, session.append, which Claude Code raises for every row it adds to a conversation. Of those rows it changes only one kind: the text of Claude's own replies in the main conversation, where it adds markdown links. It reads tool results without changing them, and it leaves every other row alone, including your prompts, tool calls, subagent replies and rows other mods or code append. It makes no network requests and runs no commands.
In detail:
door: tool-result): it reads each result for GitHub pull request and issue URLs, and parses JSON for objects that carry a name (name, title, displayName, filename, ...) beside a web URL (webViewLink, html_url, url, link, ...) or an id it knows how to turn into one.door: response): it rewrites the text blocks of each main-conversation reply with markdown links before the reply is saved.Because the link becomes part of the reply's markdown, any renderer draws it, including the plain Claude Code renderer and mods that redraw replies such as prismantis. A reply streams in as plain text and gains its links once it finishes.
When the session starts (and after a reload) it rebuilds what it learned from the tool results already in the conversation.
claude plugin validate .
claude plugin test .
MIT
hooks/register.ts 44 lines1import type { EngineInterface, Register } from 'claude-code'
2import { emptyRegistry, harvest, linkify, repoFromRemote, resultText, type Registry } from './links'
3
4const seen: { reg: Registry } = { reg: emptyRegistry() }
5
6async function readRepo($: EngineInterface, reg: Registry) {
7 const repo = await $.session.repo()
8 reg.repo = repo?.remote ? repoFromRemote(repo.remote) : null
9}
10
11async function rebuild($: EngineInterface): Promise<Registry> {
12 const fresh = emptyRegistry()
13 const messages = await $.session.messages({ as: 'api' })
14 const tools = new Map<string, string>()
15 for (const message of messages) {
16 for (const block of message.content) {
17 if (block.type === 'tool_use') tools.set(String(block.id), String(block.name))
18 if (block.type === 'tool_result') harvest(resultText(block.content), tools.get(String(block.tool_use_id)) ?? '', fresh)
19 }
20 }
21 return fresh
22}
23
24export const register: Register = on => {
25 on('session.start', async ($, e, next) => {
26 const started = await next(e)
27 seen.reg = await rebuild($).catch(() => emptyRegistry())
28 return started
29 })
30
31 on('session.append', { door: 'tool-result' }, ($, e, next) => {
32 const tool = e.origin.kind === 'tool' ? e.origin.tool : ''
33 for (const block of e.message.content) if (block.type === 'tool_result') harvest(resultText(block.content), tool, seen.reg)
34 return next(e)
35 }).catch(($, e, next) => next(e))
36
37 on('session.append', { door: 'response' }, async ($, e, next) => {
38 if (e.agentId !== undefined || e.message.type !== 'assistant') return next(e)
39 await readRepo($, seen.reg).catch(() => undefined)
40 const content = e.message.content.map(block => (block.type === 'text' && typeof block.text === 'string' ? { ...block, text: linkify(block.text, seen.reg) } : block))
41 return next({ ...e, message: { ...e.message, content } })
42 }).catch(($, e, next) => next(e))
43}
44hooks/links.ts 149 lines1export type Registry = {
2 repo: string | null
3 seenRepo: string | null
4 names: Map<string, string>
5 numbers: Map<string, string>
6}
7
8export const emptyRegistry = (): Registry => ({ repo: null, seenRepo: null, names: new Map(), numbers: new Map() })
9
10const MAX_NAMES = 2000
11const MAX_NODES = 5000
12const NAME_KEYS = ['name', 'title', 'displayName', 'display_name', 'filename', 'fileName', 'file_name', 'label']
13const URL_KEYS = ['webViewLink', 'htmlUrl', 'html_url', 'url', 'link', 'webUrl', 'web_url', 'alternateLink', 'permalink', 'viewUrl', 'view_url', 'editUrl', 'edit_url', 'shareUrl', 'share_url', 'href']
14const GITHUB_REF = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/(?:pull|issues)\/(\d+)/g
15const GITHUB_REMOTE = /github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/
16
17export const repoFromRemote = (remote: string): string | null => {
18 const m = GITHUB_REMOTE.exec(remote.trim())
19 return m ? `${m[1]}/${m[2]}` : null
20}
21
22const isWebUrl = (v: unknown): v is string => typeof v === 'string' && /^https:\/\/[^\s<>()]+$/.test(v)
23
24const goodName = (v: unknown): v is string =>
25 typeof v === 'string' && v.length >= 4 && v.length <= 120 && !/^[\d\s.,:-]+$/.test(v) && !/[\n\r`[\]]/.test(v)
26
27const templateUrl = (o: Record<string, unknown>, tool: string): string | null => {
28 const id = typeof o.id === 'string' ? o.id : null
29 if (typeof o.agent_id === 'string' && /^agent_\w{10,}$/.test(o.agent_id)) return `https://elevenlabs.io/app/agents/agents/${o.agent_id}`
30 if (id && typeof o.mimeType === 'string') return `https://drive.google.com/open?id=${encodeURIComponent(id)}`
31 if (id && /^[a-z]{20}$/.test(id) && typeof o.region === 'string' && ('organization_id' in o || /supabase/i.test(tool))) return `https://supabase.com/dashboard/project/${id}`
32 return null
33}
34
35const remember = (reg: Registry, name: string, url: string) => {
36 if (reg.names.size >= MAX_NAMES && !reg.names.has(name)) return
37 reg.names.set(name.trim(), url)
38}
39
40const walk = (node: unknown, tool: string, reg: Registry, budget: { left: number }, depth = 0) => {
41 if (depth > 8 || budget.left-- <= 0 || node === null || typeof node !== 'object') return
42 if (Array.isArray(node)) {
43 for (const item of node) walk(item, tool, reg, budget, depth + 1)
44 return
45 }
46 const o = node as Record<string, unknown>
47 const name = NAME_KEYS.map(k => o[k]).find(goodName)
48 const url = URL_KEYS.map(k => o[k]).find(isWebUrl) ?? templateUrl(o, tool)
49 if (name && url) remember(reg, name, url)
50 for (const v of Object.values(o)) if (v && typeof v === 'object') walk(v, tool, reg, budget, depth + 1)
51}
52
53const parseJson = (text: string): unknown => {
54 const start = text.search(/[[{]/)
55 if (start < 0) return undefined
56 try {
57 return JSON.parse(text.slice(start))
58 } catch {
59 return undefined
60 }
61}
62
63export const harvest = (text: string, tool: string, reg: Registry) => {
64 for (const m of text.matchAll(GITHUB_REF)) {
65 reg.seenRepo = m[1]!
66 reg.numbers.set(`${m[1]}#${m[2]}`, m[0])
67 }
68 const json = parseJson(text)
69 if (json !== undefined) walk(json, tool, reg, { left: MAX_NODES })
70}
71
72export const resultText = (content: unknown): string =>
73 typeof content === 'string'
74 ? content
75 : Array.isArray(content)
76 ? content.map(b => (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' ? String((b as { text?: unknown }).text ?? '') : '')).join('\n')
77 : ''
78
79const PROTECTED = /(```|~~~)[\s\S]*?(?:\1|$)|`[^`\n]*`|!?\[[^\]\n]*\]\([^)\n]*\)|<[^>\n]+>|https?:\/\/[^\s<>()]+/g
80
81type Hit = { start: number; end: number; label: string; url: string }
82
83const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
84const escapeLabel = (s: string) => s.replace(/[[\]]/g, '\\$&')
85
86const githubUrl = (reg: Registry, repo: string | null, n: string, kind: 'pull' | 'issues'): string | null => {
87 const r = repo ?? reg.repo ?? reg.seenRepo
88 if (!r) return null
89 return reg.numbers.get(`${r}#${n}`) ?? `https://github.com/${r}/${kind}/${n}`
90}
91
92const RULES: ((text: string, reg: Registry) => Hit[])[] = [
93 (text, reg) =>
94 [...text.matchAll(/(?<![\w/.-])([\w.-]+\/[\w.-]+)#(\d+)\b/g)].map(m => ({ start: m.index, end: m.index + m[0].length, label: m[0], url: githubUrl(reg, m[1]!, m[2]!, 'issues')! })),
95 (text, reg) =>
96 [...text.matchAll(/\b(?:PRs?|[Pp]ull request)\s*#?(\d{1,6})\b/g)].flatMap(m => {
97 const url = githubUrl(reg, null, m[1]!, 'pull')
98 return url ? [{ start: m.index, end: m.index + m[0].length, label: m[0], url }] : []
99 }),
100 (text, reg) =>
101 [...text.matchAll(/(?<=^|[\s(])#(\d{2,6})\b(?![.,]\d)/gm)].flatMap(m => {
102 const url = githubUrl(reg, null, m[1]!, 'issues')
103 return url ? [{ start: m.index, end: m.index + m[0].length, label: m[0], url }] : []
104 }),
105 text =>
106 [...text.matchAll(/\b(agent_\w{10,}|conv_\w{10,})\b/g)].map(m => ({
107 start: m.index,
108 end: m.index + m[0].length,
109 label: m[0],
110 url: m[1]!.startsWith('agent_') ? `https://elevenlabs.io/app/agents/agents/${m[1]}` : `https://elevenlabs.io/app/agents/history/${m[1]}`,
111 })),
112]
113
114const nameHits = (text: string, reg: Registry, linked: Set<string>): Hit[] => {
115 const hits: Hit[] = []
116 const names = [...reg.names.keys()].sort((a, b) => b.length - a.length)
117 for (const name of names) {
118 if (linked.has(name)) continue
119 const m = new RegExp(`(?<![\\w/])${escapeRe(name)}(?!\\w)`).exec(text)
120 if (!m) continue
121 hits.push({ start: m.index, end: m.index + name.length, label: name, url: reg.names.get(name)! })
122 linked.add(name)
123 }
124 return hits
125}
126
127const linkPlain = (text: string, reg: Registry, linked: Set<string>): string => {
128 const all = [...RULES.flatMap(rule => rule(text, reg)), ...nameHits(text, reg, linked)].sort((a, b) => a.start - b.start || b.end - a.end)
129 let out = ''
130 let at = 0
131 for (const hit of all) {
132 if (hit.start < at) continue
133 out += text.slice(at, hit.start) + `[${escapeLabel(hit.label)}](${hit.url})`
134 at = hit.end
135 }
136 return out + text.slice(at)
137}
138
139export const linkify = (text: string, reg: Registry): string => {
140 const linked = new Set<string>()
141 let out = ''
142 let at = 0
143 for (const m of text.matchAll(PROTECTED)) {
144 out += linkPlain(text.slice(at, m.index), reg, linked) + m[0]
145 at = m.index + m[0].length
146 }
147 return out + linkPlain(text.slice(at), reg, linked)
148}
149