Toasts from Xyra while you work with Claude: a League of Legends match found, accepted, back in the queue, and the game starting with your champion.

Toasts from Xyra while you work with Claude, so a League of Legends match never waits on you:
| In League of Legends | Toast |
|---|---|
| A match is found | Match found! |
| It is accepted and champion select starts | Xyra accepted it. Champion select. (or Match accepted, when Xyra does not accept on its own) |
| Someone does not accept | Someone did not accept. You are still in the queue. (or The match did not start, once out of the queue) |
| The game starts | The game started: your champion. |
What the game already shows when a session starts is not announced. The toasts follow Claude Code's language (English or Spanish).
At the prompt of a terminal session:
/plugin install xyra-alert --marketplace DiegoFernandoLojanTenesaca/indaga-mods
From a shell:
claude plugin install xyra-alert --marketplace DiegoFernandoLojanTenesaca/indaga-mods
It needs Xyra on the same computer with its phone link on. At the first session it pairs on its own, with the pairing code Xyra keeps in %APPDATA%\com.indagalab.xyra\config.json, and shows up among Xyra's phones as Claude Code. Then it listens to Xyra the way the phone app does (/api/state). While Xyra is closed it tries again every 30 seconds and says nothing.
Disconnecting Claude Code from Xyra's phones stops it for that session; it pairs again at the next one, so turn the mod off to stop it for good.
| Option | What it is |
|---|---|
address | Where Xyra's phone link answers. http://127.0.0.1:47811 by default. |
hooks/register.ts 106 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { alertsOf } from './alerts'
4import type { XyraState } from './alerts'
5import { localeOf, translate } from './locales'
6import type { Locale } from './locales'
7
8const DEFAULT_ADDRESS = 'http://127.0.0.1:47811'
9const DEVICE_NAME = 'Claude Code'
10const XYRA_CONFIG = ['com.indagalab.xyra', 'config.json']
11const TOKEN_KEY = 'token'
12const NEXT_MS = 1
13const RETRY_MS = 30_000
14const FORBIDDEN = 403
15
16type Config = { address: string }
17type Timer = { cancel: () => void }
18type StateAnswer = { version: number; state: XyraState }
19
20const configOf =(options: Record<string, unknown>): Config => ({
21 address: (typeof options.address === 'string' && options.address.trim() ? options.address.trim() : DEFAULT_ADDRESS).replace(/\/+$/, ''),
22})
23
24const joinPath = (folder: string, names: readonly string[]) => [folder.replace(/[\\/]+$/, ''), ...names].join(folder.includes('\\') ? '\\' : '/')
25
26const linkOf = (config: Config, path: string, token: string, extra = '') =>
27 `${config.address}${path}?t=${encodeURIComponent(token)}${extra}`
28
29async function sessionLocale($: EngineInterface): Promise<Locale> {
30 const language = await $.settings.read({ key: 'language' }).catch(() => undefined)
31 return localeOf(typeof language === 'string' ? language : undefined)
32}
33
34async function pairingCode($: EngineInterface) {
35 const folder = await $.env.get('APPDATA')
36 if (!folder) return undefined
37 const saved = JSON.parse(await $.fs.read(joinPath(folder, XYRA_CONFIG))) as { phone_token?: unknown }
38 return typeof saved.phone_token === 'string' && saved.phone_token ? saved.phone_token : undefined
39}
40
41async function tokenOf($: EngineInterface, config: Config) {
42 const kept = await $.store.get(TOKEN_KEY)
43 if (typeof kept === 'string' && kept) return kept
44 const code = await pairingCode($)
45 if (!code) return undefined
46 const paired = await $.http.fetch(linkOf(config, '/api/pair', code, `&name=${encodeURIComponent(DEVICE_NAME)}`), { method: 'POST' })
47 const token = paired.ok ? (JSON.parse(paired.text) as { token?: unknown }).token : undefined
48 if (typeof token !== 'string' || !token) return undefined
49 await $.store.set(TOKEN_KEY, token)
50 return token
51}
52
53async function acceptsAlone($: EngineInterface, config: Config, token: string) {
54 const settings = await $.http.fetch(linkOf(config, '/api/settings', token))
55 return settings.ok && (JSON.parse(settings.text) as { auto_accept?: unknown }).auto_accept === true
56}
57
58function watch($: EngineInterface, config: Config): Timer {
59 let timer: Timer | undefined
60 let seen: XyraState | undefined
61 let version: number | undefined
62 let locale: Locale | undefined
63
64 const schedule = (ms: number) => {
65 timer = $.clock.after(ms, () => void step().catch(() => schedule(RETRY_MS)))
66 }
67
68 const step = async () => {
69 const token = await tokenOf($, config)
70 if (!token) return schedule(RETRY_MS)
71 const answer = await $.http.fetch(linkOf(config, '/api/state', token, version === undefined ? '' : `&after=${version}`))
72 if (answer.status === FORBIDDEN) return $.store.delete(TOKEN_KEY)
73 if (!answer.ok) return schedule(RETRY_MS)
74 const latest = JSON.parse(answer.text) as StateAnswer
75 locale ??= await sessionLocale($)
76 for (const alert of seen ? alertsOf(seen, latest.state) : []) {
77 const key = alert.key === 'accepted' && (await acceptsAlone($, config, token)) ? 'acceptedByXyra' : alert.key
78 $.ui.toast(translate(locale, key, alert.values))
79 }
80 seen = latest.state
81 version = latest.version
82 schedule(NEXT_MS)
83 }
84
85 schedule(NEXT_MS)
86 return { cancel: () => timer?.cancel() }
87}
88
89export const register: Register = (on, options) => {
90 const config = configOf(options)
91 let watcher: Timer | undefined
92
93 on('session.start', async ($, e, next) => {
94 const started = await next(e)
95 watcher?.cancel()
96 watcher = watch($, config)
97 return started
98 })
99
100 on('session.end', async ($, e, next) => {
101 watcher?.cancel()
102 watcher = undefined
103 return next(e)
104 })
105}
106hooks/alerts.ts 30 lines1import type { MessageKey } from './locales'
2
3export type XyraPhase = 'noClient' | 'client' | 'champSelect' | 'inGame' | 'paused'
4
5type Champion = { name: string } | null
6
7export type XyraState = {
8 phase: XyraPhase
9 ready_check: boolean
10 champ_select: { champion: Champion } | null
11 game: { champion: Champion } | null
12 lobby: { searching: boolean } | null
13}
14
15export type XyraAlert = { key: MessageKey; values?: Record<string, string> }
16
17const PLAYING = new Set<XyraPhase>(['inGame', 'paused'])
18
19export function alertsOf(before: XyraState, after: XyraState): XyraAlert[] {
20 const alerts: XyraAlert[] = []
21 if (!before.ready_check && after.ready_check) alerts.push({ key: 'found' })
22 if (before.phase !== 'champSelect' && after.phase === 'champSelect') alerts.push({ key: before.ready_check ? 'accepted' : 'champSelect' })
23 if (before.ready_check && !after.ready_check && after.phase !== 'champSelect') alerts.push({ key: after.lobby?.searching ? 'requeued' : 'notFormed' })
24 if (!PLAYING.has(before.phase) && after.phase === 'inGame') {
25 const champion = after.game?.champion?.name ?? before.champ_select?.champion?.name
26 alerts.push(champion ? { key: 'started', values: { champion } } : { key: 'startedPlain' })
27 }
28 return alerts
29}
30hooks/locales.ts 42 lines1const en = {
2 found: 'Match found!',
3 accepted: 'Match accepted. Champion select.',
4 acceptedByXyra: 'Xyra accepted it. Champion select.',
5 champSelect: 'Champion select.',
6 requeued: 'Someone did not accept. You are still in the queue.',
7 notFormed: 'The match did not start.',
8 started: 'The game started: {champion}.',
9 startedPlain: 'The game started.',
10}
11
12export type MessageKey = keyof typeof en
13
14const es: Record<MessageKey, string> = {
15 found: '¡Partida encontrada!',
16 accepted: 'Partida aceptada. Selección de campeón.',
17 acceptedByXyra: 'Xyra la aceptó. Selección de campeón.',
18 champSelect: 'Selección de campeón.',
19 requeued: 'Alguien no aceptó. Sigues en cola.',
20 notFormed: 'La partida no se armó.',
21 started: 'La partida empezó: {champion}.',
22 startedPlain: 'La partida empezó.',
23}
24
25const LOCALES = {
26 en: { names: ['en', 'english', 'inglés'], messages: en },
27 es: { names: ['es', 'español', 'espanol', 'spanish', 'castellano'], messages: es },
28}
29
30export type Locale = keyof typeof LOCALES
31
32const BASE_LOCALE: Locale = 'en'
33
34export function localeOf(language: string | undefined): Locale {
35 const name = (language ?? '').trim().toLowerCase().split(/[-_\s]/)[0] ?? ''
36 const found = (Object.keys(LOCALES) as Locale[]).find(locale => LOCALES[locale].names.includes(name))
37 return found ?? BASE_LOCALE
38}
39
40export const translate = (locale: Locale, key: MessageKey, values: Record<string, string> = {}) =>
41 LOCALES[locale].messages[key].replace(/\{(\w+)\}/g, (whole, name: string) => values[name] ?? whole)
42