Google Chat from your terminal, your scripts, and your agents.
$ spacebar send "deploy done"
message spaces/AAAAAAA/messages/BBB
space spaces/AAAAAAA
profile alertsThat 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.
| 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. |
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.
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.
spacebaris an independent third-party client. It is not affiliated with, sponsored by, or endorsed by Google, and Google does not support it.
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 messagessend 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.
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.
Nothing is released yet. From source:
git clone https://github.com/kmoneil/spacebar
cd spacebar
make build # -> bin/spacebarA build from source has no OAuth client baked in, which is deliberate (see
SECURITY.md), and spacebar auth setup will walk through
creating one.
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 fileprofile 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 --verifyspacebar 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 itOn 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.
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 nothingmessage 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.
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 locallyThe 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.
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.
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/nullPaste 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.
- 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.
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/BBBBBBBA 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.
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 themHow 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 engThe 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.
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 .textOldest 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.
spacebar send spaces/AAAAAAA 'the report' --file report.pdf
spacebar messages download spaces/AAAAAAA/messages/BBBBBBB --out ~/DownloadsSending 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.
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 --allColumns 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.
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 --jsonsync 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.
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.
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-Expressioncompletion 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.
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'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 itmake 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.
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.
