Command-line tool (CLI)
Schedule, publish and manage posts from your terminal, your scripts and your CI.
The bulkit command does everything the API does, from a terminal. Use it to
send a quick post, to script your publishing, or to post from a CI job when you
ship a release.
bulkit posts create -c @acme -c linkedin -t "Shipping today." --at +2h- Asks before anything public or destructive, unless you pass
--yes --jsonoutput and stable exit codes for scripts- Tab completion for bash, zsh and fish
Install
npm install -g bulkit-cliNeeds Node.js 22.12 or later. Check it worked with bulkit --version.
To try it without installing, run npx bulkit-cli --help.
Log in
Create an API key, then:
bulkit auth loginPaste the key when asked. What you type is hidden. Check which key is in use
at any time with bulkit auth status.
Start with a test key (bk_test_…). It checks every request like a live
key, but never posts and is never billed. With a test key the CLI also
doesn't ask before publishing, since nothing can go out.
The key is saved in ~/.config/bulkit/config.json (%APPDATA%\bulkit on
Windows), and only your user can read it.
Several workspaces
Save one key per workspace under a profile, and pick it with --profile:
bulkit auth login --profile acme
bulkit posts list --profile acmeYou can also set BULKIT_PROFILE=acme instead of passing --profile every
time.
Your first post
# 1. What can I post to, and what does each platform accept?
bulkit channels list
bulkit platforms get x
# 2. Save a draft to two channels.
bulkit posts create -c x -c linkedin -t "Hello from the Bulkit CLI." --draft
# 3. Look at it.
bulkit posts listposts create makes one post for every --channel you give, with one id.
Don't run the command once per channel: that makes separate posts, each with
its own schedule.
Pick channels by name
--channel (-c) takes any of these. They're tried in this order:
| You give | Example | Matches |
|---|---|---|
| The channel id | -c cmtu8u6n4000… | That channel. |
| The username | -c @acme | The channel with that username. |
| The display name | -c "Acme Inc" | The channel with that name. |
| The platform | -c linkedin | Your one LinkedIn channel. |
If a value matches more than one channel (for example -c instagram when you
have two Instagram accounts), the CLI stops and lists them instead of
guessing. Pass the id then.
Draft, schedule or publish now
| Flag | What happens |
|---|---|
--draft | Saved as a draft. This is the default. |
--at <time> | Scheduled. Give a time like 2026-11-01T09:00 (your time zone), 2026-11-01T09:00Z, or an offset from now: +30m, +2h, +1d, +1w. |
--now | Published right away, to real accounts. |
bulkit posts create -c x -t "Launch day." --at +2h
bulkit posts create -c x -t "We're live!" --nowWith a test key, --at and --now are checked and the post is saved as a
draft. The CLI says so: "Test mode: nothing was published or scheduled."
Threads, media and titles
# A thread: one --text per part.
bulkit posts create -c x -t "1/ Big news" -t "2/ Here is why" --draft
# Long text from a file (or - for stdin).
bulkit posts create -c linkedin --text-file announcement.md --draft
# A YouTube video with a title.
id=$(bulkit media upload launch.mp4)
bulkit posts create -c youtube -m "$id" --title "Launch" -t "Our new release." --draftDifferent text per channel
Every channel gets the same text with --text. To write a different text for
each channel, or to set platform options, write the
request body as JSON and pass it with --file. The body is the same as
POST /posts:
{
"channels": [
{ "channelId": "chn_x", "blocks": [{ "content": "Shipping today." }] },
{ "channelId": "chn_li", "blocks": [{ "content": "A longer version, for LinkedIn." }] }
]
}bulkit posts create --file post.json --at +1d--file - reads the body from stdin. Add --dry-run to print the body
without sending it.
Upload media
bulkit media upload photo.jpg clip.mp4
bulkit media upload https://cdn.example.com/launch.pngThe command prints one media id per file, ready for --media (-m). Large
videos are uploaded in parts automatically.
A URL is downloaded by the CLI, then uploaded like a local file. So it
only has to work from your computer, not from Bulkit's servers. Put quotes
around URLs that contain & or ?, or your shell will cut them short:
bulkit media upload 'https://cdn.example.com/photo.jpg?width=1200&quality=85'See Media for the sizes and types each platform accepts.
Publishing is asynchronous
--now and bulkit posts publish return as soon as the post is queued.
That doesn't mean it's live yet. Add --wait to wait until every channel has
finished:
bulkit posts create -c x -c linkedin -t "We're live!" --now --waitWith --wait, the command exits with code 1 if any channel failed, and
prints why. You can also check later:
bulkit posts get <post-id> # where each channel stands
bulkit posts get <post-id> --wait # wait for it to finish
bulkit posts publish <post-id> # retry; channels that already published are skippedSee Check the result for what each status means.
Safety
Everything the CLI does with a live key happens on real accounts. So it asks before:
- publishing (
--now,posts publish), with a live key - replying to a comment, with a live key (it shows you the exact reply first)
- replacing, revising or deleting a post, deleting media, and deleting a webhook or changing its secret, with any key
bulkit posts retract deletes the published posts from the platforms,
with their likes and replies. It can't be undone, so with a live key the CLI
asks you to type the post id.
Pass --yes (-y) to skip the question. When there's no terminal to ask in
(a script, a CI job, --no-input), the CLI never waits for an answer: it
stops with exit code 2 and tells you to add --yes.
posts retract and posts delete are different. Retract takes the post
down from the platforms. Delete only removes it from Bulkit and leaves
it live. See Manage posts.
Two more ways to stay safe:
posts create --dry-runprints the request without sending it.posts create --idempotency-key <key>makes a retry safe: run the same command with the same key again and you won't get a second post.
Use it in scripts and CI
Output
--jsonprints the API's own JSON on stdout. Use it in scripts: the normal output may change between versions, the JSON won't.- When the output goes to a pipe or a file, tables print as tab-separated
rows without a header, so
cutandawkwork. posts createandmedia uploadprint only the id on stdout. All messages go to stderr. So this works:
id=$(bulkit posts create -c x -t "Hello" --draft)
bulkit posts list --status failed --json | jq -r '.[].id'With --json, errors are JSON too, on stderr:
{ "error": { "message": "Post pst_123 not found", "code": "not_found", "status": 404, "requestId": "req_…", "exitCode": 4 } }Exit codes
| Code | Meaning |
|---|---|
0 | Success. |
1 | Something failed: an API error, a channel that failed to publish (with --wait), a cancelled question. |
2 | The command was wrong: unknown command or flag, bad value, or --yes missing where there's no terminal. |
3 | Not logged in, or the key was refused or lacks the permission. |
4 | Not found. |
5 | Your plan's limit for this period is used up. See bulkit usage. |
130 | Stopped with Ctrl-C. |
In CI
Store the key as a secret and pass it in BULKIT_API_KEY. It's used instead
of any saved key, and nothing is written to disk:
- name: Announce the release
run: npx bulkit-cli posts create -c x -c linkedin -t "v${{ github.ref_name }} is out." --now --yes
env:
BULKIT_API_KEY: ${{ secrets.BULKIT_API_KEY }}To log in from a script instead, pipe the key in. Never put a key in a command-line flag, where other users and your shell history can see it:
bulkit auth login --with-key < key.txtShell completion
Tab completes commands, flags, and values like --status. In zsh and fish
you also see what each one does. Add one line to your shell's startup file,
then open a new terminal:
echo 'eval "$(bulkit completion zsh)"' >> ~/.zshrcPut it after the line that runs compinit, if you have one.
echo 'eval "$(bulkit completion bash)"' >> ~/.bashrcbulkit completion fish > ~/.config/fish/completions/bulkit.fishEnvironment variables
| Variable | What it does |
|---|---|
BULKIT_API_KEY | The API key to use, instead of the saved one. |
BULKIT_PROFILE | The profile to use when --profile isn't given. |
BULKIT_BASE_URL | Send requests to another API address (a proxy, a local server). |
BULKIT_CONFIG_DIR | Where to keep config.json. |
BULKIT_DEBUG=1 | Same as --debug. |
BULKIT_NO_INPUT=1, CI | Same as --no-input: never ask. |
BULKIT_NO_UPDATE_NOTIFIER=1 | Don't check for a newer version. It's checked at most once a day, and never in CI. |
NO_COLOR, FORCE_COLOR | Turn colors off or on. |
Troubleshooting
Something failed and you don't know why. Run the command again with
--debug. It prints every request, its status and its request id. It never
prints your key.
You need help from us. Every API error shows a request id
(req_…). Send it with your question: it lets us find exactly what happened.
A URL upload stopped at &. Put the URL in single quotes. See
Upload media.