CLI
Scripting & CI
Every command speaks JSON, reads its key from the environment, and never blocks on a prompt when it isn't attached to a terminal.
JSON output
--json prints the raw API response instead of a table. That is the contract to script against — table columns are for humans and may change.
# Every scheduled post, id and time
echoia posts --status scheduled --json | jq -r '.posts[] | "\(.id) \(.scheduledAt)"'
# Account ids for one platform
echoia accounts --json | jq -r '.accounts[] | select(.platform=="instagram") | .id'
# Anything that needs reconnecting
echoia accounts --json | jq -r '.accounts[] | select(.needsReconnect) | .username'Naming accounts in a script
Do not hard-code --platforms in automation. The moment a second account is connected on that platform the call starts failing — deliberately, because the destination would otherwise be ambiguous. Write the handle instead; nothing has to be looked up first:
echoia post "Shipping today." --accounts instagram:@acme --nowA handle stops resolving if that account is renamed or disconnected — the call fails rather than going somewhere else. When a script must survive a rename, pin the id instead:
IG=$(echoia accounts --json \
| jq -r '.accounts[] | select(.platform=="instagram" and .username=="acme") | .id')
echoia post "Shipping today." --accounts "$IG" --nowTo fan out to every account on a platform, collect them all — one call, one result per account:
ALL=$(echoia accounts --json \
| jq -r '[.accounts[] | select(.platform=="instagram") | .id] | join(",")')
echoia post "Same announcement, every brand." --accounts "$ALL" --at 1hEnvironment
| Variable | Effect |
|---|---|
ECHOIA_API_KEY | The API key. Takes precedence over the stored one, so CI never needs a login step. |
ECHOIA_BASE_URL | Point at another API host. |
ECHOIA_CONFIG_DIR | Where config.json lives. |
NO_COLOR | Disable colour. Already off automatically when stdout is not a terminal. |
Exit codes and errors
0on success,1on anything else. Errors go to stderr, so--jsonon stdout stays parseable.set -eis enough to stop a script on the first failure.- A
403names the scope the token is missing; a429says whether it is a rate limit (retry) or an exhausted monthly quota (retrying will not help).
--now does not ask
--now publishes on the spot: the flag is the confirmation, and there is no prompt to catch a typo. --yes is still accepted so older scripts keep running, but it has nothing left to skip.reply is the exception and still confirms, because a reply goes out to one particular person under your name. Pass --yes there when you mean it.GitHub Actions
Store the key as a repository secret. Give the token only the scopes the job needs — a job that schedules posts does not need publish.
name: Announce release
on:
release:
types: [published]
jobs:
post:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Schedule the announcement
env:
ECHOIA_API_KEY: ${{ secrets.ECHOIA_API_KEY }}
run: |
set -e
ACCOUNTS=$(npx echoia-cli accounts --json \
| jq -r '[.accounts[] | select(.needsReconnect | not) | .id] | join(",")')
npx echoia-cli post "${{ github.event.release.name }} is out." \
--accounts "$ACCOUNTS" \
--at 1hReviewing before it goes out
The safest automation writes a draft and lets a human release it. Omit --at and --now — nothing is published, and the post is waiting in the Posts tab.
echoia post "$(cat announcement.txt)" --accounts "$ACCOUNTS" --json \
| jq -r '"draft \(.post.id) → " + ([.accounts[].account] | join(", "))'Pacing a batch
The API rate-limits per token, and X writes draw on a separate monthly pool. When looping over many posts, publish sequentially and let a failure stop the batch rather than retrying blindly:
set -e
while IFS= read -r line; do
echoia post "$line" --accounts "$ACCOUNTS" --at 30m
sleep 2
done < posts.txt