Note: This project is vibe coded and heavily WIP. Expect rough edges, breaking changes, and incomplete features.
A custom macOS XMPP client built with SwiftUI using the Tigase Martin library. It renders a 2-column directory (Dispatchers/Sessions) and keeps a chat panel always visible.
| Pane | Content | Behavior |
|---|---|---|
| 1 | Dispatchers | Selecting loads Sessions + opens dispatcher chat |
| 2 | Sessions | Selecting opens 1:1 chat + loads history |
| Chat | Chat panel | Always visible; shows welcome state when no chat target |
- Dispatcher selection drives the Sessions list
- Clicking a dispatcher → shows only that dispatcher's sessions in Column 2
- Clicking a session → opens 1:1 chat and loads history
- Chat panel shows the current chat target
- Always visible on the right side of the sidebar
- Shows welcome/empty state when nothing is selected (subtle design)
- Displays 1:1 chat when a session is selected
- Displays 1:1 chat when a dispatcher is selected
- Auto-scrolls to the latest message
- Renders Markdown formatting (bold, inline code, code blocks, lists)
- Message timestamps displayed subtly below each message
- Typing indicators show when contact is composing
The client supports a custom message metadata extension for rich rendering:
<meta xmlns="urn:switch:message-meta" type="..." tool="..." />Supported meta types:
| Type | Rendering |
|---|---|
tool |
Monospace code block style |
tool-result |
Monospace code block style |
run-stats |
Normal markdown + stats footer badge |
Tool messages display a badge with the tool name (e.g., "bash", "read", "edit").
Run stats display a compact footer showing model, tokens, cost, and duration:
<meta xmlns="urn:switch:message-meta"
type="run-stats"
model="gpt-5.2"
tokens_in="824"
tokens_out="92"
cost_usd="0.000"
duration_s="158.8" />- Shows spinner next to sessions that are typing
- Shows spinner next to dispatchers when any of their sessions are typing
- Shows "typing..." indicator in chat header
- Sessions sorted by most recent message (newest at top)
- Dispatchers sorted by most recent activity (newest at top)
- Auto-re-sorts when new messages arrive
- Inline code: Accent-colored background with white text
- Code blocks: Monospace font with subtle background, text wrapping (no horizontal scroll)
- Improved spacing: Better line height and paragraph separation
- Language: Swift
- XMPP Library: Tigase Martin (v3.2.1+)
- Swift Package Manager integration
- Modular XEP support
- Real-time Updates: XMPP event backbone via ejabberd
- Platform: macOS 13.0+
- Copy
.env.exampleto.envand fill in credentials. - On macOS: open
Package.swiftin Xcode and Run theSwitchMacOSexecutable.
The app reads .env from the process's current working directory, so launch from the repo root:
swift build
.build/debug/SwitchMacOS./bundle.sh
open .build/SwitchMacOS.appYou can also pass env vars inline:
XMPP_HOST="..." XMPP_JID="..." XMPP_PASSWORD="..." SWITCH_DIRECTORY_JID="..." .build/debug/SwitchMacOSNote: Values in .env take precedence over environment variables passed on the command line.
You can optionally pin one or more chats to the top of the sidebar:
SWITCH_PINNED_CHATS="notes=notes@your.xmpp.server.com,alerts=alerts@your.xmpp.server.com" \
.build/debug/SwitchMacOSYou can bind up to 4 dispatchers to Cmd+1, Cmd+2, Cmd+3, Cmd+4 via env vars:
SWITCH_DISPATCHER_HOTKEY_1=acorn@
SWITCH_DISPATCHER_HOTKEY_2=oc-codex@
SWITCH_DISPATCHER_HOTKEY_3=anthropic@
SWITCH_DISPATCHER_HOTKEY_4=oc-gpt@These shortcuts work while the app is focused (standard macOS menu key equivalents).
swift testThis client expects Switch to provide the hierarchy using:
- XEP-0030 disco#items served by a Switch "directory" XMPP account.
- IMPORTANT: on ejabberd, disco for bare user JIDs is handled by the server (PEP), so the directory must be addressed as a full JID resource, e.g.
switch-dir@domain/directory.
- IMPORTANT: on ejabberd, disco for bare user JIDs is handled by the server (PEP), so the directory must be addressed as a full JID resource, e.g.
- XEP-0060 pubsub notifications sent via ejabberd
mod_pubsub(usuallypubsub.domain).
The pubsub payload is treated as a lightweight "refresh ping"; the client refreshes lists by re-running disco queries.
dispatchersgroups:<dispatcherJid>individuals:<groupJid>subagents:<individualJid>
Note: the current UI only renders Dispatchers + Sessions, but the directory protocol remains hierarchical so we can add filtering/grouping later.
- XEP-0030: Service Discovery - for directory hierarchy
- XEP-0060: Publish-Subscribe - for real-time directory updates
- XEP-0085: Chat State Notifications - for typing indicators
- XEP-0198: Stream Management - for reconnection
- XEP-0280: Message Carbons - for multi-device sync
- XEP-0313: Message Archive Management (MAM) - for history
urn:switch:message-meta- for tool/run-stats message metadata
- Dispatchers - Standard XMPP contacts
- Sessions - Standard XMPP contacts
Dispatcher selected
↓
Fetch groups for dispatcher (internal)
↓
Fetch sessions from group(s)
↓
Session selected
↓
Open chat + load history
Presence/status text is good for online state, but it's not a reliable place to encode durable classification (dispatcher vs session vs subagent) or parent/child relationships.
Decision: use standard discovery + subscriptions, with Switch as the source of truth.
- Directory service (required): Switch runs an XMPP account that supports:
- XEP-0030 (Service Discovery) to list items for each level
- PubSub (required): ejabberd
mod_pubsubpushes realtime "refresh" signals (XEP-0060)- The service returns structured lists:
- dispatchers
- groups for a dispatcher
- individuals (sessions) for a group
- The service returns structured lists:
- Client behavior: the client renders columns from directory results; it uses standard message/presence/roster for chat + online indicators.
- Subtle, minimal design
- Displayed when no selection active
- No chat content shown
- Chat header shows contact/room name
- Message history loaded via MAM
- Real-time message delivery
- Typing indicators when contact is composing
- Run stats badge on final assistant messages
- Re-introduce Groups as a UI filter when it becomes useful
- Subagents can be added back as a separate view when we want agent spawning UX
- All contact types use standard XMPP protocol
