Terminal-based admin TUI for Matrix Synapse homeservers. Single static binary, no runtime dependencies, communicates directly with the Synapse Admin API over HTTPS.
go build . # build binary
go run . # run from source
go test ./... # run all tests
go vet ./... # static analysis
gofumpt -w . # format (stricter than gofmt)
golangci-lint run # lintPrimary config file path: ~/.synapse-admin/config.toml
Legacy fallback read path: ~/Library/Application Support/synapse-admin/config.toml
synapse-admin/
├── main.go # entry point: load config → build client → start tea.Program
├── go.mod
├── go.sum
├── README.md
├── AGENTS.md # this file
└── internal/
├── api/
│ ├── client.go # HTTP client, auth header, error unwrapping
│ ├── json.go # tolerant numeric/string JSON decoding helpers
│ ├── users.go # /v2/users endpoints + activate/deactivate helpers
│ ├── rooms.go # /v1/rooms endpoints
│ └── tokens.go # /v1/registration_tokens endpoints
├── config/
│ └── config.go # TOML Config struct, Load(), Save(), ConfigPath()
└── ui/
├── model/
│ ├── root.go # RootModel: routing, modal orchestration, status bar
│ ├── keys.go # all key.Binding definitions
│ └── statusbar.go # renderStatusBar()
├── views/
│ ├── actions.go # typed messages from views to root for mutations
│ ├── common.go # shared filter + empty-state helpers
│ ├── errors.go # shared view errors
│ ├── helpers.go # small rendering utilities
│ ├── users.go # UsersView — table + API search + local prev history
│ ├── rooms.go # RoomsView — table + pagination + search
│ └── tokens.go # TokensView — registration token table
├── dialog/
│ ├── dialog.go # Dialog interface + shared message types
│ ├── command_palette.go # ctrl+p command palette
│ ├── config_dialog.go # homeserver / access-token form
│ └── confirm_dialog.go # generic confirmation overlay
└── styles/
└── styles.go # all lipgloss styles, adaptive light/dark colors
Rule: only internal/ui/model/root.go implements the full Bubble Tea
Update(tea.Msg) cycle. Every other UI struct exposes plain methods
(SetSize, SetClient, Load, Update, View) that return tea.Cmd.
This is the "smart root, dumb views" pattern. Keep it.
| Layer | Package | Notes |
|---|---|---|
| TUI framework | github.com/charmbracelet/bubbletea v1 |
Elm architecture |
| Styling | github.com/charmbracelet/lipgloss |
adaptive colors |
| Input components | github.com/charmbracelet/bubbles |
textinput, spinner, key |
| Config | github.com/BurntSushi/toml |
~/.synapse-admin/config.toml |
| HTTP | stdlib net/http |
no extra dependency |
| Go version | 1.22+ |
[server]
homeserver = "https://matrix.example.com"
access_token = "syt_admin_xxxxxx"File is created with mode 0600. Never log the access token.
User keyboard/mouse
│
▼
RootModel.Update() ← only place tea.Msg is processed
│
├─ dialog open? → forward all keys to activeDialog.Update()
│
├─ global keys (ctrl+p, ctrl+s, q) → handle here
│
├─ root-level action messages → open confirm dialog / run API mutation
│
└─ else → activeView.Update(msg) returns tea.Cmd
│
▼
goroutine via tea.Cmd
api.Client.*() (non-blocking)
│
▼
view-loaded messages | ok/error messages | action/request messages
│
▼
RootModel.Update() picks up result, updates state
All API calls are wrapped in tea.Cmd background work. The UI never blocks.
dialog.Dialog interface:
type Dialog interface {
Update(tea.Msg) (tea.Model, tea.Cmd)
View() string
}RootModel.activeDialog is nil when no dialog is open. When set, all
keyboard events are forwarded to it. Dialogs communicate back via typed
messages:
| Message | Sender | Meaning |
|---|---|---|
dialog.CancelMsg |
any dialog | user pressed esc / cancelled |
dialog.ConfigSavedMsg |
config dialog | credentials saved, client should be rebuilt |
dialog.ConfirmMsg{Confirmed: true, OnConfirm: cmd} |
confirm dialog | user confirmed, run cmd |
dialog.CommandPaletteMsg |
command palette | user selected a scope or command |
func (v *XxxView) SetSize(w, h int)
func (v *XxxView) SetClient(c *api.Client)
func (v *XxxView) Load() tea.Cmd
func (v *XxxView) Update(msg tea.Msg) tea.Cmd
func (v *XxxView) View(height int) stringViews own their pagination state (from, nextToken, prevBatch, local
history).
Three channels, all on the bottom bar:
RootModel.errMsg— red, auto-clears after 4 s viaClearStatusMsgRootModel.okMsg— green, same timerRootModel.loading+ spinner — shown while any async load is in flight
Never use panic or log.Fatal in UI code. Return an ErrorMsg instead.
| Key | Action |
|---|---|
ctrl+p |
open command palette |
ctrl+s |
open config dialog directly |
q / ctrl+c |
quit |
esc |
close dialog / cancel filter |
| Key | Action |
|---|---|
↑ / k |
move up |
↓ / j |
move down |
n / → |
next page |
p / ← |
previous page |
enter |
confirm |
| Key | Action |
|---|---|
/ |
search users through the Synapse API |
r |
planned: reset password |
x |
deactivate + erase selected user |
a |
reactivate selected user |
n |
next page |
p |
previous page from local history |
| Key | Action |
|---|---|
/ |
search rooms through search_term |
m |
planned: members overlay |
d |
delete room, retry with force purge on failure |
| Key | Action |
|---|---|
c |
planned: create token |
d |
planned: delete token |
All bindings are defined in internal/ui/model/keys.go. Add new bindings
there first, then wire them in root.go or the relevant view.
| Endpoint | Method | Implemented in |
|---|---|---|
/_synapse/admin/v2/users |
GET | api/users.go ListUsers |
/_synapse/admin/v2/users/{userId} |
PUT | api/users.go ReactivateUser |
/_synapse/admin/v1/reset_password/{userId} |
POST | api/users.go ResetPassword |
/_synapse/admin/v1/deactivate/{userId} |
POST | api/users.go DeactivateUser |
/_synapse/admin/v1/rooms |
GET | api/rooms.go ListRooms |
/_synapse/admin/v1/rooms/{roomId}/members |
GET | api/rooms.go GetRoomMembers |
/_synapse/admin/v1/rooms/{roomId} |
DELETE | api/rooms.go DeleteRoom |
/_synapse/admin/v1/registration_tokens |
GET | api/tokens.go ListTokens |
/_synapse/admin/v1/registration_tokens/{token} |
DELETE | api/tokens.go DeleteToken |
/_synapse/admin/v1/registration_tokens/new |
POST | api/tokens.go CreateToken |
GET/PUT /_synapse/admin/v2/users/{userId}— user detail & edit formPUT /_synapse/admin/v2/users/{userId}— create user flowGET /_synapse/admin/v1/server_version— server info screenGET /_synapse/admin/v1/statistics/users/media— storage stats per user- Media quarantine / delete endpoints
- Background task polling (
GET /_synapse/admin/v1/background_updates/status) - Multi-profile support in config
All visual constants live in internal/ui/styles/styles.go. Use
lipgloss.AdaptiveColor for every color so the app works in both light and
dark terminals.
Color palette:
| Name | Light | Dark | Usage |
|---|---|---|---|
ColorPrimary |
#5D5FEF |
#7B7DEF |
command palette, borders, selected items |
ColorDanger |
#CC3333 |
#FF6B6B |
errors, deactivate, delete |
ColorSuccess |
#2D8A4E |
#4ADE80 |
ok messages |
ColorMuted |
#999999 |
#666666 |
secondary text, help bar |
ColorBorder |
#CCCCCC |
#333333 |
panel borders, separators |
ColorSelected |
#E8E8FF |
#2A2A5A |
highlighted row |
Do not add inline lipgloss.NewStyle() calls in view or model files. Define
the style in styles.go and import it.
- Use
testing+testify/requirefor assertions. - Enable
t.Parallel()in every test. - Use
t.TempDir()for any file I/O. - Mock the API by passing a
*httptest.ServerURL as homeserver. - Never make real network calls in tests.
- Test file naming:
foo_test.goin the same package as the file under test. - Golden files for rendered TUI output:
testdata/*.golden, regenerate with-updateflag.
- Formatter:
gofumpt(stricter thangofmt). - Import grouping: stdlib → external → internal (separated by blank lines).
- Naming:
PascalCasefor exported,camelCasefor unexported. - Comments: complete sentences, start with capital, end with period.
- Error strings: lowercase, no trailing punctuation (
fmt.Errorf("list users: %w", err)). - Commits:
feat:,fix:,refactor:,chore:,docs:prefixes.
Status: runnable TUI skeleton implemented.
-
internal/ui/views/users.go— table rendering, up/down selection, next-page pagination plus local previous history, API-backed search -
internal/ui/views/rooms.go— table rendering, prev/next pagination, search (/opens inline filter, sent assearch_termquery param) -
internal/ui/views/tokens.go— table rendering, no pagination
-
internal/ui/dialog/config_dialog.go— homeserver/access-token form -
internal/ui/dialog/confirm_dialog.go— generic confirmation dialog -
internal/ui/dialog/command_palette.go—ctrl+pscope/action palette -
internal/ui/dialog/password_dialog.go— masked password input -
internal/ui/dialog/members_dialog.go— read-only room members overlay
- Users view:
rkey → open password dialog for selected user - Users view:
xkey → confirm dialog →api.DeactivateUser - Users view:
akey → confirm dialog →api.ReactivateUser - Rooms view:
mkey → open members dialog for selected room - Rooms view:
dkey → confirm dialog →api.DeleteRoom, retry with force purge on failure - Tokens view:
dkey → confirm dialog →api.DeleteToken - Tokens view:
ckey → open create-token dialog
-
?key toggles a full-screen help overlay - Bottom status bar shows relevant bindings for the current view
-
ctrl+pcommand palette is the primary way to switch scope
- First-run UX: auto-open config dialog when no config exists
-
versioninjected at build time via-ldflags -
README.mdwith install instructions, config, and keybindings -
MakefileorTaskfile.yaml - GitHub Actions CI
- Multi-profile config:
[[profiles]]array in TOML - User create form
- User detail / edit view
- Server info screen
- Media management
- Background task status bar
- Bulk selection with
space - Local action log
- Export selected table to CSV
Pagination asymmetry. The v2 users API is forward-only via next_token.
The app simulates previous navigation with a local history of visited pages.
Room deletion is async. The DELETE endpoint returns immediately; actual purge happens in the background. Treat the initial 200 response as accepted, not finished.
Deactivate ≠ delete. Synapse never removes the user row completely. Deactivation + erase removes personal data but the user record remains visible.
Activation may require a password. Reactivation is currently wired through
PUT /_synapse/admin/v2/users/{userId} with deactivated=false. Some
homeservers may require additional fields for local-password users.
Config file permissions. Always open with os.O_WRONLY|os.O_CREATE|os.O_TRUNC
and mode 0600. Never write the access token to stdout or logs.