Raily is a personal AI agent that finds people worth meeting for you (friends, dates, business contacts) and handles the first contact. This mod shows your…

A Claude Code mod for Raily.
What is Raily? Raily is a personal AI agent that looks for people worth meeting for you (friends, dates, business contacts), talks to their agents first, and brings you only the matches that fit. You need a Raily account (18+) and the Raily MCP server connected.
What does this mod do? It keeps your Raily inbox visible while you work in Claude Code, so you do not have to open the app to see what your agent found.
Shows new Raily matches (◆), contact requests (⇄), mutual matches (✓), unread conversations (✉) and agent questions (?) above the prompt, with a toast for each new event.
claude mcp add --transport http raily https://railyai.com/mcp, then sign in via /mcp. /plugin marketplace add railyai/claude-code-mods /plugin install raily-matches@railyai-mods /reload-plugins ``A status line above the prompt, plus a toast for every new event.
◆ 3 matches ⇄ 2 requests ✉ 2 ? agent waiting ¤ 120 ◆ New match: Anna · Berlin · AI · 83%
| Icon | Event | Shown as | Read from |
|---|---|---|---|
| ◆ | New match: name, city, topic, score | counter + toast | list_match_deliveries |
| ⇄ | Contact request | counter + toast | list_connection_requests |
| ✓ | Mutual match (count only) | toast | list_connection_requests |
| ✉ | Unread conversations (count only) | counter + toast | get_agent_state |
| ? | Your agent is waiting for an answer | status + toast | get_agent_state |
| ¤ | Balance | counter | get_agent_state |
The first poll of a session only records what already exists, so nothing is announced on startup.
Planned: ⚖ negotiation results (get_negotiation_report) and ▤ agent reports (get_agent_report_latest).
Polls the existing MCP tools (get_agent_state, list_match_deliveries, list_connection_requests) at most once a minute, every 2 minutes while the session is active, and stops after 10 minutes of idle. Reads only; it never opens, skips or answers anything.
The mod calls three read-only Raily tools. In auto permission mode Claude Code may refuse plugin MCP calls ("classifier gave no verdict"); the status line then shows Raily: the permission mode blocked the call. Allow the tools in ~/.claude/settings.json:
{ "permissions": { "allow": [
"mcp__claude_ai_Raily__get_agent_state",
"mcp__claude_ai_Raily__list_match_deliveries",
"mcp__claude_ai_Raily__list_connection_requests"
] } }
If you connected the server with claude mcp add ... raily, use mcp__raily__… instead of mcp__claude_ai_Raily__….
The mod calls the three read-only Raily tools above by itself, on a timer, without the model asking. It never opens, skips, answers or pays for anything.
| Hook | What it does |
|---|---|
session.start | Records what already exists, then starts the poll timer. |
prompt.submit | Marks the session as active so polling continues. It does not read or change your prompt. |
ui.render | Draws the strip above the prompt from that state. |
turn.complete | Marks the session as active and polls once more. It does not read the reply. |
Polling stops after 10 minutes without activity.
hooks/register.tsx 250 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { FeedItem, Snapshot } from '../types'
5
6const TICK_MS = 120_000
7const MIN_GAP_MS = 60_000
8const IDLE_MS = 10 * 60_000
9const MAX_BACKOFF_MS = 15 * 60_000
10const FEED_SIZE = 20
11const SERVERS = ['claude.ai Raily', 'claude_ai_Raily', 'Raily', 'raily']
12
13const snapshot = atom({ plugin: 'raily-matches', key: 'snapshot' } as const, null as Snapshot | null)
14const feed = atom({ plugin: 'raily-matches', key: 'feed' } as const, [] as FeedItem[])
15const seen = atom({ plugin: 'raily-matches', key: 'seen' } as const, [] as string[])
16const server = atom({ plugin: 'raily-matches', key: 'server' } as const, '')
17const lastPoll = atom({ plugin: 'raily-matches', key: 'lastPoll' } as const, 0)
18const lastActivity = atom({ plugin: 'raily-matches', key: 'lastActivity' } as const, 0)
19const failures = atom({ plugin: 'raily-matches', key: 'failures' } as const, 0)
20const retryAt = atom({ plugin: 'raily-matches', key: 'retryAt' } as const, 0)
21const isStopped = atom({ plugin: 'raily-matches', key: 'isStopped' } as const, false)
22
23function parse(result: any): any {
24 if (!result || result.isError) return null
25 if (result.structuredContent) return result.structuredContent
26 const text = (result.content ?? []).find((b: any) => b.type === 'text')?.text
27 try {
28 return text ? JSON.parse(text) : null
29 } catch {
30 return null
31 }
32}
33
34function isAuthError(result: any): boolean {
35 const text = (result?.content ?? []).map((b: any) => b.text ?? '').join(' ')
36 return /auth|unauthori[sz]ed|401|forbidden|sign in|log ?in/i.test(text)
37}
38
39function label(d: any): string {
40 const place = [d.city, d.topic].filter(Boolean).join(' · ')
41 const score = typeof d.score === 'number' ? ` · ${Math.round(d.score * (d.score <= 1 ? 100 : 1))}%` : ''
42 return `${d.display_name ?? 'Someone'}${place ? ' · ' + place : ''}${score}`
43}
44
45function diffDeliveries(list: any[], known: Set<string>): FeedItem[] {
46 return list
47 .filter(d => d.delivery_id && !known.has(`d:${d.delivery_id}`))
48 .map(d => ({ id: `d:${d.delivery_id}`, icon: '◆', text: `New match: ${label(d)}`, at: 0 }))
49}
50
51function diffRequests(list: any[], known: Set<string>): FeedItem[] {
52 return list
53 .filter(r => r.pair_key && !known.has(`r:${r.pair_key}`))
54 .map(r => ({
55 id: `r:${r.pair_key}`,
56 icon: '⇄',
57 text: `Contact request${r.projection ? ': ' + String(r.projection).slice(0, 60) : ''}`,
58 at: 0
59 }))
60}
61
62function toSnapshot(s: any, connected: number): Snapshot {
63 return {
64 matches: s.matches_count ?? 0,
65 requests: s.requests_count ?? 0,
66 connected,
67 unread: s.unread_conversations_count ?? 0,
68 balance: typeof s.balance === 'number' ? s.balance : null,
69 isAsking: Boolean(s.agent_question?.waiting)
70 }
71}
72
73function changed(a: Snapshot | null, b: Snapshot): boolean {
74 return !a || a.matches !== b.matches || a.requests !== b.requests || a.connected !== b.connected
75}
76
77function stateItems(prev: Snapshot | null, next: Snapshot): FeedItem[] {
78 const items: FeedItem[] = []
79 if (prev && next.isAsking && !prev.isAsking) {
80 items.push({ id: `q:${next.matches}:${next.requests}`, icon: '?', text: 'Your agent is waiting for an answer', at: 0 })
81 }
82 if (prev && next.connected > prev.connected) {
83 items.push({ id: `c:${next.connected}`, icon: '✓', text: `Mutual match (${next.connected} connected)`, at: 0 })
84 }
85 if (prev && next.unread > prev.unread) {
86 items.push({ id: `u:${next.unread}`, icon: '✉', text: `${next.unread} unread conversation${next.unread === 1 ? '' : 's'}`, at: 0 })
87 }
88 return items
89}
90
91async function call($: any, name: string, tool: string): Promise<any> {
92 try {
93 return await $.mcp.call(name, tool, {})
94 } catch {
95 return null
96 }
97}
98
99async function pickServer($: any): Promise<string> {
100 const known = await read($, server)
101 if (known) return known
102 for (const name of SERVERS) {
103 try {
104 const result = await $.mcp.call(name, 'get_agent_state', {})
105 if (result && !result.isError) {
106 await update($, server, () => name)
107 return name
108 }
109 } catch (err: any) {
110 const text = String(err?.message ?? err)
111 if (/classifier|refused/i.test(text)) {
112 $.ui.status('Raily: the permission mode blocked the call; allow the read-only Raily tools (see README)')
113 }
114 }
115 }
116 return ''
117}
118
119async function fail($: any, now: number, reason: 'auth' | 'error', detail = '') {
120 $.ui.status(`Raily: ${reason}${detail ? ' (' + detail + ')' : ''}`)
121 if (reason === 'auth') {
122 await update($, isStopped, () => true)
123 $.ui.toast('Raily: not signed in. Reconnect the Raily MCP server, then restart the session.')
124 return
125 }
126 const count = (await read($, failures)) + 1
127 await update($, failures, () => count)
128 await update($, retryAt, () => now + Math.min(TICK_MS * 2 ** count, MAX_BACKOFF_MS))
129}
130
131async function poll($: any, force: boolean) {
132 const now = await $.clock.now()
133 if (await read($, isStopped)) return
134 if (!force && now < (await read($, retryAt))) return
135 if (!force && now - (await read($, lastPoll)) < MIN_GAP_MS) return
136 await update($, lastPoll, () => now)
137
138 const name = await pickServer($)
139 if (!name) return fail($, now, 'error', 'no MCP server found')
140
141 const stateResult = await $.mcp.call(name, 'get_agent_state', {}).catch((err: any) => ({ isError: true, content: [{ type: 'text', text: String(err?.message ?? err) }] }))
142 const state = parse(stateResult)
143 if (!state) return fail($, now, isAuthError(stateResult) ? 'auth' : 'error', String(stateResult?.content?.[0]?.text ?? 'unreadable reply').slice(0, 60))
144
145 const prev = await read($, snapshot)
146 const requestsResult = await call($, name, 'list_connection_requests')
147 const requests = parse(requestsResult)
148 const connected = requests?.counters?.connected ?? prev?.connected ?? 0
149 const next = toSnapshot(state, connected)
150
151 const known = new Set(await read($, seen))
152 let fresh: FeedItem[] = stateItems(prev, next)
153 if (changed(prev, next) || force) {
154 const deliveries = parse(await call($, name, 'list_match_deliveries'))?.deliveries ?? []
155 fresh = [...diffDeliveries(deliveries, known), ...diffRequests(requests?.requests ?? [], known), ...fresh]
156 for (const f of fresh) known.add(f.id)
157 for (const d of deliveries) known.add(`d:${d.delivery_id}`)
158 for (const r of requests?.requests ?? []) known.add(`r:${r.pair_key}`)
159 await update($, seen, () => [...known].slice(-500))
160 }
161
162 await update($, failures, () => 0)
163 await update($, snapshot, () => next)
164 $.ui.status(undefined)
165
166 // first poll of a session only records a baseline: nothing is announced
167 if (!prev) return
168 if (fresh.length === 0) return
169 const stamped = fresh.map(f => ({ ...f, at: now }))
170 await update($, feed, (old: FeedItem[]) => [...stamped, ...old].slice(0, FEED_SIZE))
171 for (const f of stamped.slice(0, 3)) $.ui.toast(`${f.icon} ${f.text}`)
172 if (stamped.length > 3) $.ui.toast(`Raily: +${stamped.length - 3} more events`)
173}
174
175async function safePoll($: any, force: boolean) {
176 try {
177 await poll($, force)
178 } catch (err: any) {
179 $.ui.status(`Raily: ${String(err?.message ?? err).slice(0, 80)}`)
180 }
181}
182
183async function touch($: any) {
184 const now = await $.clock.now()
185 await update($, lastActivity, () => now)
186}
187
188export const register: Register = on => {
189 on('session.start', async ($, e, next) => {
190 const result = await next(e)
191 await touch($)
192 await update($, isStopped, () => false)
193 await update($, failures, () => 0)
194 await update($, retryAt, () => 0)
195 await safePoll($, true)
196 $.clock.every(TICK_MS, async () => {
197 const now = await $.clock.now()
198 if (now - (await read($, lastActivity)) > IDLE_MS) return
199 await safePoll($, false)
200 })
201 return result
202 })
203
204 on('prompt.submit', async ($, e, next) => {
205 await touch($)
206 return next(e)
207 })
208
209 on('turn.complete', async ($, e, next) => {
210 const result = await next(e)
211 if (!e.agentId) {
212 await touch($)
213 await safePoll($, false)
214 }
215 return result
216 })
217
218 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
219 const snap = await read($, snapshot)
220 if (e.props.hasSurvey || !snap) return next(e)
221 const below = await next(e)
222 const items = await read($, feed)
223 const { Box, Text } = $.ui.resolve(e)
224 const latest = items[0]
225 const wide = e.props.bodyColumns >= 70
226
227 const band = (
228 <Box flexDirection="row" paddingX={1}>
229 <Text color="cyan" bold>{`◆ ${snap.matches}`}</Text>
230 <Text dimColor>{' matches '}</Text>
231 <Text color={snap.requests > 0 ? 'yellow' : undefined} bold={snap.requests > 0}>{`⇄ ${snap.requests}`}</Text>
232 <Text dimColor>{' requests'}</Text>
233 {snap.unread > 0 && <Text color="green">{` ✉ ${snap.unread}`}</Text>}
234 {snap.isAsking && <Text color="magenta" bold>{' ? agent waiting'}</Text>}
235 {snap.balance !== null && <Text dimColor>{` ¤ ${snap.balance}`}</Text>}
236 {wide && latest && <Text dimColor>{` ${latest.icon} ${latest.text}`.slice(0, 80)}</Text>}
237 </Box>
238 )
239
240 // stack above whatever another mod (or the engine) draws in this band
241 if (!below) return band
242 return (
243 <Box flexDirection="column">
244 {band}
245 {below}
246 </Box>
247 )
248 })
249}
250types/index.d.ts 27 lines1export type Snapshot = {
2 matches: number
3 requests: number
4 connected: number
5 unread: number
6 balance: number | null
7 isAsking: boolean
8}
9
10export type FeedItem = { id: string; icon: string; text: string; at: number }
11
12declare module 'claude-code' {
13 interface PluginState {
14 'raily-matches': {
15 snapshot: Snapshot | null
16 feed: FeedItem[]
17 seen: string[]
18 server: string
19 lastPoll: number
20 lastActivity: number
21 failures: number
22 retryAt: number
23 isStopped: boolean
24 }
25 }
26}
27