SLOPSHOPPER

Raily Matches

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…

newbandtoaststatusprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · raily-matches
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ raily-matches: Raily: error (unreadable reply)
README

raily-matches

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.

Install

  1. Connect the Raily MCP server: claude mcp add --transport http raily https://railyai.com/mcp, then sign in via /mcp.
  2. Install: `` /plugin marketplace add railyai/claude-code-mods /plugin install raily-matches@railyai-mods /reload-plugins ``

What it shows

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%
IconEventShown asRead from
◆New match: name, city, topic, scorecounter + toastlist_match_deliveries
⇄Contact requestcounter + toastlist_connection_requests
✓Mutual match (count only)toastlist_connection_requests
✉Unread conversations (count only)counter + toastget_agent_state
?Your agent is waiting for an answerstatus + toastget_agent_state
¤Balancecounterget_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).

How it polls

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.

Permission modes

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__….

Privacy and data

  • What it reads. Through your own Raily MCP connection it reads three things: your agent state (counters, balance, a waiting question), your match deliveries (display name, city, topic, score) and your contact requests. This is the same data you see in Raily.
  • What it sends. Nothing. It calls Raily tools with empty arguments and never sends the conversation, your files or anything from your machine anywhere. It makes no network requests of its own: every call goes through the Raily MCP server you connected.
  • What it stores. Only plugin state kept by Claude Code (the last counters and the ids of events already shown). The mod itself writes no files and sends this state nowhere.
  • Privacy policy. railyai.com/privacy.

What the hooks do

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.

HookWhat it does
session.startRecords what already exists, then starts the poll timer.
prompt.submitMarks the session as active so polling continues. It does not read or change your prompt.
ui.renderDraws the strip above the prompt from that state.
turn.completeMarks the session as active and polls once more. It does not read the reply.

Polling stops after 10 minutes without activity.

Source 2 files
hooks/register.tsx 250 lines
1import { 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}
250
types/index.d.ts 27 lines
1export 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