A Go command-line client for Buffer's GraphQL API
(api.buffer.com). List channels, create ideas and drafts, queue posts and
threads, attach images, and read or delete existing posts, all from the
terminal. The GraphQL client lives in its own package, bufferclient, which
carries no project-specific defaults, so it can be imported by other Go
tools without pulling this CLI along.
bfr is built to be driven by an agent, not just a human at a terminal.
That means the single fact that matters most is which commands are safe to
run unattended and which ones put text or an image in front of a real
audience. Full behavior for every verb is in the Commands
table below. This is just the split that must never get lost in a skim.
Never publishes (safe to script/automate):
channels, idea, draft, draft-image, show, list, version
Publishes live to a real audience:
post, thread, image
Mutates an existing post/draft in place (not a new publish, but not a no-op either):
schedule (retimes), attach-image (adds an image), update (replaces text)
Irreversible:
delete
Both connected channels run an unpaused queue, so there is no "safe" queue
state for post/thread/image: calling them will post for real.
Full documentation: https://esturban.github.io/bfr/
git clone https://github.com/Esturban/bfr.git
cd bfr
go build -o bfr .Or, if you already have the module available to your Go toolchain:
go install github.com/Esturban/bfr@latestRequires Go 1.21+.
bfr reads BUFFER_API_KEY from the environment, or from a .env file at
the repo root (gitignored, never committed). Generate a token from your
Buffer account's API access / developer app settings. Never pass it as a
flag, it is never echoed or logged.
# .env
BUFFER_API_KEY=your-token-hereThe image verbs (image, draft-image) additionally require:
BUFFER_DRIVE_ACCOUNT: agog-authenticated Google Drive account email, used to upload the image and share it before attaching it to the post. No default: it must be set explicitly.- The
gogandsipsCLIs on PATH (sipsis macOS-native, used to convert the source image to real JPEG bytes before upload).
BUFFER_CACHE_FILE optionally overrides where the channel cache is written
(default: .bfr-channels.json next to the repo root).
| Command | Effect |
|---|---|
bfr channels |
List channel ids/names, cache to .bfr-channels.json |
bfr idea <file.md> |
Post to the ideas board: no channel attached, never posts |
bfr draft <channel> <file.md> |
Draft on the channel (saveToDraft), never posts |
bfr schedule <post-id> <ISO8601-datetime> |
Move an EXISTING draft to a scheduled time, or retime an already-scheduled post. Never creates a new post, confirms the change by re-reading it back |
bfr post <channel> <file.md> |
Publishes live: queues to the channel, will post |
bfr thread <channel> <file.md> |
Publishes live: ----delimited blocks become a thread |
bfr image <channel> <file.md> <path> |
Publishes live: Drive upload, attach, queue |
bfr draft-image <channel> <file.md> <path> |
Draft on the channel with an image, never posts |
bfr attach-image <post-id> <url> |
Attach an image to an EXISTING draft/post. Never creates a new one, never changes text/status/schedule |
bfr stats <post-id> |
Read-only metrics for one sent post: impressions, reach, reactions, comments, engagement rate. A metric Buffer does not return prints n/a (clicks is not returned for LinkedIn) |
bfr stats --sent [n] |
Same columns for the n most recently sent posts (default 10, no max; pages through Buffer), newest first. Read-only |
bfr update <post-id> <file.md> |
Replace an EXISTING draft's text. Refuses anything not a draft, never touches status/schedule/channel/assets |
bfr show <post-id> [--full] |
Read a post/draft back: status, channel, text, assets. Text truncates to 100 chars by default; --full prints it untruncated (needed to verify anything appended past that point, e.g. hashtags) |
bfr list [channel] |
List drafts AND scheduled posts: id, status, channel, due time in UTC/Riyadh/New York, text, image flag (read-only) |
bfr delete <post-id> |
Permanently deletes a post/draft, irreversible |
bfr version |
Print version, commit and build date |
<channel> is a channel id, or a channel name from bfr channels
(case-insensitive, resolved against the cache: run bfr channels first).
Both connected channels have an unpaused queue, so post/thread/image
publish for real; there is no "safe" queue state for them. idea and
draft/draft-image are the only verbs that never reach an audience.
attach-image takes an already-public URL (not a local file path) and
edits an existing post/draft in place: it does no Drive upload of its
own and does not require BUFFER_DRIVE_ACCOUNT/gog/sips. Use
image/draft-image when the image is still a local file.
Every post created through the normal queue path carries
schedulingType: "automatic". Buffer derives its dueAt from the
channel's own posting schedule rather than storing an exact pinned time.
Sending a bare dueAt to editPost on such a post returns
PostActionSuccess and silently changes nothing: the mutation succeeds,
the response looks correct, and the time never moves. That happened for
real on 2026-08-30: ten posts sat scheduled at 20:00 UTC (23:00 Riyadh),
and repeated attempts to move them all reported success while doing
nothing.
The fix is mode: "customScheduled" alongside dueAt: that is the field
that actually pins an exact time; dueAt alone is not. bfr schedule
sends it on every call, and because a success response is exactly what
this defect looks like, it never trusts the mutation's own response: it
always re-reads the post afterward and compares the ACTUAL resulting
dueAt to what was requested (and confirms status is still scheduled),
failing loudly if they don't match rather than reporting success.
bfr schedule also uses different field combinations depending on the
post's starting status, both found by live trial against the real API:
moving a draft to scheduled additionally requires an explicit
schedulingType: "automatic" and saveToDraft: false, or the post
silently stays a draft despite a success response; retiming an
already-scheduled post needs mode: customScheduled alone. Adding
schedulingType or saveToDraft there is not part of the verified working
mutation.
bfr channels # cache channel ids/names once
bfr draft general-x ./post.md # draft on a channel, never posts
bfr show <post-id> # confirm it landed as a draft
bfr list general-x # see all drafts on that channelA thread file uses --- on its own line to split blocks; the first
non-empty block is the post text, the rest become thread entries:
First tweet in the thread.
---
Second tweet, posted as a reply.bfr thread general-x ./thread.md # publishes liveimport (
"os"
"github.com/Esturban/bfr/bufferclient"
)
func main() {
c := bufferclient.New(os.Getenv("BUFFER_API_KEY"), "")
org, _, err := c.OrganizationID()
if err != nil {
panic(err)
}
result, _, err := c.CreatePost(bufferclient.PostInput{
Text: "hello from bufferclient",
ChannelID: "channel-id-from-bfr-channels",
SchedulingType: "automatic",
Mode: "addToQueue",
SaveToDraft: true, // omit to queue for real
})
if err != nil {
panic(err)
}
_ = org
_ = result
}bufferclient never calls a share-immediately mutation: every write is
mode: addToQueue (queues, and publishes for real once the channel's queue
is unpaused), saveToDraft: true (never leaves draft state), or
createIdea (no channel attached at all).
MIT. See LICENSE.