atcli #
Bluesky and the atproto network, from the command line, for people and for agents.
atcli login alice.bsky.social # OAuth in your browser
atcli timeline -n 10
atcli post 'Hello from the terminal! #atproto'
atcli notifications --unread
atcli search posts 'bundle' --sort latest --json
Everything the Bluesky app does, and the raw atproto layer beneath it:
- Read: your timeline, anyone's posts, threads (or a whole self-thread unrolled into one piece), custom feeds, list feeds, notifications, trending topics, likes, reposts and quotes of a post.
- Write: posts and threads with mentions, links, hashtags, link cards, images, video and captions, quotes, replies, content warnings, languages, reply gates and quote controls. Also likes, reposts, bookmarks, and deletes.
- People: profiles (and editing yours), follows, blocks, mutes (accounts, threads and words), relationships, suggestions, curation and moderation lists, starter packs.
- Messages: direct messages and group chats. Read, send, react, accept, mute and leave.
- Raw access: resolve handles and DIDs, read and write any record in any collection, and call any XRPC method on any service.
- Several accounts signed in at once, with a default and
--accountto pick another. - Output as text, JSON, Markdown or HTML, on every command, with a default you choose.
- Agent-ready:
--jsonon every command (errors included),--helpon every command (as JSON too), stable exit codes, and a skill that teaches agents to use it (atcli skill install).
What writes #
Most commands only read. The ones that write:
- On the network — they publish something other people can see, or
change the account:
post,reply,quote,delete,like/unlike,repost/unrepost,bookmark add/remove,gate,profile update/pin/unpin,follow/unfollow,block/unblock,mute/unmute,list create/edit/delete/add/removeand the moderation-list actions,feed save/unsave/pin/unpin,notifications seen,chat sendand every chat-management command,record create/put/delete,upload,report. - On this machine only — sessions and settings:
login,logout,account remove/default/refresh,config set/unset/forget,skill install,completion --install. - Either, by method:
xrpc— queries (GET) read, procedures (POST) write.
The generated command reference marks every writing command and lists them
all up top, and the agent skill (atcli skill show) carries the same list
with what each one does. post --dry-run prints the exact record without
writing anything.
Install #
atcli ships as one signed file, built with @pipobscure/bundle, and needs Node.js 26.11.1 or later.
bundle install <url of atcli.nzip> # verified, put on your PATH, kept current by `bundle update`
Or run it from a checkout. There is no build step: Node runs the TypeScript directly.
git clone … && cd atcli && npm install
node src/main.ts --help
npm link # puts `atcli` on your PATH
Signing in #
atcli login alice.bsky.social # OAuth (the default): opens your browser
atcli login alice.bsky.social --password # an app password, asked for at the terminal
printf %s "$APP_PW" | atcli login alice.bsky.social --password-stdin
Sessions are kept in the system credential store, never in a plain file:
| Platform | Store |
|---|---|
| Linux, BSD | Secret Service (GNOME Keyring, KWallet, KeePassXC…) through secret-tool; KWallet directly through kwallet-query |
| macOS | the login Keychain, through security |
| Windows | Credential Manager |
On a machine without a keyring (a container, CI),
atcli config set secret-store file keeps them in a 0600 file in the config
directory instead.
OAuth when the browser is elsewhere. If you are on SSH, open the printed
URL wherever you have a browser, then paste the address it ends up at
(http://127.0.0.1/callback?…, which will not load) into the terminal. Or do
it in two steps, which is how agents do it:
atcli login alice.bsky.social --start # prints the URL to open
atcli login --finish 'http://127.0.0.1/callback?state=…&iss=…&code=…'
Which OAuth client. Before signing in, atcli picks the first OAuth
client whose metadata document would pass the authorization server's checks:
status 200 without a redirect, served as application/json, naming itself,
atcli's redirect and its scope. It tries, in order:
- the
oauth-clientsetting, if you have put a document's URL there; - this release's own document, attached to the release on tangled. Its URL
is known from the version alone, from the first entry of
atcli.oauthClientsin package.json; - the fallback document at
https://ordner.fyi/bundles/atcli.json, the second entry; - atproto's loopback development client, which needs nothing hosted but gets shorter sessions.
The candidates are checked at once, and the first that passes, in order, is
used. atcli only says which candidates it passed over when it ends up at the
loopback client, or when the one in your setting doesn't pass.
atcli config set oauth-client loopback skips straight to the loopback client.
To host your own document, atcli config client-metadata <url> prints it.
A session keeps the client it signed in with, so upgrading atcli doesn't sign
you out. atcli whoami shows which client a session uses.
Several accounts #
Sign in to as many as you like. The first becomes the default.
atcli account list # * marks the default
atcli account default bob.example.com # commands now act as bob
atcli post 'hi' --account alice.bsky.social
atcli logout bob.example.com
Using it #
atcli --help lists every command, and --help after any command explains
it, wherever it appears on the line: atcli post --image --help works.
Output formats. Every command prints for the terminal by default. It can
also print --json (the data, for programs), --markdown, or --html (a
whole page), or take --format text|json|markdown|html. Set your own default
with atcli config set format markdown; a flag always wins. Markdown and HTML are made from the same document, with post
text escaped so it stays text, and mentions, links, tags, images, quotes and
link cards rendered properly. Shell scripts (atcli completion bash) print as
they are, whatever the format.
atcli unroll <post> --html > thread.html && open thread.html
atcli timeline -n 20 --markdown | glow -
atcli thread <post> --markdown | pandoc -o thread.pdf
atcli config set format markdown # from now on, unless told otherwise
atcli config set lang en # the language posts are tagged with
Naming things. Accounts are handles, @handles, DIDs or profile links.
Posts are at:// URIs, bsky.app links, or handle/rkey. Feeds and lists
can be named the way you think of them: atcli feed show mutuals,
atcli feed show "JS/TS Wien", atcli list show "js ts wien". A name (or the
last part of a feed's address) is looked for among your saved feeds, then the
ones you made, then by searching all of Bluesky's feeds. Case, accents and
punctuation don't matter, and part of a name is enough. following is your
timeline. The output says which feed it read. If two of your feeds fit
equally well, atcli lists them instead of guessing. at:// URIs, bsky.app
links and handle/rkey work too. A chat conversation is its id, or
the handle of the person it is with.
Lists and paging. Listing commands take -n <total> (default 25), --all,
and --cursor to continue where a previous listing stopped. Listings of dated
things (timeline, posts, feed show, list feed, notifications,
chat list/show, bookmark list, likes, quotes) also take
--since <time>: an ISO 8601 time (UTC unless it says otherwise) or how long
ago (30m, 2h, 1d, 1w). atcli timeline --since 2h --all is everything
from the last two hours, and paging stops as soon as it reaches older posts.
Only what's new. --new on any of those listings shows only what the
last --new listing of the same thing didn't: atcli feed show mutuals --new
shows the newest the first time, and after that only what has come since. With
-n, it takes the oldest n of the new ones, so reading in batches skips
nothing. It remembers per account and per listing; atcli timeline and
atcli feed show following count as the same listing.
atcli config forget --new forgets.
Watching. atcli chat watch and atcli notifications watch keep running
and print messages and notifications as they arrive, as text or as one JSON
object per line:
atcli chat watch # everything, as it happens
atcli chat watch --incoming --messages --count 1 --json # wait for the next DM, then stop
atcli notifications watch --reason mention --reason reply
atcli chat watch --resume --once # what came since last time, then stop
Bluesky has no push channel for direct messages that a client like this can
use: they aren't on the firehose, and push notifications only go to the
official apps. So the watches poll with a cursor, the way the Bluesky app
does: messages every 10s, notifications every 30s, slowing while nothing
happens (--interval changes it). --count, --timeout and --once stop
them. --resume carries on from where the last one stopped, per account,
neither repeating nor missing anything.
Posting. Text over 300 characters is refused, unless --split turns it
into a thread. --thread posts each argument as its own post. --dry-run
prints the records without writing anything.
atcli post 'Look at this' --image cat.jpg --alt 'A cat asleep on a keyboard'
atcli post --video clip.mp4 --video-alt 'Waves at sunset' --caption en:clip.vtt
atcli reply https://bsky.app/profile/alice.bsky.social/post/3k… 'Agreed!'
atcli quote at://did:plc:…/app.bsky.feed.post/3k… 'This.'
atcli post 'Only for people I follow' --reply-gate following --no-quotes
cat essay.txt | atcli post --split --lang en -
Whole threads. Given any post of a thread someone wrote as replies to
themselves, unroll finds its start and end and prints it as one piece,
without other people's replies or the "1/7" numbering. atcli thread points
it out when a post is part of one.
atcli unroll https://bsky.app/profile/alice.bsky.social/post/3k…
atcli thread <post> --unroll --links # the same, with each post's link
atcli unroll <post> --markdown > thread.md # links, mentions and images as Markdown
atcli unroll <post> --json | jq -r .text
Raw access, for anything not covered:
atcli resolve alice.bsky.social
atcli record list app.bsky.graph.follow --repo alice.bsky.social --all --json
atcli xrpc app.bsky.graph.getKnownFollowers -p actor=alice.bsky.social
atcli xrpc app.bsky.actor.putPreferences --data @prefs.json
For agents #
atcli skill install # into .claude/skills/atcli in this project
atcli skill install --global # into ~/.claude/skills for every project
atcli skill show > AGENTS.md # the whole skill as one document, for any other agent
The skill explains the conventions, signing in without handling passwords, when to ask the user before acting, and recipes for common tasks. It includes a full command reference generated from the CLI itself. Without the skill:
--jsonprints the result as JSON on stdout. Failures print{"error": {"code", "message", "exit"}}there too.atcli <command> --help --jsondescribes a command's arguments and options;atcli help --jsondescribes all of them.- Exit codes:
0ok,1error,2usage,3not signed in,4not found,5rate limited,6needs input it had no terminal to ask for.
Tab completion #
eval "$(atcli completion bash)" # ~/.bashrc
eval "$(atcli completion zsh)" # ~/.zshrc
atcli completion fish | source # ~/.config/fish/config.fish
atcli completion powershell | Out-String | Invoke-Expression # $PROFILE
atcli completion --install # or let atcli add it
It completes commands, options, their choices, files, and signed-in accounts.
Settings #
Everything atcli can be told to do differently is a setting in its config file, beside the account list (never the credentials), not an environment variable:
atcli config list # every setting, its value, and what it does
atcli config set format markdown
atcli config unset format
| Setting | |
|---|---|
format |
text (default), json, markdown or html |
lang |
the language(s) posts are tagged with, e.g. en or en,de |
secret-store |
auto (default), secret-service, kwallet, keychain, wincred or file |
kwallet-wallet |
the KWallet wallet to use (default kdewallet) |
oauth-client |
auto (default: this release's client, else loopback), loopback, or a client metadata URL |
browser |
auto (default) or never: whether login opens a browser |
rate-limit-wait |
the longest to wait out a rate limit, in seconds (default 60) |
appview, public-appview, chat, video-service, plc-directory |
the services atcli talks to |
The config file is ~/.config/atcli/config.json
(~/Library/Application Support/atcli on macOS, %APPDATA%\atcli on
Windows); atcli config path prints it. The one thing that cannot be a
setting is where that file is: ATCLI_CONFIG_DIR moves it, for tests and
for keeping separate setups apart. Beyond that, atcli honours the usual
conventions of the platform: NO_COLOR, XDG_CONFIG_HOME.
Releasing #
CI (.tangled/workflows/ci.yml) typechecks and tests every push and pull
request to main. Releases are made the way tangled releases itself:
npm version patch # bumps package.json and makes an annotated tag
git push --follow-tags # the tag starts .tangled/workflows/release.yml
tools/release.sh then checks the tag matches package.json, tests, builds
the archive reproducibly (dated by the commit, in UTC), attaches it to the
release as atcli.nzip, attests it as published, and points the
@<you>/atcli bundle listing at it. A version that already has atcli.nzip
is left as it is. Run by hand (tangled run trigger --workflow release.yml),
it is a dry run, and so is RELEASE_DRY_RUN=1 ./tools/release.sh locally.
It needs two secrets (tangled secret set <NAME>): TANGLED_HANDLE and
TANGLED_APP_PASSWORD, an app password for that account. It uploads with the
tangled CLI, installed from its listing only if its author has attested it,
and with bundle's state kept inside the run, so a local dry run leaves your
own installs alone.
Each release also carries its OAuth client document, client-metadata.json.
atcli only signs in with it once it is served as application/json, which
tangled.org does not do yet
(tangled.org/core#2264);
until then atcli signs in through https://ordner.fyi/bundles/atcli.json, and
switches by itself, with no new release, once tangled.org serves it right.
To build and try the archive by hand:
npm run release # check, manifest, pack, client metadata, audit
./build/atcli.unsigned.nzip --version