Perch
Lua APIDiscord

Discord

perch.discord: call state, participants, notifications, incoming rings, the control verbs and the Discord types. Scopes discord and discord.control.

5 min readUpdated Sep 9, 2026

Perch talks to the local Discord client over its RPC socket, using the Application ID the user entered in Settings (see Connect Discord).

ScopeInstalls
discordperch.discord.read(), perch.discord.on_call, .on_notification, .on_ring, .on_status
discord.controlperch.discord.toggle_mute(), .toggle_deafen(), .hangup(), .accept_ring(), .decline_ring(), .connect(), .disconnect(), .configure(options)

The Discord call chip, the incoming-call pill, and the call and inbox cards

perch.discord.read

perch.discord.read(): discord_state_t

The link status. Synchronous; every flag is false before the first update, and all false together means Discord is not running.

perch.discord.on_status

perch.discord.on_status: signal_t<discord_state_t>

Fires when the link status changes. The builtin package drives its settings rows from it:

--!strict
local rows = perch.settings.declare {
  __order = { "link", "connect", "disconnect" },
  link = settings.status("Discord account"),
  connect = settings.button("Connection", "Connect", function() perch.discord.connect() end),
  disconnect = settings.button("Sign out", "Disconnect", function() perch.discord.disconnect() end),
}

perch.discord.on_status:connect(function(s: discord_state_t)
  rows.link:set(if s.connected then "Connected" else "Not connected", s.connected)
  rows.connect:set_visible(not s.connected)
  rows.disconnect:set_visible(s.connected)
end)

perch.discord.on_call

perch.discord.on_call: signal_t<discord_call_t>

Fires when a call is joined or left. Speaking rings, mute flips and participant changes during a call are pushed natively into the builtin Discord widgets and do not re-fire it.

Avatars are downloaded once into Perch’s avatar cache and referenced as file:// URLs; the first event may carry "" for avatars still downloading, followed by a second event once they land. Declare the slice you bind and copy it over:

--!strict
type face_refs_t = {
  name: field_ref_t<string>, initial: field_ref_t<string>, avatar: field_ref_t<string>, hue_css: field_ref_t<string>,
  speaking: field_ref_t<boolean>, muted: field_ref_t<boolean>,
}
type fields_t = { channel: field_ref_t<string>, started: field_ref_t<number>, faces: list_ref_t<face_refs_t> }

local call = widget.new { id = "call", label = "Call", urgency = urgency.ambient, fields = {
  channel = field.string(""), started = field.time_ms(),
  faces = field.list { name = field.string(""), initial = field.string(""), avatar = field.image(), hue_css = field.color(),
    speaking = field.boolean(false), muted = field.boolean(false) },
} }

perch.discord.on_call:connect(function(c: discord_call_t)
  if not c.in_call then
    call:retire()
    return
  end
  local faces = {}
  for i, face in ipairs(c.faces) do
    faces[i] = { name = face.name, initial = face.initial, avatar = face.avatar, hue_css = face.hue_css, speaking = face.speaking, muted = face.muted }
  end
  call:present({ channel = c.channel, started = c.started or 0, faces = faces })
end)

perch.discord.on_notification

perch.discord.on_notification: signal_t<discord_notification_t>

A message notification.

perch.discord.on_ring

perch.discord.on_ring: signal_t<discord_ring_t>

An incoming call started ringing. Discord sends no “stopped ringing” event; expire the ring yourself (the builtin package uses 32 seconds) or on the next on_call.

The control verbs

Scope discord.control. Each takes no arguments and returns nothing.

VerbEffect
perch.discord.toggle_mute()mute or unmute the microphone
perch.discord.toggle_deafen()deafen or undeafen
perch.discord.hangup()leave the current call
perch.discord.accept_ring()join the ringing DM call
perch.discord.decline_ring()dismiss the ring locally; Discord’s RPC has no reject
perch.discord.connect()sign back in to the RPC link
perch.discord.disconnect()sign out: forgets the token, keeps the Application ID
ui.button { on_click = function() perch.discord.toggle_mute() end, w = 25, h = 25, radius = 5, bg = ui.bg.well,
  style = { f.self_mute:is_true():set { color = ui.color.red, bg = "#f0474729" } },
  ui.shape { src = "glyphs/mic.svg", w = 13, h = 13 } }

The same verbs answer the voice router through perch.voice.on_intent: toggle_mute, toggle_deafen, hangup, accept_call, decline_call.

perch.discord.configure

perch.discord.configure(options: discord_options_t): ()

Stores a new Application ID (digits; anything else counts as not set up), invalidates the token and reconnects. The builtin package’s settings page calls it; a third-party mod rarely needs to.

Types

export type discord_state_t = { connected: boolean, needs_auth: boolean, needs_setup: boolean, disconnected: boolean? }
FieldMeaning
connectedthe RPC link is up
needs_setupno Application ID has been entered
needs_auththe user denied, or the authorization failed
disconnectedthe user signed out in Settings
export type discord_call_t = {
	in_call: boolean, started: number?, dm: boolean, server: string, server_icon: string, channel: string,
	self_mute: boolean, self_deaf: boolean,
	names: { string }, avatars: { string }, speaking: { number }, streaming: { number }, video: { number }, muted: { number }, deafened: { number },
	count: number, faces: { discord_face_t }, has_faces: boolean, solo: discord_face_t?,
}
FieldMeaning
in_callin a call
startedcall start, epoch milliseconds; only while in a call
dma DM or group call (no server)
server, server_iconthe server name and its icon as a file:// path, "" for DMs
channelthe channel name
self_mute, self_deafthe local user’s voice settings
names, avatarsone entry per participant; index 1 is the local user; an avatar is a file:// path or ""
speaking, streaming, video, muted, deafened0-based indices into names; a deafened user is not repeated in muted
countnumber of participants
faces, has_facesup to 12 participants as records: the local user first, then speakers, then the rest
solothe last person who spoke, falling back to the local user; only when count > 0
export type discord_face_t = { name: string, initial: string, avatar: string, hue_css: string, speaking: boolean, streaming: boolean, muted: boolean, deafened: boolean }

One participant. initial is the uppercased first character; hue_css is a ready linear-gradient(...) for a monogram background (declare it field.color() and bind bg = it.hue_css).

export type discord_notification_t = { from: string, text: string, avatar: string, context: string, at: number }

context is "" for a DM, "#channel" for a server channel (then avatar is the server icon), or the group name; at is epoch milliseconds.

export type discord_ring_t = { active: boolean, from: string, avatar: string }
export type discord_options_t = { app_id: string? }

A ring, and the argument of configure.

esc
Type to search
navigate open