Skip to main content

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>.json or ~/.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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

xctl-0.1.0.tar.gz (43.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

xctl-0.1.0-py3-none-any.whl (30.0 kB view details)

Uploaded Python 3

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

Hashes for xctl-0.1.0.tar.gz
Algorithm Hash digest
SHA256 bd84148fefbd86ff8431dcb5a67a87e4f740e7385da2c78ac12bbe8575b28c2a
MD5 b773be5e9e85e331c90152d84931edd7
BLAKE2b-256 8ebf4cceba4987ac08f0c56d054ab0cec8b390700b753a4e8e72747d871bb64c

See more details on using hashes here.

Provenance

The following attestation bundles were made for xctl-0.1.0.tar.gz:

Publisher: publish.yml on andresleecom/xctl

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for xctl-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c0f4605b640034163ed6c3289ab8464a1826c674eede780c2d0013ac396d3999
MD5 a76d04c7644e0321478740a4e1ecfca7
BLAKE2b-256 fd919f86535407303bb4e1f2cd86135483f94eaf94c8759f1fbac54ca0a522b6

See more details on using hashes here.

Provenance

The following attestation bundles were made for xctl-0.1.0-py3-none-any.whl:

Publisher: publish.yml on andresleecom/xctl

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page