Agent-first CLI for X/Twitter: post, thread, stats, search, delete - stdlib only
Project description
xctl
Agent-first CLI for X/Twitter. Post tweets and threads, pull stats, search, and delete - with JSON-only I/O, deterministic exit codes, and zero runtime dependencies (Python stdlib only).
Install
Requires Python 3.10 or newer. Runtime dependencies: none (stdlib only).
With uv:
uv tool install xctl
With pip:
pip install xctl
Credentials
Create an app in the X Developer Portal with Read and Write permissions. You need four OAuth 1.0a user-context values: API key, API secret, access token, and access token secret.
Set them as environment variables:
export XCTL_API_KEY="..."
export XCTL_API_SECRET="..."
export XCTL_ACCESS_TOKEN="..."
export XCTL_ACCESS_SECRET="..."
Or write them to ~/.config/xctl/credentials.json:
{
"api_key": "...",
"api_secret": "...",
"access_token": "...",
"access_secret": "..."
}
Environment variables win field-by-field over the file. Missing fields raise an auth error that names the unset variables and the file path - never secret values.
Verify credentials are present:
xctl auth check
{"ok":true,"data":{"credentials":"present","source":"unknown","live":false}}
Verify them live against the API (GET /2/users/me):
xctl auth check --live
{"ok":true,"data":{"handle":"yourhandle","id":"1234567890","live":true}}
Quickstart
Global flags belong before the subcommand: --pretty, --version, --max-retries N (default 1).
Success envelopes go to stdout; errors go to stderr.
Default JSON is compact; use --pretty for indented output.
Version
xctl --version
{"ok":true,"data":{"name":"xctl","version":"0.1.0"}}
Pretty:
xctl --pretty --version
{
"ok": true,
"data": {
"name": "xctl",
"version": "0.1.0"
}
}
Post
Text as a positional argument:
xctl post "Hello from xctl"
{"ok":true,"data":{"id":"1234567890123456789","text":"Hello from xctl"}}
Text from stdin (omit TEXT, or use --file -):
echo "Hello from stdin" | xctl post
{"ok":true,"data":{"id":"1234567890123456789","text":"Hello from stdin"}}
With images (comma-separated paths, max 4, images only, each ≤5MB):
xctl post "Photo post" --media ./a.png,./b.jpg
{"ok":true,"data":{"id":"1234567890123456789","text":"Photo post","media_ids":["9876543210","9876543211"]}}
Reply to a tweet:
xctl post "Agree" --reply-to 1234567890123456789
{"ok":true,"data":{"id":"1234567890123456790","text":"Agree"}}
Optional: --file PATH reads text from a file; --quote ID attaches a quote tweet.
Provide text via TEXT or --file/stdin, not both.
Thread dry-run
Validates stanzas locally (280-char naive limit) and prints a plan - no network, no checkpoint:
xctl thread examples/demo-thread.md --dry-run
{"ok":true,"data":{"dry_run":true,"count":3,"posts":[{"index":0,"chars":163,"text":"Shipping a small CLI is mostly packaging discipline: a clear entry point, a single source of truth for the version, and a dry-run path that never hits the network."},{"index":1,"chars":155,"text":"Agents need predictable stdout. JSON envelopes with stable keys beat ad-hoc text when a script has to post a thread, check stats, or clean up a failed run."},{"index":2,"chars":141,"text":"When the dry-run plan looks right, the same file becomes the live thread. Checkpoint after each post so a resume picks up where you left off."}]}}
Thread live
xctl thread thread.md
{"ok":true,"data":{"count":3,"posts":[{"index":0,"id":"111"},{"index":1,"id":"222"},{"index":2,"id":"333"}],"first_id":"111","last_id":"333"}}
Optional: --delay SECONDS sleeps between posts (default 0).
On full success the checkpoint file is deleted.
Thread resume after partial failure
If a mid-thread API call fails, exit code is 5.
stderr carries a partial_failure envelope with the checkpoint id and posted IDs:
xctl thread thread.md
{"ok":false,"error":{"code":"partial_failure","message":"Thread failed at post 2 of 3","retryable":false,"hint":"thread --resume a1b2c3d4e5f6"},"data":{"checkpoint":"a1b2c3d4e5f6","posted":[{"index":0,"id":"111"}],"failed_index":1,"remaining":2,"hint_resume":"thread --resume a1b2c3d4e5f6"}}
Resume without re-posting completed stanzas:
xctl thread --resume a1b2c3d4e5f6
{"ok":true,"data":{"count":3,"posts":[{"index":0,"id":"111"},{"index":1,"id":"222"},{"index":2,"id":"333"}],"first_id":"111","last_id":"333"}}
--resume cannot be combined with a file path or with --dry-run.
Stats
Authenticated account metrics:
xctl stats
{"ok":true,"data":{"user":{"id":"1234567890","handle":"yourhandle","name":"Your Name","created_at":"2010-01-01T00:00:00.000Z","metrics":{"followers_count":100,"following_count":50,"tweet_count":200,"listed_count":1}}}}
Include recent posts (--last is 1-100):
xctl stats --last 5
{"ok":true,"data":{"user":{"id":"1234567890","handle":"yourhandle","name":"Your Name","metrics":{"followers_count":100,"following_count":50,"tweet_count":200,"listed_count":1}},"posts":[{"id":"111","text":"recent post","created_at":"2026-01-01T12:00:00.000Z","metrics":{"retweet_count":0,"reply_count":0,"like_count":1,"quote_count":0}}]}}
Optional: --user USERNAME looks up another account (with or without leading @).
Search
Recent search (default limit 10; API max results clamped 10-100):
xctl search "from:yourhandle" --limit 10
{"ok":true,"data":{"count":1,"tweets":[{"id":"111","text":"Hello","created_at":"2026-01-01T12:00:00.000Z","metrics":{"retweet_count":0,"reply_count":0,"like_count":1,"quote_count":0}}],"next_token":null}}
Paginate with --next-token from a previous response.
Delete
By numeric id:
xctl delete 1234567890123456789
{"ok":true,"data":{"count":1,"results":[{"id":"1234567890123456789","deleted":true}]}}
By status URL (x.com or twitter.com):
xctl delete "https://x.com/user/status/1234567890123456789"
{"ok":true,"data":{"count":1,"results":[{"id":"1234567890123456789","deleted":true}]}}
Dry-run parses and dedupes ids only - no network:
xctl delete 1234567890123456789 "https://x.com/user/status/1234567890123456789" --dry-run
{"ok":true,"data":{"dry_run":true,"count":1,"ids":["1234567890123456789"]}}
Multiple ids are accepted.
If some deletes succeed and others fail, exit code is 5 with per-id results under top-level data.
Thread file format
Split posts on lines that contain exactly --- (whitespace around the dashes is ignored for the match; each stanza is then stripped).
One post per stanza.
Empty stanzas are a usage error.
Example (see examples/demo-thread.md):
Shipping a small CLI is mostly packaging discipline: a clear entry point, a single source of truth for the version, and a dry-run path that never hits the network.
---
Agents need predictable stdout. JSON envelopes with stable keys beat ad-hoc text when a script has to post a thread, check stats, or clean up a failed run.
---
When the dry-run plan looks right, the same file becomes the live thread. Checkpoint after each post so a resume picks up where you left off.
Local validation uses a naive character limit of 280 (len of the stanza text).
X uses weighted counting (URLs, emoji, and CJK weigh differently), so a stanza that passes dry-run may still be rejected by the API.
v1 stanzas are text-only; attach media with xctl post --media, not inside thread files.
For agents
Output contract
Success always looks like this on stdout:
{"ok":true,"data":{}}
Errors always look like this on stderr (optional fields omitted when null):
{"ok":false,"error":{"code":"usage","message":"...","retryable":false,"hint":"..."}}
With rate-limit timing:
{"ok":false,"error":{"code":"rate_limited","message":"...","retryable":true,"hint":"Wait for retry_after seconds before retrying","retry_after":90}}
Partial work (thread or multi-delete) adds top-level data beside error:
{"ok":false,"error":{"code":"partial_failure","message":"...","retryable":false,"hint":"..."},"data":{}}
All tweet and user IDs in success and error payloads are JSON strings, never bare numbers. Do not print secrets; credentials never appear in envelopes, hints, or logs.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | API error, network error, or unexpected internal error |
| 2 | Usage / local validation |
| 3 | Auth missing or unauthorized |
| 4 | Rate limited (HTTP 429) |
| 5 | Partial failure (some work succeeded) |
Error codes
error.code |
Exit | retryable |
Notes |
|---|---|---|---|
usage |
2 | false | Bad flags, empty text, overlong stanza, missing file |
auth |
3 | false | Missing credentials or unauthorized API response |
rate_limited |
4 | true | May include retry_after (seconds); never auto-retried |
api_error |
1 | sometimes | 5xx often retryable: true; 4xx usually false |
network_error |
1 | true | Timeout, DNS, connection failures |
partial_failure |
5 | false | Inspect top-level data; resume threads with checkpoint |
internal_error |
1 | false | Unexpected exception wrapped into a stable envelope |
retryable: true means an agent may retry after waiting.
When present, retry_after is seconds to wait (from x-rate-limit-reset / headers when available).
HTTP 429 is never auto-retried by xctl; agents own the sleep-and-retry loop.
--max-retries
xctl --max-retries 2 post "hello"
Default is 1 (one original attempt plus one extra for retryable 5xx / network failures).
Flat 1s sleep between attempts.
Does not apply to 429.
Checkpoints
On live thread runs, checkpoints live under the per-user state directory:
- Windows:
%LOCALAPPDATA%\xctl\state\checkpoints\<id>.json - Unix:
$XDG_STATE_HOME/xctl/checkpoints/<id>.jsonor~/.local/state/xctl/checkpoints/<id>.json
Ids are 12 hex characters.
After each successful post the checkpoint is rewritten with posted entries {index, id}.
On full success the checkpoint is removed.
On partial failure, resume with xctl thread --resume <id>.
Limitations (v1)
- Images only, simple multipart upload, each file ≤5MB (jpg/jpeg/png/gif/webp).
- No video and no chunked upload.
- Thread length checks use naive
len(not X weighted counting). - Thread stanzas are text-only (no per-stanza media).
- No scheduling or queue.
License
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file xctl-0.1.0.tar.gz.
File metadata
- Download URL: xctl-0.1.0.tar.gz
- Upload date:
- Size: 43.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bd84148fefbd86ff8431dcb5a67a87e4f740e7385da2c78ac12bbe8575b28c2a
|
|
| MD5 |
b773be5e9e85e331c90152d84931edd7
|
|
| BLAKE2b-256 |
8ebf4cceba4987ac08f0c56d054ab0cec8b390700b753a4e8e72747d871bb64c
|
Provenance
The following attestation bundles were made for xctl-0.1.0.tar.gz:
Publisher:
publish.yml on andresleecom/xctl
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
xctl-0.1.0.tar.gz -
Subject digest:
bd84148fefbd86ff8431dcb5a67a87e4f740e7385da2c78ac12bbe8575b28c2a - Sigstore transparency entry: 2255497396
- Sigstore integration time:
-
Permalink:
andresleecom/xctl@ec98c517236b6ae8d38d8f80c2bccd121194024f -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/andresleecom
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@ec98c517236b6ae8d38d8f80c2bccd121194024f -
Trigger Event:
release
-
Statement type:
File details
Details for the file xctl-0.1.0-py3-none-any.whl.
File metadata
- Download URL: xctl-0.1.0-py3-none-any.whl
- Upload date:
- Size: 30.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c0f4605b640034163ed6c3289ab8464a1826c674eede780c2d0013ac396d3999
|
|
| MD5 |
a76d04c7644e0321478740a4e1ecfca7
|
|
| BLAKE2b-256 |
fd919f86535407303bb4e1f2cd86135483f94eaf94c8759f1fbac54ca0a522b6
|
Provenance
The following attestation bundles were made for xctl-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on andresleecom/xctl
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
xctl-0.1.0-py3-none-any.whl -
Subject digest:
c0f4605b640034163ed6c3289ab8464a1826c674eede780c2d0013ac396d3999 - Sigstore transparency entry: 2255497402
- Sigstore integration time:
-
Permalink:
andresleecom/xctl@ec98c517236b6ae8d38d8f80c2bccd121194024f -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/andresleecom
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@ec98c517236b6ae8d38d8f80c2bccd121194024f -
Trigger Event:
release
-
Statement type: