/snippet drafts your daily Slack update from Linear + GitHub and posts it on one press

Seven Claude Code mods (function-hook plugins) that wire your terminal into Slack, Linear, GitHub and Datadog through the Composio CLI.
Demo: video/out.mp4. The video is built with fframes; its source is in video/.
/reviews lists your open PRs waiting on reviewers, with a Slack nudge button per reviewer./snippet drafts your daily Slack update from the last 24h of Linear + GitHub and posts it on one press./standup pane, looks up failing-test errors in Datadog ("seen before"), and sets your Slack status while the agent is busy.composio CLI call into a status-line sparkline (/pulse for details) and blocks re-linking a toolkit that's already connected./plugin marketplace add tridha643/claude-composio-mods
/plugin install ticket-band@claude-composio-mods
Install whichever mods you want the same way. To hack on them instead, clone the repo and run claude --plugin-dir plugins/<mod>.
~/.composio/composio and logged in, with the toolkits each mod uses connected (Slack, Linear, GitHub, Datadog).gh, logged in, for review-chaser and snippet.review-chaser and snippet search PRs in the ComposioHQ org; change the query in hooks/register.tsx for your own org.Set these from the /plugin config menu:
composio-daily → standup_channel: the Slack channel the standup Post button sends to.snippet → channel: the Slack channel /snippet posts to.ticket-band → team_keys: comma-separated Linear team keys to look for in branch names. Empty matches any KEY-123.composio-pulse → search_budget_ms: marks the median search time ✓ or ✗ against this budget. 0 hides the mark.An empty channel hides that Post button.
hooks/register.tsx 113 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const GH = 'gh'
5let channel = '' // userConfig.channel, set in register; empty hides the Post button
6const PANE = 'snippet'
7
8const draft = atom({ plugin: 'snippet', key: 'draft' } as const, null)
9const phase = atom({ plugin: 'snippet', key: 'phase' } as const, 'drafting')
10const note = atom({ plugin: 'snippet', key: 'note' } as const, '')
11
12const LINEAR_TODAY = `query { viewer { assignedIssues(first: 30, filter: { updatedAt: { gt: "-P1D" } }) {
13 nodes { identifier title url state { name } } } } }`
14
15const system = () => `You write the user's daily Slack update for ${channel || 'their team channel'}.
16Style: lowercase, casual, terse, like an engineer posting in their team channel. No headings, no emoji walls, no fluff, no "excited to".
17Shape: 2-5 short bullet lines covering shipped, in progress, and next or blockers. Skip a category with nothing in it.
18Link PRs as [repo#N](url) and tickets as [TEAM-N](url). Only state facts present in the input; never invent numbers or outcomes.
19Output the message text only.`
20
21async function composio($: EngineInterface, slug: string, data: object) {
22 const { stdout } = await $.process.run([`${await $.env.get('HOME')}/.composio/composio`, 'execute', slug, '-d', JSON.stringify(data)], { timeoutMs: 60_000 })
23 const out = JSON.parse(stdout)
24 if (!out.successful) throw new Error(`${slug}: ${out.error}`)
25 return out.data
26}
27
28async function gather($: EngineInterface) {
29 const since = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
30 const query = `query { search(query: "is:pr author:@me org:ComposioHQ updated:>=${since}", type: ISSUE, first: 30) {
31 nodes { ... on PullRequest { number title url state isDraft mergedAt repository { name } } } } }`
32 const [gh, linear] = await Promise.all([
33 $.process.run([GH, 'api', 'graphql', '-f', `query=${query}`]),
34 composio($, 'LINEAR_RUN_QUERY_OR_MUTATION', { query_or_mutation: LINEAR_TODAY }),
35 ])
36 const prs = gh.exitCode === 0 ? JSON.parse(gh.stdout).data.search.nodes : []
37 return { prs, tickets: linear.data.viewer.assignedIssues.nodes }
38}
39
40async function redraft($: EngineInterface, extra: string) {
41 await update($, phase, () => 'drafting')
42 await update($, note, () => 'pulling Linear + GitHub from the last 24h…')
43 try {
44 const facts = await gather($)
45 const prompt = `PRs touched in the last 24h:\n${JSON.stringify(facts.prs, null, 1)}\n\nLinear tickets updated in the last 24h:\n${JSON.stringify(facts.tickets, null, 1)}${extra ? `\n\nExtra notes from the user (include these):\n${extra}` : ''}`
46 const reply = await $.model.complete({ model: 'sonnet', system: system(), prompt, maxTokens: 800, effort: 'low' })
47 if (!reply.isAnswered) throw new Error(`model: ${reply.reason}`)
48 await update($, draft, () => reply.text.trim())
49 await update($, phase, () => 'ready')
50 await update($, note, () => `${facts.prs.length} PRs, ${facts.tickets.length} tickets`)
51 } catch (err) {
52 await update($, phase, () => 'failed')
53 await update($, note, () => String(err).slice(0, 200))
54 }
55}
56
57async function post($: EngineInterface) {
58 const text = await read($, draft)
59 if (!text || (await read($, phase)) !== 'ready') return
60 await update($, phase, () => 'posting')
61 try {
62 await composio($, 'SLACK_SEND_MESSAGE', { channel, markdown_text: text })
63 await update($, phase, () => 'posted')
64 $.ui.toast(`Posted to ${channel}`)
65 } catch (err) {
66 await update($, phase, () => 'ready')
67 $.ui.toast(String(err).slice(0, 160))
68 }
69}
70
71export const register: Register = (on, options) => {
72 channel = String(options.channel ?? '').trim()
73 let lastExtra = ''
74
75 on('session.start', async ($, e, next) => {
76 await $.command.register({ name: 'snippet', description: `Draft today's ${channel || 'Slack'} update from Linear + GitHub; optional notes as args` })
77 return next(e)
78 })
79
80 on('command.run', { command: 'snippet' }, async ($, e) => {
81 lastExtra = e.args.trim()
82 await $.ui.open({ id: PANE, title: `Daily snippet → ${channel || 'Slack'}`, focus: true, closeOnEscape: true })
83 void redraft($, lastExtra)
84 return { text: 'Drafting snippet…' }
85 })
86
87 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
88 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
89 const text = await read($, draft)
90 const now = await read($, phase)
91 const status = await read($, note)
92
93 return (
94 <Box flexDirection="column" gap={1}>
95 {now === 'drafting' || text === null ? <Text dimColor>{status || 'drafting…'}</Text> : <Markdown text={text} />}
96 <Box gap={1}>
97 {now === 'ready' && channel && <Button key="post" hotkey="p" variant="primary" label={`Post to ${channel}`} onPress={() => post($)} />}
98 {now === 'posting' && <Text color="yellow">posting…</Text>}
99 {now === 'posted' && <Text color="green">✓ posted</Text>}
100 {text && (
101 <Button key="copy" hotkey="c" label="Copy" onPress={async press => {
102 await $.ui.copy({ text, surface: press.surface })
103 $.ui.toast('Copied')
104 }} />
105 )}
106 {now !== 'drafting' && now !== 'posting' && <Button key="redraft" hotkey="g" label="Redraft" onPress={() => redraft($, lastExtra)} />}
107 {now !== 'drafting' && <Text dimColor>{now === 'failed' ? status : status && `from ${status}`}</Text>}
108 </Box>
109 </Box>
110 )
111 })
112}
113types/index.d.ts 8 lines1export type SnippetPhase = 'drafting' | 'ready' | 'posting' | 'posted' | 'failed'
2
3declare module 'claude-code' {
4 interface PluginState {
5 snippet: { draft: string | null; phase: SnippetPhase; note: string }
6 }
7}
8