Terminal client for Google Chat, for Workspace organizations.
- Spaces, DMs and group chats, with a fuzzy switcher that lists unread spaces first and shows web-client sidebar sections
- Threads collapsed to their root, with reply counts, opened in their own view
- Live updates through the Workspace Events API and Pub/Sub
- Unread state for spaces and threads synced from the web client and phone, muted spaces respected
- Your Do Not Disturb, away state and status follow changes made on other devices
- Desktop notifications following each space's notification setting, with a bell that marks the terminal tab, and the unread count in the tab title
- Reactions, edits, deletes, quotes, and completion for @mentions and
:emoji:, including the organization's custom emoji - Do Not Disturb, away and status from the input line
- Drafts kept per space and thread, across restarts
- Spaces and images cached locally and encrypted, so switching spaces and restarting are fast
- Cards rendered as text, images and animated GIFs drawn in the terminal, and Drive, Meet and Calendar links labeled
- Search across all spaces with
/find - Starts without network from the local cache, and catches up once Google is reachable
- File upload, download and open, and images pasted from the clipboard or dropped on the terminal
- GIF search through GIPHY with
/gif, when you set your own API key
An admin does this once. Users then only log in.
- A GCP project inside your Workspace organization, and the Owner role on it.
- A Google group containing everyone who will use mutter.
gcloudlogged in as that owner (gcloud auth login).
mutter admin setup
It asks for the project, the live update mode and the users' group, then:
- enables the Chat, Workspace Events, Pub/Sub and Cloud Identity APIs
- creates the Pub/Sub resources. A shared topic,
mutter-events, with the group allowed to create subscriptions, or a topic and subscription per member. See Isolating users. - walks through the three console steps that have no API: the Chat app configuration, OAuth branding with an Internal audience, and a Desktop OAuth client
- asks for the client ID and secret, and writes the organization's config to
mutter-<project>.json
Every step is safe to repeat, so a setup that stopped halfway runs again from the start.
Publish the config mutter admin setup wrote where users can fetch it, such as an intranet page or a private repository. Desktop client secrets are not confidential, because Google treats installed apps as public clients.
{
"client_id": "<ID>",
"client_secret": "<SECRET>",
"topic": "projects/<PROJECT_ID>/topics/mutter-events"
}Users install it with mutter config import, see Setup (per user). Without a topic, mutter runs without live updates.
On start, mutter subscribes the user to events from all their spaces through the Workspace Events API, delivered to the topic. On a shared topic, each machine pulls from its own filtered Pub/Sub subscription, which deletes itself after 31 days unused. With per-user topics, mutter pulls from the user's provisioned subscription.
Releases build in GitHub Actions when a v* tag is pushed, with GoReleaser (.goreleaser.yaml, .github/workflows/release.yml). They cover macOS and Linux on amd64 and arm64, and carry no organization's client or topic, so one release serves every organization. The workflow needs one secret:
| Secret | Value |
|---|---|
TAP_APP_PRIVATE_KEY |
private key of a GitHub App installed on neticdk/netic-homebrew-tap with Contents and Pull requests write access |
The app's Client ID goes in the repository variable TAP_APP_CLIENT_ID. Each release mints a token from the app that lasts an hour and reaches only the tap.
git tag v0.1.0 && git push origin v0.1.0
Each release opens a pull request on neticdk/netic-homebrew-tap that updates the mutter cask. Merging it publishes the release to Homebrew.
Users install with Homebrew. The tap is internal to the organization, so git needs GitHub credentials, which gh auth setup-git provides:
gh auth setup-git
brew tap neticdk/tap https://github.com/neticdk/netic-homebrew-tap
brew install neticdk/tap/mutter
The tap needs its URL because its repository name doesn't start with homebrew-.
Without Homebrew, scripts/install.sh downloads the release with gh:
gh api repos/neticdk/mutter/contents/scripts/install.sh -H 'Accept: application/vnd.github.raw' | bash
The script downloads the archive for the machine, checks it against checksums.txt, and installs to ~/.local/bin, or $BINDIR when set. Pass a tag to pin a version. The cask clears the macOS quarantine attribute after installing, and gh never sets it, so the unsigned binary runs without Gatekeeper prompts. A binary downloaded through a browser is blocked until it's signed and notarized.
mutter supports both: a shared topic, and a topic per user. mutter admin setup asks which.
- All users' events go to the shared
mutter-eventstopic. Each client filters for its own events with a per-machine subscription. - A filter only limits what its own subscription receives. Anyone who can attach a subscription to the topic receives every event on it.
roles/pubsub.editorlets every group member attach one.- Events carry no message content, because mutter subscribes with
includeResource=false. Reading a message still needs the reader's own Chat access. - What leaks is activity metadata: space and message IDs, timestamps, and read-state changes, meaning who reads which space and when.
Recommended beyond one team. A topic per user in the shared project, with resources an admin creates:
| Resource | Per | Notes |
|---|---|---|
Topic mutter-user-<id> |
user | chat-api-push@system.gserviceaccount.com has Pub/Sub Publisher on it |
Subscription mutter-user-<id> |
user | the user has roles/pubsub.subscriber on this subscription only |
| Project-wide Pub/Sub role | nobody | users can't attach to topics they weren't given |
mutter admin provisioncreates these for every member of the group, including members of groups nested in it, and runs on a schedule. For several teams, give it one parent group holding the team groups. See Provisioning per-user topics.<id>is the user's numeric Google account ID, which stays the same when their email changes.- mutter derives both names from the ID and creates no Pub/Sub resources. It still creates the user's Workspace Events subscriptions, which deliver to the user's topic.
- One subscription per user means two machines running mutter at once split the events between them, so each misses some live updates. Opening a space still loads it in full.
| Option | Isolation | Cost |
|---|---|---|
| Shared topic | metadata visible within the group | Pub/Sub delivery grows with users², since filtered-out messages are billed |
| Topic per user in a shared project (recommended) | full | admin-created topics and subscriptions |
| Project per user | full | each user needs a billing-enabled project they own, and runs setup themselves |
Running mutter in an organization estimates the cost and covers Google's terms.
-
Get your organization's config from its admin, and install it:
mutter config import https://intranet.example.com/mutter.json # or a local file -
Run
mutter. The first run opens a browser for login, and the token is stored in the OS keychain. On Linux, the keychain is the Secret Service over D-Bus, such as GNOME Keyring or KeePassXC. Without one, mutter can't store the token.
The config lives in ~/Library/Application Support/mutter/config.json on macOS and ~/.config/mutter/config.json on Linux, readable only by the user.
| Command | Does |
|---|---|
mutter config init [--force] |
writes an empty config to fill in |
mutter config edit |
opens it in $VISUAL or $EDITOR, and checks it when the editor exits |
mutter config import [--force] FILE|URL |
installs a config from a file or an https URL, after checking it |
| Key | Value |
|---|---|
client_id, client_secret |
the organization's Desktop OAuth client. Required. Desktop client secrets aren't confidential. |
topic |
projects/PROJECT/topics/TOPIC, a topic shared by all users, see Isolating users |
topic_project |
PROJECT, a project with a topic per user. Set this or topic, not both. |
Without a topic, mutter works but has no live updates, and refreshes a space when it's opened.
{
"client_id": "....apps.googleusercontent.com",
"client_secret": "...",
"topic": "projects/<PROJECT_ID>/topics/mutter-events",
"topic_project": ""
}mutter --help lists the commands, the environment variables mutter reads, and where its config, logs and cache live. Inside mutter, F1 or /help lists the keys and commands.
./mutter
| Feature | Needs |
|---|---|
| Images and animated GIFs | kitty graphics protocol with Unicode placeholders, as in Ghostty and kitty. Other terminals show [n · name]. |
| Desktop notifications | OSC 777, as in Ghostty |
| shift+enter for newline | kitty keyboard protocol. alt+enter and ctrl+j work everywhere. |
| Key | Action |
|---|---|
| enter | send, or open the selected thread when the input is empty |
| shift+enter, alt+enter, ctrl+j | newline. Pasted text keeps its newlines and never sends early. |
| ctrl+e | edit the input in $VISUAL or $EDITOR, vi by default. The text comes back when the editor exits. The End key moves to the end of the line. |
| ctrl+v | paste an image from the clipboard to send with the next message, or text when there's no image. cmd+v belongs to the terminal, which pastes text and file paths but never images. |
| ctrl+x | remove the last image waiting to be sent |
| ctrl+b | show or hide the sidebar of unread and recent spaces. It hides itself in windows narrower than 100 columns. |
| alt+1…9, ctrl+1…9 | open a sidebar entry. alt needs option-as-alt on macOS, ctrl works without it. |
| ctrl+n | open the next thread with new messages in the open space, oldest first. With none left, open the next unread space, in the switcher's order. |
| ctrl+k | switch space. The filter matches names and sidebar sections, and ✎ marks spaces with a draft. |
| ↑ ↓ with an empty input | select a thread, or a message inside a thread |
| ↑ on the oldest thread | load older history |
| pgup, pgdown | scroll |
| tab, shift+tab | complete a /command, an @mention, @all in spaces, a /dm argument or a :shortcode. Repeated presses cycle through the suggestions. |
| esc | cancel editing or quoting, end selection, leave the thread |
| F1 | show keys and commands, as /help does. Any key closes it, and typed characters go to the input. |
While a thread or message is selected, letter keys act on it. Any other key goes to the input.
| Key | Action |
|---|---|
r |
react: 1–6 pick a quick reaction, or type a name to search all emoji, your organization's custom emoji first, tab to move, enter to pick. Picking one again removes it. |
e |
edit your own message |
d |
delete your own message, confirmed with y |
q |
quote it in your next message |
y |
copy its text to the clipboard, over OSC 52 |
l |
open a link in it. With several, pick one with 1–9. |
c |
copy a link to it in the web client |
b |
open it in the web client, for what mutter can't do, such as calls, polls and card buttons |
v |
view its first image or GIF at the size of the message pane. Clicking an image does the same. Any key or click closes it. |
u |
mark the space unread from this message onward |
o |
open its files |
s |
save its files to ~/Downloads |
While the sidebar or images show, the terminal passes clicks to mutter: clicking a sidebar entry opens it, clicking an image views it, and the scroll wheel scrolls messages. Select text with shift held.
Dropping an image file on the terminal pastes its path, and mutter attaches the file in place of the path.
| Command | Action |
|---|---|
/find QUERY |
search messages in every space you're in, newest first. ↑ ↓ pick, enter opens the thread with the message selected. Plain words search text, and the API's filters work too, such as has_link(), is_unread() or sender.name = "users/kn@example.com". |
/triage |
step through every unread thread, space by space. Each opens in turn. Enter on an empty input moves on, a reply goes out and moves on, ↑ selects a message to react to, esc stops. |
/mentions |
recent messages that mention you across all spaces, unread first and marked ●. Enter opens the thread. Mentions read in mutter count as read even though the API can't learn it. |
/dm WHO |
open or start a DM. WHO is an email address, a short name such as kn tried at your own domain first, or part of a name. |
/new NAME |
create a space with only you in it, and open it |
/rename NAME |
rename the open space |
/invite WHO |
add someone to the open space. WHO works as in /dm. |
/leave |
leave the open space, confirmed with y |
/mute, /unmute |
mute or unmute the open space, as in the web client. A muted space never notifies and doesn't count as unread. |
/attach PATH [text] |
upload a file into the open thread, or as a new thread. Quote paths with spaces. |
/open [n] |
open file n of the selected or open thread, the last one by default |
/save [n] |
save file n to ~/Downloads |
/read |
mark the open space and its threads read |
/read all |
mark every unread space read |
/unread |
mark the space unread from the selected or open thread onward |
/dnd [DURATION] |
Do Not Disturb, for an hour or a duration such as 30m or 2h |
/away |
show as away until you're active again |
/active [DURATION] |
show as active, back to activity-based after DURATION |
/status [EMOJI] [TEXT] |
set your status, with 💬 when no emoji is given. Without text, it's cleared. |
/gif QUERY |
search GIPHY and pick a GIF with ← → or tab. Enter attaches it to your next message. Needs GIPHY_API_KEY, see GIFs. |
/web |
open the space, or the open thread, in the web client |
/help |
show keys and commands |
/logout |
delete the stored token and the local cache, and quit |
/quit |
quit |
Saved files get the macOS quarantine attribute, so Gatekeeper checks them before they run. Leading dots are stripped from their names, so a file can't land as a hidden dotfile. /save creates ~/Downloads when it's missing. /open downloads into mutter's directory in the user cache dir. /open only follows http and https links, and only http and https links in cards are clickable.
/gif uses your own GIPHY API key, so mutter ships without one and you accept GIPHY's terms yourself:
- Create an app at developers.giphy.com and copy its API key.
- Set it in your shell profile, for example
set -Ux GIPHY_API_KEY <key>in fish orexport GIPHY_API_KEY=<key>in bash and zsh.
Each search is one API request, and a new key allows 100 an hour. Previews and the GIF you send come from GIPHY's media servers and don't count. The picked GIF is downloaded and sent as an uploaded image, since the Chat API can't post GIFs the way the web client's picker does. Searches use the pg rating.
mutter keeps spaces and processed images in the user cache directory, ~/Library/Caches/mutter/store on macOS:
- Encryption: files are encrypted with AES-GCM. The key is in the OS keychain next to the login token, so a copied cache file can't be read.
- Size: spaces are capped at 50 MB and images at 200 MB. The least recently used are removed at startup.
- Freshness: while live updates run, a space opened earlier in the session shows from memory with no fetch. After a restart, a space shows its cached copy, marked
refreshing…, until the fresh load replaces it. - Offline: when Google can't be reached at startup, mutter starts with the cached space list and shows each space's cached copy. Once live updates connect, the list and the open space reload. Your identity is kept unencrypted in
identity.jsonnext to the cache. - Removal:
/logoutdeletes the cache and its key.
The first run asks the user to grant these OAuth scopes. mutter asks again whenever the set changes.
| Scope | Used for |
|---|---|
openid, email |
identifying the user |
chat.spaces |
listing and renaming spaces |
chat.spaces.create |
starting DMs with /dm |
chat.messages |
reading, sending, editing and deleting messages, reactions, attachments |
chat.memberships |
DM titles, @mention completion, /invite and /leave |
chat.users.readstate |
unread state |
chat.users.spacesettings |
notification and mute settings, /mute and /unmute |
chat.users.sections.readonly |
sidebar sections |
chat.users.availability |
your Do Not Disturb, away state and status |
chat.customemojis.readonly |
custom emoji in completion, the reaction picker, reactions and messages |
pubsub |
pulling live events |
Planned improvements are in ROADMAP.md. The ones under Chat API can't be fixed in mutter.
- Opening a thread clears its markers only in mutter. The API can read a thread's read state but not write it. Reading a thread in the web client or on your phone does clear it in mutter.
FOR_YOUnotifications fire on @mentions only. The API doesn't expose which threads you follow.- Card buttons that call Chat apps don't work, and interactive card widgets are skipped. Both need app authentication.
- Other people's presence isn't shown. The API only returns the user's own availability.
- Opening a space marks all of it read, as the web client does, however far you scroll.
- At startup, spaces with no activity for 30 days count as read.
- History loads 200 messages at a time.
- Without live updates, every space switch fetches again, since nothing keeps the cached copy current.
- DMs and group chats whose other members have all left the organization are hidden.
- Only one account at a time.
- A message that fails to send goes back into the input with its images, quote and mentions. If you switched space or thread meanwhile, it becomes that thread's draft, and its images and quote are dropped.
- An @mention notifies only when completed with tab. A typed
@namestays plain text. - Editing a message sends its mentions back as plain text.
- Several pasted images go out as one message each, with the text on the first.
- Pasted text has its tabs turned into spaces.
/dmmatches names and completes only members of the spaces opened since startup. Short names at your own domain and full email addresses work for anyone.
- Images need a terminal with kitty graphics and Unicode placeholders, such as Ghostty or kitty.
- WebP images and images inside cards show as text.
- GIFs play their first 150 frames.
- Images keep their size when the window is resized, until mutter restarts.
- Drive files and GIFs open in the browser.
/savecan't download them without a Drive scope.
- The first start takes about 30 seconds to resolve DM and group-chat names. Later starts use a cache.
- Live updates need a Pub/Sub topic. Without one, mutter only refreshes when a space is opened.
- On the shared topic, group members can see each other's event metadata. See Isolating users.
- With per-user topics, two machines running mutter at once split the user's live updates between them. Opening a space still loads it in full.
- macOS and Linux only.
- On Linux, storing the login token needs a Secret Service, such as GNOME Keyring or KeePassXC.
- The macOS binary isn't signed. Homebrew and
scripts/install.shinstall it without Gatekeeper prompts, but a browser download gets blocked.
Needs Go, just and golangci-lint. gosec and govulncheck run with go tool, pinned in go.mod. AGENTS.md describes the code layout and the rules for changing it.
| Recipe | Does |
|---|---|
just check |
format check, lint, gosec, govulncheck and tests, as CI runs them |
just cover |
print total test coverage and write coverage.html |
just build |
build ./mutter |
just run ARGS |
build and run |
just fmt |
format with gofumpt and goimports |
just fix |
modernize the code with go fix |
just golden |
rewrite the golden render files after an intended change |
just bench |
run the benchmarks, such as rendering a 200-thread space |
just snapshot |
build all release targets into dist/ |
just alone lists every recipe.
Planned work is in ROADMAP.md, and ideas that aren't planned are in IDEAS.md.
mutter logs to ~/Library/Logs/mutter/mutter.log on macOS, and to $XDG_STATE_HOME/mutter/mutter.log, usually ~/.local/state/mutter/mutter.log, elsewhere. Each start keeps the previous run's log as mutter.log.1.
MUTTER_LOG_LEVEL sets how much goes in, warn by default:
| Level | Adds |
|---|---|
error |
errors shown in the status line |
warn |
failures the user may notice, such as cache writes or API calls in the background |
info |
login, live-update subscriptions |
debug |
each event, image processing, paste steps, and at exit the API calls per method |
trace |
raw API responses, and tty.out next to the log with every byte sent to the terminal. tty.out grows fast while GIFs animate, so keep runs short. |
MUTTER_LOG_LEVEL=debug ./mutter
MIT, see LICENSE.