A Claude Code pane of your assigned Linear tickets by status; click one to load it into the session, read its details, comment, or change its status and…

A Claude Code mod that puts your assigned Linear tickets in a pane. Open one to read it, and load it into your session when you're ready to work on it.
w) loads the ticket into your session: Claude reads it with the Linear tools, summarizes it, finds the relevant code and proposes a plan.1–9 open a ticket, w works on the open ticket, o opens it in Linear, b goes back to the list, r refreshes. The list also refreshes every 5 minutes.c writes a comment (Enter posts it), m opens a status picker with the team's workflow states, p opens a priority picker, and x cancels. The ticket and the list reload after each change. These need a surface with text fields, so the mobile app shows the ticket read-only.Works in the terminal and the desktop app.
You need a connected Linear MCP server, such as the official Linear plugin. The mod uses its login, so there's no API key to set.
Load the mod in every session by adding it to ~/.claude/settings.json, then restart Claude Code:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/linear-claude-mod" } }
Or for one session: claude --plugin-dir /path/to/linear-claude-mod.
Then type /tickets.
If your Linear MCP server isn't named plugin:linear:linear (check /mcp), set the linearServer option in /config.
claude plugin validate .
claude plugin test .
The code is in hooks/: register.tsx has the command and the pane, and board.ts has the sorting and parsing helpers.
MIT
hooks/register.tsx 382 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, McpToolResult, Register, RenderNode } from 'claude-code'
3
4import type { Action, Board, Ticket, View, WorkflowState } from '../types'
5import {
6 OPEN_STATE_TYPES,
7 PRIORITY_LABEL,
8 PRIORITY_OPTIONS,
9 ago,
10 groupTickets,
11 parseDetail,
12 parseIssues,
13 parseStates,
14 workPrompt,
15} from './board'
16
17const PANE = 'linear-tickets'
18const REFRESH_MS = 5 * 60 * 1000
19
20// The Linear MCP server's name; set from the plugin's options when the module registers.
21let LINEAR = 'plugin:linear:linear'
22
23const board = atom({ plugin: 'linear-claude-mod', key: 'board' } as const, {
24 tickets: [],
25 loadedAt: null,
26 isLoading: false,
27 error: null,
28} as Board)
29
30const EMPTY_VIEW: View = { selected: null, detail: null, isLoading: false, error: null, action: null, states: null, saving: null }
31
32const view = atom({ plugin: 'linear-claude-mod', key: 'view' } as const, EMPTY_VIEW)
33
34// Each action's field, by the key the pane draws it under.
35const ACTION_KEY: Record<Action, string> = { comment: 'comment-input', move: 'status-select', priority: 'priority-select' }
36
37function textOf(result: McpToolResult): string {
38 const text = result.content.map(c => (c.type === 'text' ? c.text : '')).join('')
39 if (result.isError) throw new Error(text.slice(0, 200) || 'Linear call failed')
40 return text
41}
42
43const messageOf = (err: unknown) => (err instanceof Error ? err.message : String(err))
44
45async function refresh($: EngineInterface): Promise<void> {
46 await update($, board, b => ({ ...b, isLoading: true }))
47 try {
48 const pages = await Promise.all(
49 OPEN_STATE_TYPES.map(state =>
50 $.mcp.call(LINEAR, 'list_issues', {
51 assignee: 'me',
52 state,
53 limit: 100,
54 fields: ['id', 'title', 'status', 'statusType', 'priority', 'url', 'project', 'teamId'],
55 }),
56 ),
57 )
58 const tickets = pages.flatMap(page => parseIssues(textOf(page)))
59 const loadedAt = await $.clock.now()
60 await update($, board, () => ({ tickets, loadedAt, isLoading: false, error: null }))
61 } catch (err) {
62 await update($, board, b => ({ ...b, isLoading: false, error: messageOf(err) }))
63 }
64}
65
66async function showTicket($: EngineInterface, ticket: Ticket): Promise<void> {
67 await update($, view, () => ({ ...EMPTY_VIEW, selected: ticket, isLoading: true }))
68 await loadDetail($, ticket)
69}
70
71// Fetches the ticket's detail and comments into the view, keeping what is shown until they land.
72async function loadDetail($: EngineInterface, ticket: Ticket): Promise<void> {
73 try {
74 const [issue, comments] = await Promise.all([
75 $.mcp.call(LINEAR, 'get_issue', { id: ticket.id }),
76 $.mcp.call(LINEAR, 'list_comments', { issueId: ticket.id, orderBy: 'createdAt', limit: 50 }),
77 ])
78 const detail = parseDetail(ticket, textOf(issue), textOf(comments))
79 // A Back press (or another ticket) while this loaded wins.
80 await update($, view, v => (v.selected?.id === ticket.id ? { ...v, detail, isLoading: false } : v))
81 } catch (err) {
82 await update($, view, v => (v.selected?.id === ticket.id ? { ...v, isLoading: false, error: messageOf(err) } : v))
83 }
84}
85
86// No engine call opens a URL, so use the OS opener; a missing one rejects and the next is tried.
87const OPENERS = [['xdg-open'], ['open'], ['cmd', '/c', 'start', '']]
88
89async function openUrl($: EngineInterface, url: string): Promise<void> {
90 for (const opener of OPENERS) {
91 try {
92 await $.process.run([...opener, url], { timeoutMs: 10_000 })
93 return
94 } catch {
95 // Not on this platform; try the next.
96 }
97 }
98 $.ui.toast(`Couldn't open a browser: ${url}`)
99}
100
101const backToList = ($: EngineInterface) => update($, view, () => EMPTY_VIEW)
102
103// Runs fn on the shown ticket only if it is still the one shown.
104const onShown = (id: string, fn: (v: View) => View) => (v: View) => (v.selected?.id === id ? fn(v) : v)
105
106async function openAction($: EngineInterface, action: Action): Promise<void> {
107 const { selected, detail, states } = await read($, view)
108 if (!selected) return
109 await update($, view, v => ({ ...v, action, error: null }))
110 // Best effort: the keyboard is the person's, so this is refused when they hold it elsewhere.
111 $.ui.focus({ requestId: PANE, key: ACTION_KEY[action] }).catch(() => {})
112 if (action !== 'move' || states) return
113
114 const team = detail?.team ?? selected.team
115 try {
116 if (!team) throw new Error('this ticket has no team to list statuses for')
117 const loaded = parseStates(textOf(await $.mcp.call(LINEAR, 'list_issue_statuses', { team })))
118 await update($, view, onShown(selected.id, v => ({ ...v, states: loaded })))
119 } catch (err) {
120 await update($, view, onShown(selected.id, v => ({ ...v, action: null, error: messageOf(err) })))
121 }
122}
123
124const closeAction = ($: EngineInterface) => update($, view, v => ({ ...v, action: null }))
125
126// One write to the shown ticket: marks it saving, runs it, then reloads the ticket and the list.
127async function write(
128 $: EngineInterface,
129 saving: string,
130 run: (t: Ticket) => Promise<unknown>,
131 done: (t: Ticket) => { toast: string; patch?: Partial<Ticket> },
132): Promise<void> {
133 const { selected, saving: busy } = await read($, view)
134 if (!selected || busy) return
135 await update($, view, v => ({ ...v, saving, error: null }))
136 try {
137 await run(selected)
138 const { toast, patch } = done(selected)
139 const ticket = { ...selected, ...patch }
140 await update($, view, onShown(selected.id, v => ({
141 ...v,
142 selected: ticket,
143 detail: v.detail && { ...v.detail, ...patch },
144 action: null,
145 saving: null,
146 })))
147 void $.ui.toast(toast)
148 await Promise.all([loadDetail($, ticket), refresh($)])
149 } catch (err) {
150 await update($, view, onShown(selected.id, v => ({ ...v, saving: null, error: messageOf(err) })))
151 }
152}
153
154const postComment = ($: EngineInterface, body: string) =>
155 body.trim()
156 ? write(
157 $,
158 'Posting comment…',
159 async t => textOf(await $.mcp.call(LINEAR, 'save_comment', { issueId: t.id, body: body.trim() })),
160 t => ({ toast: `Commented on ${t.id}` }),
161 )
162 : closeAction($)
163
164async function moveTo($: EngineInterface, value: string): Promise<void> {
165 const { selected, detail, states } = await read($, view)
166 const state = states?.find(s => (s.id ?? s.name) === value)
167 if (!selected || !state || state.name === (detail ?? selected).status) return void (await closeAction($))
168 await write(
169 $,
170 `Moving to ${state.name}…`,
171 async t => textOf(await $.mcp.call(LINEAR, 'save_issue', { id: t.id, state: state.id ?? state.name })),
172 t => ({ toast: `${t.id} → ${state.name}`, patch: { status: state.name, statusType: state.type } }),
173 )
174}
175
176async function setPriority($: EngineInterface, value: string): Promise<void> {
177 const { selected } = await read($, view)
178 const priority = Number(value)
179 if (!selected || priority === selected.priority) return void (await closeAction($))
180 const name = PRIORITY_OPTIONS.find(o => o.value === value)?.label ?? value
181 await write(
182 $,
183 `Setting priority to ${name}…`,
184 async t => textOf(await $.mcp.call(LINEAR, 'save_issue', { id: t.id, priority })),
185 t => ({ toast: `${t.id} priority → ${name}`, patch: { priority } }),
186 )
187}
188
189const stateValue = (s: WorkflowState) => s.id ?? s.name
190
191export const register: Register = (on, options) => {
192 if (typeof options.linearServer === 'string' && options.linearServer) LINEAR = options.linearServer
193
194 on('session.start', async ($, e, next) => {
195 await $.command.register({
196 name: 'tickets',
197 description: 'Show my assigned Linear tickets by status',
198 })
199 $.clock.every(REFRESH_MS, async () => {
200 if ((await $.ui.panes()).some(p => p.id === PANE)) await refresh($)
201 })
202
203 return next(e)
204 })
205
206 on('command.run', { command: 'tickets' }, async $ => {
207 await backToList($)
208 const opened = await $.ui.open({ id: PANE, title: 'Linear', focus: true })
209 await refresh($)
210 const { tickets, error } = await read($, board)
211 const count = error ? `Linear: ${error}` : `${tickets.length} open Linear tickets.`
212
213 return { text: opened.isPlaced ? count : `${count} Pane not shown: ${opened.reason}` }
214 })
215
216 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
217 const ui = $.ui.resolve(e)
218 const { Box, Text, Button, Markdown } = ui
219 // Mobile draws no Input or Select yet, so it gets no actions.
220 const fields = 'Input' in ui && 'Select' in ui ? { Input: ui.Input, Select: ui.Select } : null
221 const now = await $.clock.now()
222 const {
223 selected,
224 detail,
225 isLoading: isDetailLoading,
226 error: detailError,
227 action,
228 states,
229 saving,
230 } = await read($, view)
231
232 if (selected) {
233 const t = detail ?? selected
234 const meta = [
235 t.status,
236 PRIORITY_LABEL[t.priority]?.trim() || null,
237 t.project,
238 detail?.assignee,
239 detail?.updatedAt ? `updated ${ago(now - Date.parse(detail.updatedAt))}` : null,
240 ].filter(Boolean)
241
242 return (
243 <Box flexDirection="column" gap={1}>
244 <Box justifyContent="space-between">
245 <Button key="back" plain hotkey="b" label="Back to list" onPress={() => void backToList($)} />
246 <Box gap={2}>
247 <Button
248 key="work"
249 plain
250 hotkey="w"
251 label="Work on this"
252 onPress={() => {
253 void $.prompt.submit({ text: workPrompt(selected), asUser: true })
254 $.ui.toast(`Loading ${selected.id} into the session`)
255 }}
256 />
257 <Button key="open" plain hotkey="o" label="Open in Linear" onPress={() => void openUrl($, t.url)} />
258 </Box>
259 </Box>
260 <Box flexDirection="column">
261 <Text bold>
262 {t.id} {t.title}
263 </Text>
264 <Text dimColor>{meta.join(' · ')}</Text>
265 {detail && detail.labels.length > 0 && <Text color="magenta">{detail.labels.join(', ')}</Text>}
266 {detail?.branch && <Text dimColor>branch {detail.branch}</Text>}
267 </Box>
268 {fields && (
269 <Box flexDirection="column">
270 <Box gap={2}>
271 <Button key="act:comment" plain hotkey="c" label="Comment" onPress={() => void openAction($, 'comment')} />
272 <Button key="act:move" plain hotkey="m" label="Move" onPress={() => void openAction($, 'move')} />
273 <Button key="act:priority" plain hotkey="p" label="Priority" onPress={() => void openAction($, 'priority')} />
274 {action && <Button key="act:cancel" plain hotkey="x" label="Cancel" onPress={() => void closeAction($)} />}
275 </Box>
276 {saving && <Text dimColor>{saving}</Text>}
277 {!saving && action === 'comment' && (
278 <fields.Input
279 key={ACTION_KEY.comment}
280 label="Comment: "
281 placeholder="Markdown; Enter posts it"
282 submitLabel="post"
283 autoFocus
284 onSubmit={value => void postComment($, value)}
285 />
286 )}
287 {!saving && action === 'move' && !states && <Text dimColor>Loading statuses…</Text>}
288 {!saving && action === 'move' && states && states.length > 0 && (
289 <fields.Select
290 key={ACTION_KEY.move}
291 label="Move to: "
292 options={states.map(s => ({ value: stateValue(s), label: s.name }))}
293 value={stateValue(states.find(s => s.name === t.status) ?? states[0]!)}
294 autoFocus
295 onSelect={value => void moveTo($, value)}
296 />
297 )}
298 {!saving && action === 'priority' && (
299 <fields.Select
300 key={ACTION_KEY.priority}
301 label="Priority: "
302 options={PRIORITY_OPTIONS}
303 value={String(t.priority)}
304 autoFocus
305 onSelect={value => void setPriority($, value)}
306 />
307 )}
308 </Box>
309 )}
310 {isDetailLoading && <Text dimColor>Loading ticket…</Text>}
311 {detailError && <Text color="red">Linear: {detailError}</Text>}
312 {detail && (
313 <Markdown key="description" text={detail.description.trim() || '_No description._'} />
314 )}
315 {detail && (
316 <Box flexDirection="column">
317 <Text bold>Comments ({detail.comments.length})</Text>
318 {detail.comments.length === 0 && <Text dimColor>None yet.</Text>}
319 {detail.comments.map((c, i) => (
320 <Box key={`c:${i}`} flexDirection="column" marginTop={1}>
321 <Text color="cyan">
322 {c.author}
323 <Text dimColor>{c.createdAt ? ` ${ago(now - Date.parse(c.createdAt))}` : ''}</Text>
324 </Text>
325 <Markdown key={`cm:${i}`} text={c.body} />
326 </Box>
327 ))}
328 </Box>
329 )}
330 </Box>
331 )
332 }
333
334 const { tickets, loadedAt, isLoading, error } = await read($, board)
335 const cols = e.props.bodyColumns || e.viewport?.columns || 80
336 const room = Math.max(3, (e.viewport?.rows ?? 24) - 3)
337 const groups = groupTickets(tickets)
338
339 const rows: RenderNode[] = []
340 let hotkey = 1
341 for (const g of groups) {
342 if (rows.length >= room) break
343 rows.push(
344 <Text key={`h:${g.status}`} bold color={g.tickets[0]?.statusType === 'started' ? 'cyan' : undefined}>
345 {g.status} ({g.tickets.length})
346 </Text>,
347 )
348 for (const t of g.tickets) {
349 if (rows.length >= room) break
350 const key = hotkey <= 9 ? String(hotkey++) : undefined
351 const label = `${PRIORITY_LABEL[t.priority] ?? ' '} ${t.id} ${t.title}`
352 rows.push(
353 <Button
354 key={t.id}
355 plain
356 hotkey={key}
357 dimColor={t.statusType === 'backlog'}
358 label={label.length > cols - 6 ? `${label.slice(0, cols - 7)}…` : label}
359 onPress={() => void showTicket($, t)}
360 />,
361 )
362 }
363 }
364 const hidden = tickets.length + groups.length - rows.length
365
366 return (
367 <Box flexDirection="column">
368 <Box justifyContent="space-between">
369 <Text dimColor>
370 {isLoading ? 'Refreshing…' : loadedAt ? `${tickets.length} open · ${ago(now - loadedAt)}` : ''}
371 </Text>
372 <Button key="refresh" plain hotkey="r" label="Refresh" onPress={() => void refresh($)} />
373 </Box>
374 {error && <Text color="red">Linear: {error}</Text>}
375 {!isLoading && !error && loadedAt && tickets.length === 0 && <Text dimColor>No open tickets assigned to you.</Text>}
376 {rows}
377 {hidden > 0 && <Text dimColor>+{hidden} more</Text>}
378 </Box>
379 )
380 })
381}
382hooks/board.ts 145 lines1import type { Ticket, TicketComment, TicketDetail, WorkflowState } from '../types'
2
3// The open state types, fetched one call each so finished tickets never eat the page limit.
4export const OPEN_STATE_TYPES = ['started', 'unstarted', 'triage', 'backlog'] as const
5
6export type Group = { status: string; tickets: Ticket[] }
7
8// Lower is more actionable: what you're on, then what's ready to pick up, then
9// what's waiting on someone else (review / QA), then the unsorted piles.
10export function statusRank(t: Pick<Ticket, 'status' | 'statusType'>): number {
11 if (t.statusType === 'started' && /in progress/i.test(t.status)) return 0
12 if (t.statusType === 'unstarted') return 1
13 if (t.statusType === 'started') return 2
14 if (t.statusType === 'triage') return 3
15 if (t.statusType === 'backlog') return 4
16 return 5
17}
18
19// Linear priority: 1 Urgent .. 4 Low, 0 None sorts last.
20const priorityRank = (p: number) => (p === 0 ? 5 : p)
21
22export function groupTickets(tickets: readonly Ticket[]): Group[] {
23 const sorted = [...tickets].sort(
24 (a, b) =>
25 statusRank(a) - statusRank(b) ||
26 a.status.localeCompare(b.status) ||
27 priorityRank(a.priority) - priorityRank(b.priority),
28 )
29 const groups: Group[] = []
30 for (const t of sorted) {
31 const last = groups[groups.length - 1]
32 if (last?.status === t.status) last.tickets.push(t)
33 else groups.push({ status: t.status, tickets: [t] })
34 }
35 return groups
36}
37
38type RawIssue = {
39 id: string
40 title: string
41 status: string
42 statusType: string
43 priority?: { value: number } | null
44 url: string
45 project?: string | null
46 team?: string | null
47 teamId?: string | null
48}
49
50export function parseIssues(text: string): Ticket[] {
51 const body = JSON.parse(text) as { issues?: RawIssue[] }
52 return (body.issues ?? []).map(i => ({
53 id: i.id,
54 title: i.title,
55 status: i.status,
56 statusType: i.statusType,
57 priority: i.priority?.value ?? 0,
58 url: i.url,
59 project: i.project ?? null,
60 team: i.teamId ?? i.team ?? null,
61 }))
62}
63
64export const PRIORITY_LABEL: Record<number, string> = {
65 1: 'URG',
66 2: 'HI ',
67 3: 'MED',
68 4: 'LOW',
69 0: ' ',
70}
71
72export function workPrompt(t: Ticket): string {
73 return [
74 `Let's work on Linear ticket ${t.id}: "${t.title}" (${t.status}${t.project ? `, ${t.project}` : ''}).`,
75 t.url,
76 '',
77 'Load it with the Linear tools: the full description, comments, sub-issues and any linked PRs or attachments. ' +
78 'Summarize what is being asked and what is still open, find the relevant code, then propose a plan before changing anything.',
79 ].join('\n')
80}
81
82export function ago(ms: number): string {
83 const s = Math.round(ms / 1000)
84 if (s < 60) return 'just now'
85 if (s < 3600) return `${Math.round(s / 60)}m ago`
86 if (s < 86400) return `${Math.round(s / 3600)}h ago`
87 return `${Math.round(s / 86400)}d ago`
88}
89
90const nameOf = (v: unknown): string | null =>
91 typeof v === 'string' ? v : v && typeof v === 'object' && 'name' in v ? String((v as { name: unknown }).name) : null
92
93// get_issue and list_comments bodies; the list row supplies what get_issue spells differently.
94export function parseDetail(ticket: Ticket, issueText: string, commentsText: string): TicketDetail {
95 const issue = JSON.parse(issueText) as Record<string, unknown>
96 const raw = (JSON.parse(commentsText) as { comments?: Record<string, unknown>[] }).comments ?? []
97 const comments: TicketComment[] = raw
98 .map(c => ({
99 author: nameOf(c.author) ?? nameOf(c.user) ?? 'Someone',
100 body: String(c.body ?? ''),
101 createdAt: String(c.createdAt ?? ''),
102 }))
103 .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
104 const labels = Array.isArray(issue.labels) ? issue.labels.map(nameOf).filter((l): l is string => !!l) : []
105
106 return {
107 ...ticket,
108 title: typeof issue.title === 'string' ? issue.title : ticket.title,
109 status: typeof issue.status === 'string' ? issue.status : ticket.status,
110 description: typeof issue.description === 'string' ? issue.description : '',
111 assignee: nameOf(issue.assignee),
112 labels,
113 branch: typeof issue.gitBranchName === 'string' ? issue.gitBranchName : null,
114 updatedAt: typeof issue.updatedAt === 'string' ? issue.updatedAt : null,
115 team: typeof issue.teamId === 'string' ? issue.teamId : nameOf(issue.team) ?? ticket.team,
116 comments,
117 }
118}
119
120// Workflow order, as Linear's own status menu lists them.
121const STATE_TYPE_ORDER = ['triage', 'backlog', 'unstarted', 'started', 'completed', 'canceled', 'duplicate']
122const typeRank = (type: string) => {
123 const i = STATE_TYPE_ORDER.indexOf(type)
124 return i === -1 ? STATE_TYPE_ORDER.length : i
125}
126
127// list_issue_statuses answers a bare array; older servers wrap it as { statuses }.
128export function parseStates(text: string): WorkflowState[] {
129 const body = JSON.parse(text) as unknown
130 const raw = (Array.isArray(body) ? body : ((body as { statuses?: unknown[] })?.statuses ?? [])) as Record<string, unknown>[]
131 return raw
132 .filter(s => typeof s.name === 'string')
133 .map((s, i) => ({ s: { id: typeof s.id === 'string' ? s.id : null, name: String(s.name), type: String(s.type ?? '') }, i }))
134 .sort((a, b) => typeRank(a.s.type) - typeRank(b.s.type) || a.i - b.i)
135 .map(({ s }) => s)
136}
137
138export const PRIORITY_OPTIONS = [
139 { value: '1', label: 'Urgent' },
140 { value: '2', label: 'High' },
141 { value: '3', label: 'Medium' },
142 { value: '4', label: 'Low' },
143 { value: '0', label: 'No priority' },
144] as const
145types/index.d.ts 54 lines1export type Ticket = {
2 id: string
3 title: string
4 status: string
5 statusType: string
6 priority: number
7 url: string
8 project: string | null
9 // The team's id when Linear gave one, else its name; what list_issue_statuses takes.
10 team: string | null
11}
12
13export type Board = {
14 tickets: Ticket[]
15 loadedAt: number | null
16 isLoading: boolean
17 error: string | null
18}
19
20export type TicketComment = { author: string; body: string; createdAt: string }
21
22export type TicketDetail = Ticket & {
23 description: string
24 assignee: string | null
25 labels: string[]
26 branch: string | null
27 updatedAt: string | null
28 comments: TicketComment[]
29}
30
31export type WorkflowState = { id: string | null; name: string; type: string }
32
33// The open action on the shown ticket: writing a comment, or picking a status or priority.
34export type Action = 'comment' | 'move' | 'priority'
35
36// What the pane shows: the list, or one ticket (selected) once its detail has loaded.
37export type View = {
38 selected: Ticket | null
39 detail: TicketDetail | null
40 isLoading: boolean
41 error: string | null
42 action: Action | null
43 // The team's workflow states, loaded when the status picker first opens.
44 states: WorkflowState[] | null
45 // A write in flight ("Posting comment…"), shown while it runs.
46 saving: string | null
47}
48
49declare module 'claude-code' {
50 interface PluginState {
51 'linear-claude-mod': { board: Board; view: View }
52 }
53}
54