Skip to content

Latest commit

ย 

History

110 Commits

Folders and files

NameName
Last commit message
Last commit date
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

spacebar

Google Chat from your terminal, your scripts, and your agents.

CI Licence Go Dependencies CGO

$ spacebar send "deploy done"
message  spaces/AAAAAAA/messages/BBB
space    spaces/AAAAAAA
profile  alerts

That is the whole point. One line, from a script or a terminal, with no ceremony after setup: a webhook profile knows which space it posts to, so there is nothing else to say. The same capabilities are available to an agent with no human in the loop, through --json and through a built-in MCP server.

What you get

Send with no OAuth at all An incoming webhook needs no Cloud project, no admin approval, and no consent screen. Paste a URL, send.
Read, edit, react, follow spaces, messages, tail, watch --all, react, messages edit/delete, attachments up and back down.
Search what was said There is no message search API for an ordinary user, so sync keeps a local copy and search reads it back.
Serve a model spacebar mcp speaks MCP on stdio. Writes are off by default and the tools a profile cannot serve are never registered.
Built for scripts --json is NDJSON so it streams, stdout is data and nothing else, and the exit code tells you which kind of failure it was.
One binary, no cgo Six platforms, five direct dependencies, and a licence gate that fails the build over any of them.

Quick start

The path that needs no OAuth, no Cloud project, and nobody's approval. In the Chat space: Apps & integrations โ†’ Webhooks โ†’ copy the URL.

git clone https://github.com/kmoneil/spacebar && cd spacebar && make build

pbpaste | ./bin/spacebar profile set-webhook alerts   # macOS
./bin/spacebar send "deploy done"

That is the whole setup. The URL goes to your OS keyring, never to a file and never onto a command line. To act as yourself instead of as a bot, see Authorize as yourself.

Contents

Start here ย ยทย  Install ย ยทย  Configure a webhook ย ยทย  Authorize as yourself

Use it ย ยทย  Send ย ยทย  Read a space ย ยทย  Name a space ย ยทย  Follow a space ย ยทย  Search

For agents ย ยทย  Serve it to a model ย ยทย  docs/AGENTS.md ย ยทย  docs/SKILL.md

Everything else ย ยทย  Why it is shaped this way ย ยทย  Status ย ยทย  Environment ย ยทย  Development ย ยทย  SECURITY.md ย ยทย  docs/ADMIN.md

Google Chat and Google are trademarks of Google LLC. spacebar is an independent third-party client. It is not affiliated with, sponsored by, or endorsed by Google, and Google does not support it.

Status

All six milestones in the spec are done, and the adversarial security review that followed them produced fourteen fixes that are all on main. Nothing is released yet.

# Milestone What it delivered
1 Skeleton and licensing The build, the CI gates, and version, licenses, completion.
2 Webhook transport send end to end with no OAuth at all, and --md, --dry-run, --json and the exit codes with it.
3 User OAuth auth setup, login, status and logout, the keyring, expiry warnings, and the first read commands.
4 Full CLI tail, watch, react, edit and delete, aliases, attachments, and target resolution.
5 MCP server Capability-gated tool registration, write gating, and the two agent guides.
6 Index and search The NDJSON store, sync and search. SQLite with FTS5 was priced and refused, with a threshold recorded.

Milestone 2 is the one that mattered. An organization that blocks third-party API access leaves an incoming webhook as the only way in, and that population is large enough that a tool useless to them reaches far fewer people than it looks like it does.

Everything below works:

$ spacebar --help
  alias       Name a space something you will remember
  auth        Authorize this machine to act as you
  completion  Generate the autocompletion script for the specified shell
  licenses    Print the licence of every dependency in this binary
  mcp         Serve this profile to a model over MCP
  messages    List and read messages
  profile     Configure the profiles this tool sends through
  react       React to a message with an emoji
  search      Search the local index for what was said
  send        Post a message to a space
  spaces      List and inspect spaces
  sync        Copy a space's messages into the local index
  tail        Follow a space as messages arrive
  version     Print the version of this binary
  watch       Follow everything that happens in a space, not just new messages

send needs only a webhook. Everything that reads needs a profile authorized as you. mcp serves either to a model.

Every behaviour described here is covered by tests, and the parts that matter have been run against a real Google Chat space rather than a test server: the webhook path, browser OAuth against a client in a Workspace organization, all five read endpoints, and posting, editing, reacting and deleting as the account. Those live runs found two bugs no test in the tree could have caught, which is the argument for doing them.

Why it is shaped this way

Five decisions that shape everything else: three auth models rather than one, narrow OAuth scopes, Chat markup that is not CommonMark, output built for a script before a person, and a dependency budget of five.

Each of those has cost something to get wrong, and the reasoning is in SECURITY.md where it bears on safety and in the source comments where it bears on a decision.

Install

Nothing is released yet. From source:

git clone https://github.com/kmoneil/spacebar
cd spacebar
make build     # -> bin/spacebar

A build from source has no OAuth client baked in, which is deliberate (see SECURITY.md), and spacebar auth setup will walk through creating one.

Configure a webhook

This is the path that needs no OAuth, no admin approval, and no Cloud project. In the Chat space, open Apps & integrations, then Webhooks, and copy the URL. Then, in one command:

pbpaste | spacebar profile set-webhook alerts     # macOS
xclip -o | spacebar profile set-webhook alerts    # Linux
spacebar profile set-webhook alerts < webhook.txt # from a file
profile     alerts
transport   webhook
credential  keyring:spacebar/alerts/webhook

The URL arrives on stdin or in SPACEBAR_WEBHOOK_URL, never as an argument: it carries key and token parameters that are the entire authentication for that space, so it is a credential, and an argument lands in your shell history. It goes to the OS keyring and the config file gets the reference above.

The first profile becomes the default, so nothing after this needs --profile.

--verify proves it works by posting a message, which is the only way to find out: a webhook has no endpoint that reports its own health, so a mistyped URL and an org with Chat apps switched off otherwise look the same.

pbpaste | spacebar profile set-webhook alerts --verify
spacebar profile list           # what is configured, without reading a credential
spacebar profile list --json    # one object per line
spacebar profile rm alerts      # the profile and every credential behind it

On a machine with no keyring, which is every container and most CI runners, the credential goes to a mode 0600 file beside the configuration instead, and says so every time it is used.

Send

spacebar send 'deploy done'                    # the profile knows its space
spacebar send spaces/AAAAAAA 'deploy done'     # name the space
spacebar send --md 'deploy **done**'           # translate CommonMark
echo 'deploy done' | spacebar send -           # body from stdin
spacebar send --thread-key deploys 'v1.2.3'    # group into a thread
spacebar send --mention a@b.com 'ship it'       # notify somebody
spacebar send --dry-run 'deploy done'          # print the request, send nothing
message  spaces/AAAAAAA/messages/BBBB
space    spaces/AAAAAAA
profile  alerts

--json puts one object on stdout and nothing else, so it pipes into jq. Warnings, logs and failures go to stderr, and the exit code says what happened: 0 sent, 2 you asked for something impossible, 3 the API said no, 4 authorize again, 5 this profile cannot do that, 6 rate limited.

Chat markup, threading, idempotency, cards, and what a webhook may not do

Text is sent exactly as typed. Chat markup is not CommonMark: bold is one asterisk, so **bold** arrives with the asterisks showing. --md translates, and the translation is one way. **bold** becomes *bold*, which read back as CommonMark is italic, so a body that has already been through --md must not go through it again. The output of a dry run is not an input.

--dry-run prints the exact request and sends nothing. It works on every command that reaches the network, reads included, and the credential is redacted before it is printed:

$ spacebar send --dry-run 'deploy done'
POST https://chat.googleapis.com/v1/spaces/AAAAAAA/messages?key=REDACTED&token=REDACTED
Accept: application/json
Content-Type: application/json; charset=UTF-8
User-Agent: spacebar/0.0.0-dev (+https://github.com/kmoneil/spacebar)

{"text":"deploy done"}

The Authorization header, when there is one, prints as REDACTED rather than being left out: an omitted line reads as "no credential was sent", which is a different answer to the question a dry run is asked. --json gives the same thing as an object with a dry_run field.

--file is the one case where a send is two requests: the bytes are exchanged for an upload token and the message carries the token. A dry run shows the upload, exactly, and says on stderr that the message would follow, because the second request carries a token the first one returns and cannot be shown without making it. The upload's body is the file, so it is described with its exact size rather than printed: an attachment may be 200MB and printing it is not showing a request.

The file is declared as what its bytes are, sniffed rather than taken from the filename extension. That is what the API records as the attachment's content type and what decides whether Chat shows an image or a generic file card. It is sniffed because the extension is the half a caller can get wrong, and because resolving one against the machine's own mime database would give the same file a different answer on different platforms.

Declaring the true type makes Chat validate the bytes against it, which is a real cost and not a theoretical one: a 1x1 GIF uploads as application/octet-stream and is refused as image/gif, with a 400 that blames the file extension or the quota and means neither. --content-type overrides the detection when you hit one.

A flag this profile cannot honour fails before the network call, naming the capability and the profile rather than pretending the flag does not exist:

$ spacebar send --file report.pdf 'here it is'
error: "send --file" needs attachment upload, and profile "alerts" is an
incoming webhook, which is fixed to one space, write-only, and posts as a bot.
Use a profile whose transport is useroauth:
  spacebar auth setup --profile NAME < client_secret.json
  spacebar auth login --profile NAME
Run 'spacebar auth setup' on its own to see how to create the client.

A webhook is still the only path that needs nothing from an administrator, which is the point of it.

Authorize as yourself

Reading needs an account rather than a webhook, and an account needs an OAuth client. If your organization blocks third-party applications, and many do, the answer is a client you create in your own Cloud project, which is not third-party to you. The tool prints the walkthrough with no browser and no network:

spacebar auth setup                                        # what to click
spacebar auth setup --profile work < client_secret.json    # store what you downloaded
spacebar auth login --profile work                         # consent in a browser
spacebar auth login --profile work --send-only             # ask to post and nothing else
spacebar auth status                                       # what it may do, and for how long
spacebar auth logout                                       # forget it locally

The client secret is read from stdin, never from an argument. Only the identifier and the secret are taken out of that file: its endpoints are ignored, because a file that could redirect the consent screen would be a file that could collect your authorization.

When there is no browser here: a container, SSH, a remote dev box

Nothing extra to run. spacebar auth login prints the consent URL and then asks:

Could not open a browser. Go to this URL to authorize:
https://accounts.google.com/o/oauth2/v2/auth?...

If the browser is on another machine it cannot reach this one. Paste the URL it
failed on here, and the authorization in it finishes the login:

Open the URL wherever you have a browser. Consent. The browser lands on ERR_CONNECTION_REFUSED at http://127.0.0.1:PORT/, because the listener is on the machine running the command and 127.0.0.1 in your browser is your laptop. Copy that failed URL out of the address bar and paste it at the prompt.

The authorization is in that URL. Pasting it finishes the login exactly as if the redirect had arrived by itself.

Both routes run at once and you never choose. The listener keeps waiting the whole time, so on a machine whose browser can reach it the redirect arrives on its own and nobody answers the prompt. Where it cannot, the paste is the route. There is no flag.

A wrong paste costs a sentence, not the attempt. A stray newline, the consent URL from the wrong window, a half-copied line: each is refused with a reason and it asks again, until the three minutes are up.

The prompt is only shown when somebody is there to answer it. Piped or redirected input never sees it and never blocks, which is the rule that nothing in this tool waits on input when stdin is not a terminal.

What stops a URL you were talked into pasting

The listener checks the state value it generated before it reads anything else, and a pasted URL runs that same check because it goes through the same function the socket does. A callback from somebody else's flow is refused. Even with the right state, the exchange afterwards is bound by PKCE to a verifier that never left this process.

So the code in that URL is worth nothing to anybody else, which also means a code that ends up on your screen is not an emergency. Finish the flow with it: a spent code is inert.

If you would rather not paste

The listener is an ordinary HTTP server on your own machine, so the failed URL can be replayed with anything. In a second shell, while the first is waiting:

read -r url
curl -s "$url" >/dev/null

Paste at the read prompt rather than onto the curl line, because a command line goes into your shell history and a read does not. This is the same route by hand, and it is what to reach for if you are driving auth login from something that is not a terminal.

Two more things

  • You have three minutes from when the URL prints, covering opening it elsewhere, consenting, and getting back.
  • If it runs out, nothing changed. Measured rather than assumed: after a timeout the configuration and the credential store are byte-identical to what they were, and an existing authorization still works. Run it again.

On a machine with no OS keyring, which containers usually are, the refresh token lands in credentials.json at mode 0600 instead, and every later command says so on stderr. That is by design and worth knowing before you authorize on a shared box.

Scopes, the seven-day testing-mode limit, and what your admin has to allow

Scopes are requested narrowly. --send-only asks for chat.messages.create and nothing else, because a narrower request is one an administrator is more likely to approve, and that is often the difference between the tool working and not.

A command a scope does not cover fails at exit 5 before the request, naming the profile and the command that widens the grant, rather than coming back as a 403 about your account. So an upgrade that adds a scope tells you to authorize again on the profile you are already using:

$ spacebar spaces members spaces/AAAAAAA
error: "spaces members" needs the ability to read who is in a space, and profile "work" was not granted it.
Consent to the scope it needs by authorizing again:
  spacebar auth login --profile work
The scopes this build asks for have grown since that token was issued.

spacebar auth status prints the scopes a profile actually holds.

Some of this needs a Chat app configured on your Cloud project, which is a separate step from enabling the Chat API and lives under Chat API, Configuration in the Cloud console. Measured, not guessed:

works without it   spaces list, spaces get, spaces members,
                   messages list, messages get, and resolving an address
needs it           send, and following a space with tail's successor

The line is not reading against writing, which is the obvious guess and is wrong: following a space's events is a read and needs the app.

Without it those calls refuse with a 404 saying "Google Chat app not found", which mentions neither the space nor the scope. spacebar explains it when it happens, so you will not have to work that out from the API's wording.

One warning you may see. An OAuth client that has not been verified by Google is in testing mode, where authorizations are revoked seven days after consent. Nothing in the API says whether yours is, so the warning is worded as a possibility, and it stops for good once a refresh proves the limit does not apply to you. A client with an Internal user type is not subject to it at all.

docs/ADMIN.md is the page to hand an administrator. docs/AGENTS.md is the one to hand a script or an agent driving the command line, and docs/SKILL.md covers the MCP server. Every example in both is held to the code by a test, because the reader of those two cannot tell a stale example from a current one.

Read a space

On a profile authorized as you, rather than a webhook:

spacebar spaces list                                  # name, type, name, bot, last active
spacebar spaces list --limit 0                        # every one
spacebar spaces get spaces/AAAAAAA
spacebar spaces get 'Ops'                             # or by display name
spacebar spaces members spaces/AAAAAAA                # who, kind, state, role, affiliation
spacebar spaces members spaces/AAAAAAA --show-invited # and anybody asked but not joined
spacebar spaces members spaces/AAAAAAA --show-groups   # and any Google Group with access

spacebar messages list spaces/AAAAAAA                 # newest 25
spacebar messages list spaces/AAAAAAA --limit 100
spacebar messages list spaces/AAAAAAA --order oldest
spacebar messages list spaces/AAAAAAA --since 2h      # the last two hours
spacebar messages list spaces/AAAAAAA --since 2026-08-16T09:00:00Z --until 2026-08-16T17:00:00Z
spacebar messages get spaces/AAAAAAA/messages/BBBBBBB

A space can grant access to a Google Group, and everybody in that group is then in the space without a membership of their own. Those rows are not returned unless --show-groups asks for them, so the default list can be a narrower answer than "who can see what I post here". When one is left out you are told on stderr, with the count and the flag; stdout is unchanged either way. The group row is groups/NNN and nothing more: a Chat scope cannot reach who is in it.

And the three that change what is already there:

spacebar messages edit spaces/AAAAAAA/messages/BBBBBBB 'the corrected text'
spacebar messages delete spaces/AAAAAAA/messages/BBBBBBB      # asks first
spacebar react spaces/AAAAAAA/messages/BBBBBBB ๐Ÿ‘
Who may edit and delete, and how a reaction is addressed

Editing is limited to messages you sent, and deleting is not. Measured, not assumed: editing your own message answers 200 and editing somebody else's answers 403, a second apart on the same token, while a delete of somebody else's message in a space you manage is allowed. So delete asks before it acts, and with stdin not a terminal it exits 7 rather than prompting. --yes answers in advance.

react takes the emoji, not a shortcode. :thumbsup: is refused by the API at the type level, so this tool refuses it before the request and says to paste the character rather than carrying a table of shortcode names that goes stale.

Newest first, so that the default limit returns the latest messages rather than the oldest ones in a space's history. Reading a conversation in the order it happened is what tail will be for.

Name a space without its ID

Every command that takes a SPACE also takes a display name or an address, and resolves it before making the request:

spacebar spaces get 'Ops'                     # a display name, matched loosely
spacebar messages list 'ops' --limit 20       # case does not matter
spacebar send 'Ops' 'deploy done'
spacebar spaces get someone@example.com       # the direct message with them
How a name is resolved, and why an ambiguous one is a failure rather than a guess

Four steps, in order, and the last one never guesses. A literal spaces/XXXX passes straight through and costs nothing. Then a profile alias. Then anything containing @, which is a person and becomes a direct message lookup. Then the display names of the spaces you can reach: an exact match wins, otherwise a substring match, both ignoring case.

Two matches is a question, not a tie. If more than one space matches, it lists them and exits 2 rather than picking one. The cost of picking wrong is a message in front of people who were not meant to see it.

$ spacebar send 'ops' 'deploy done'
error: 2 spaces match "ops", and this tool does not guess which one you meant:
  spaces/AAAAAAA	Ops
  spaces/BBBBBBB	Ops Escalation
Name the one you want directly, as in: spacebar spaces/AAAAAAA

An exact match beats a substring, so with those two spaces 'Ops' reaches the one actually called Ops rather than being ambiguous forever.

The space list is cached for 24 hours, per profile, under $XDG_CACHE_HOME/spacebar. Listing spaces costs quota shared with every other app in those spaces, so a resolver that listed on every command would degrade the space for everybody. --refresh fetches it again. A resource name or an alias never touches the cache at all.

It holds what spaces list returns, at mode 0600: resource names, types, and display names, with no message text and no credential. auth logout and profile rm delete it. That second one is not tidiness: a profile name is reusable and the file is keyed by it, so a name configured again for a different account would otherwise resolve display names against the previous account's spaces for the rest of the day.

Give a space a name you will remember:

spacebar alias set eng spaces/AAAAAAA
spacebar alias set eng 'Engineering'      # resolved now, stored as the space
spacebar alias set bob bob@example.com    # the direct message with them
spacebar alias list
spacebar alias rm eng

The target is resolved when the alias is set, and the space it resolved to is what gets stored. A display name is a label somebody else controls, so storing one would mean an alias that quietly points somewhere new the day a room is renamed.

An alias belongs to one profile, so a work profile and a personal one cannot see each other's names. It cannot contain a slash or an @, because resolution reads a name of either shape as something other than an alias, and one that could never be consulted is worse than one that is refused.

A webhook profile can use an alias, because an alias is a local map and needs no permission. The last two steps need to read and are refused on one.

Follow a space

spacebar tail spaces/AAAAAAA
spacebar tail eng --backfill 20        # the last 20, then follow
spacebar tail eng --since 30m          # everything since, then follow
spacebar tail eng --json | jq -r .text

Oldest first, because this is a conversation read in the order it happened. That is the opposite of messages list, which is newest first so that a limit cuts from the recent end.

Google Chat offers no socket, so this polls. The interval floor is 2s and a smaller one is refused rather than rounded up: per-space quota is shared with every other app acting in that space, so a tight loop degrades the space for everybody in it. After five polls with nothing new the interval doubles, up to a minute, and any message resets it. It never goes below what you asked for, so --interval 5m stays at five minutes however quiet the space is.

Ctrl-C exits 0. It is how the command is meant to end, so it is not a failure, and a script wrapping it does not have to special-case the code.

Attachments

spacebar send spaces/AAAAAAA 'the report' --file report.pdf
spacebar messages download spaces/AAAAAAA/messages/BBBBBBB --out ~/Downloads

Sending a file is two requests, because that is the API's shape: the bytes are exchanged for a token, and the token is what a message can carry. The upload goes first, so a file that fails to upload does not become a message with the text and no file.

Why a server-supplied filename is flattened, and why no download URL is published

A downloaded file lands inside --out and nowhere else. The name comes from whoever posted the message, so an attachment called ../../.ssh/authorized_keys is written as .._.._.ssh_authorized_keys in the directory you asked for. Nothing is overwritten without --force, because the name is not yours.

That is two claims and both are held. The name cannot leave the directory, and neither can the write: a symlink already sitting there under the name an attachment happens to have is refused rather than followed, so a download cannot write through one into a file outside --out. It matters where the directory is not only yours, which is a shared CI workspace, /tmp, or a synced folder. --force replaces the name rather than following it, so it overwrites the file you can see and not whatever a link points at.

The API's own download URL is never printed. It carries an access token in its query, which makes it a credential rather than a link, so --json gives you the attachment's resource_name and this tool fetches the bytes with your own credential.

A Drive attachment is listed and skipped: Chat returns a reference rather than the bytes, and fetching it is Drive's API rather than this one.

Watching, which is not tailing

tail polls for messages, so it cannot see an edit, a deletion, or a reaction: a poll on createTime returns new messages and none of those makes one. watch polls spaceEvents instead and reports them as events.

spacebar watch spaces/AAAAAAA
spacebar watch eng --events message,reaction,membership
spacebar watch eng --since 2h --json
spacebar watch --all

Columns are the time, the kind of event, the resource it happened to, and the message text when the event carries one.

2026-08-16T22:04:01.102175Z  message created   spaces/A/messages/8xM   spacebar watch live check
2026-08-16T22:04:01.769711Z  reaction created  spaces/A/messages/8xM/reactions/115...

--events takes any of message, reaction, membership and space, and defaults to message,reaction, which is the conversation. Membership and space updates are administrative and arrive on a different rhythm, so they are opt-in rather than noise.

--all watches every space the profile can reach, and the interesting part is the pace it chooses. The spaces are polled one at a time, round robin, at a rate this process holds to 10 requests a second however many there are. Google allows 3000 a minute for the whole Cloud project, which is fifty a second, and that quota belongs to the OAuth client rather than to you: an organization following docs/ADMIN.md shares one, so taking all of it would mean the first person to start --all denies the second, with nothing in any response saying why. A fifth of it leaves room for five.

Below twenty spaces the 2s floor decides and nothing is slowed at all. Above it each space comes round less often, thirty spaces every 3s and a hundred every 10s, and the interval chosen is printed on stderr at startup unless --quiet or --json says the reader is not a person.

A quiet space backs off here exactly as it does when you watch one space: after five polls with nothing new it gives up its turn in the rotation, doubling up to a minute, and any event puts it back at the pace you asked for. It is per space, so one busy space does not hold thirty quiet ones at the base interval.

The pace is recomputed when a space is dropped, so the ones left are polled no faster than you asked for. It looks like a detail and is the floor: the gap between requests is the interval divided by the space count, so a rotation that loses spaces and keeps its old gap comes round quicker every time, and a watch of forty spaces that lost thirty-nine would poll the last one twenty times a second.

The list of spaces is taken once. A space created while it runs is not picked up, because re-listing spends the quota this is being careful with and a watch whose subject changes underneath it is harder to reason about; restart to pick up a new one. A space that goes away, or that turns out not to be readable, is dropped with a line on stderr saying which and why, and the others carry on. If every space is dropped the exit is non-zero, because a watch that is watching nothing has not finished, it has been abandoned.

--json carries the API's own event payload under payload, unaltered, because for a message that has since been deleted the payload is the only place its tombstone exists.

This endpoint needs a Chat app configured on the Cloud project, which is a separate step from enabling the Chat API. Without it the API answers 404 and mentions neither; spacebar explains it.

Search what was said

There is no message search API for an ordinary user. spaces.search is administrator-only and searches spaces rather than messages, so searching means keeping a copy:

spacebar sync --all                       # copy every reachable space down
spacebar sync eng --limit 5000            # or one space, a bit at a time
spacebar search "deploy"
spacebar search "deploy" --space eng --since 30d --json

sync is resumable and holds no cursor: the index knows the window it covers, so an interrupted run resumes by being run again, fetching nothing twice and leaving no gap.

search names the spaces it did not look in, on stderr, from the space list already cached. A space you never synced is not searched, and a search that skipped it quietly would answer a narrower question than you asked.

A result is a snapshot, not the current state of the space. sync walks createTime, and an edit does not change createTime, so a message somebody else edited after it was copied is found by the text it had then and one somebody else deleted is still found. spacebar messages get reads the API and is how you check one. A message you edit or delete with this tool is recorded as it happens, for a space the index already holds.

The index is plaintext on your disk and nothing removes it, because it holds messages the API will not serve again. SECURITY.md says what that costs. It is a scan, not a search engine: 0.34s at 50,000 messages, crossing one second at roughly 175,000.

Serve it to a model

spacebar mcp speaks MCP on stdin and stdout. It is not a command to run by hand: an MCP client starts it, and in that client's configuration it is the command with the profile as an argument.

{"command": "spacebar", "args": ["mcp", "--profile", "work"]}

Five read tools: list_spaces, get_space, list_members, list_messages, get_message. Three behind --allow-write: send_message, react_to_message, and sync_space, which posts nothing but copies a space onto your disk and spends its shared quota. And search_messages, which appears when this machine has a local index or when sync_space can fill one. They return the same shapes --json does, because both come from one place.

A model is never shown a tool this profile cannot serve, writes are off until --allow-write says otherwise, --allow-space narrows where they may land, and every tool call is one JSON line on stderr that no flag suppresses.

Eight tools is fewer jobs than the command line does, and the gap is written down rather than left to be discovered. spacebar messages edit, spacebar messages delete, spacebar messages download and spacebar sync have no tool yet, and neither do send --file and send --card. Following a space live has none by design: a tool result is one document and a follow does not end. A test holds every command to being served, deliberately unserved, or owed a named tool, so this paragraph cannot go quietly out of date.

docs/SKILL.md is the full contract, including the confirmation sentence every write tool carries and why message text reaching a model is untrusted input.

The rest of it

spacebar version --json                   # name, version, commit, go, os, arch
spacebar licenses                         # every dependency's licence, from the binary
spacebar completion zsh > ~/.zsh/_spacebar
spacebar completion bash > /etc/bash_completion.d/spacebar
spacebar completion fish > ~/.config/fish/completions/spacebar.fish
spacebar completion powershell | Out-String | Invoke-Expression

completion is why cobra earns its place: the four shells are tedious to hand-roll and this is the part a person notices. licenses reads a file embedded at build time, so the binary can always say what is in it without a network call or a checkout.

Environment

Nine variables, and not one of them is required: each has a flag, a configuration entry, or a default. They are here because a CI job cannot type a flag, and because a credential passed as an argument lands in the shell history and in the process list where anything running as that user can read it.

Variable What it does
SPACEBAR_PROFILE Which profile to use when --profile is not given. The flag wins over this, and this wins over default_profile in the configuration file.
SPACEBAR_CLIENT_ID OAuth client ID for auth login. Overrides the profile's own and whatever this build was compiled with.
SPACEBAR_CLIENT_SECRET OAuth client secret for the same. Read from here so that it reaches neither config.json nor the process list.
SPACEBAR_WEBHOOK_URL The incoming webhook URL for profile set-webhook, which takes it here or on stdin. There is no flag for it, for the same reason.
NO_COLOR Any non-empty value turns ANSI colour off, the same as --no-color.
TERM A value of dumb turns colour off. Nothing else about it is read.
XDG_CONFIG_HOME Where config.json and the credential fallback live. Without it, ~/.config/spacebar.
XDG_CACHE_HOME Where the cached space list lives. Without it, ~/.cache/spacebar. Safe to delete: it is rebuilt from the API.
XDG_DATA_HOME Where the local message index lives. Without it, ~/.local/share/spacebar. Not safe to delete: it holds messages the API will not answer for a second time.

The three XDG variables are honoured on every platform, Windows included, because somebody who sets one has said where they want their files. Each has to be an absolute path, and a relative one is refused rather than ignored, so a file never quietly lands somewhere its owner will not think to look. Without them, Windows uses %AppData% for the first and %LocalAppData% for the other two.

SPACEBAR_PROFILE is the one worth knowing about. Profile selection is the same everywhere, so a job that sends through one profile sets it once:

export SPACEBAR_PROFILE=alerts
spacebar send 'deploy done'

Development

make hooks     # install the pre-commit and commit-msg hooks
make tools     # install every pinned tool the gate needs
make ci        # everything CI runs, in the order CI runs it

make ci is the gate, and a green run locally means a green run there. CONTRIBUTING.md has the rest: what fails CI and why, the commit message rules, the dependency policy, and the DCO.

Licence

Apache-2.0. See LICENSE and NOTICE.

spacebar licenses prints the licence of every dependency from inside the binary, with no checkout and no network, which is how the notices travel with a distribution that is one executable and nothing else.

About

Google Chat from your terminal, your scripts, and your agents.

Resources

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages