Read and post to X/Twitter via API. Check mentions, post tweets, search. Use app bearer tokens for read-only fetches and OAuth 1.0a user context for account actions.
57
66%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
Fix and improve this skill with Tessl
tessl review fix ./skills/x-api/SKILL.mdBasic X (Twitter) API access for the @joelclaw account until a proper CLI is built (ADR-0119).
Recommended for read-only access: app bearer token
There is no stored x_bot_bearer_token secret right now. Derive the app bearer token from the X app consumer key + secret, then use that bearer token for read-only endpoints like tweet lookup.
python3 <<'PY'
import base64, json, subprocess
from urllib import request, parse
def lease(name: str) -> str:
raw = subprocess.check_output(['secrets', 'lease', name], text=True)
data = json.loads(raw)
return data.get('secret') or data.get('result') or raw.strip()
ck = lease('x_consumer_key')
cs = lease('x_consumer_secret')
cred = base64.b64encode(f'{ck}:{cs}'.encode()).decode()
req = request.Request(
'https://api.twitter.com/oauth2/token',
data=parse.urlencode({'grant_type': 'client_credentials'}).encode(),
headers={
'Authorization': f'Basic {cred}',
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
method='POST',
)
with request.urlopen(req) as r:
bearer = json.loads(r.read().decode())['access_token']
req2 = request.Request(
'https://api.twitter.com/2/tweets/TWEET_ID',
headers={'Authorization': f'Bearer {bearer}'},
)
with request.urlopen(req2) as r:
print(r.read().decode())
PY
secrets revoke --allUse this for:
When ingesting an X post/article as a source, do a reply pass before writing the final note. The replies often contain the correction, missing caveat, source link, or better framing. Do not blindly archive the whole reply swamp; extract the high-signal replies and keep receipts.
Minimum pass:
conversation_id, author_id, created_at, entities, public_metrics, referenced_tweets, note_tweet, and article when available.conversation_id:ROOT_ID is:reply -is:retweet.tweet_id, author handle/name, created time, URL, text basis, metrics, and why it matters.For posts inside the recent-search window:
# After deriving bearer as above:
curl "https://api.x.com/2/tweets/search/recent?query=conversation_id%3AROOT_ID%20is%3Areply%20-is%3Aretweet&max_results=100&tweet.fields=author_id,conversation_id,created_at,entities,id,in_reply_to_user_id,note_tweet,public_metrics,referenced_tweets,text&expansions=author_id,referenced_tweets.id,referenced_tweets.id.author_id&user.fields=username,name,verified,verified_type" \
-H "Authorization: Bearer $bearer"For older posts, use Full-Archive Search when access allows it; otherwise fall back to browser inspection and mark the limitation in the verification note.
The bot app can call Full-Archive Search if the access tier permits it. Do not use Recent Search for YTD/backfill research; it rejects older start_time values.
# After deriving bearer as above:
curl "https://api.x.com/2/tweets/search/all?query=from%3Athreepointone%20-is%3Areply&start_time=2026-01-01T00%3A00%3A00Z&end_time=2026-05-02T00%3A00%3A00Z&max_results=100&tweet.fields=created_at,public_metrics" \
-H "Authorization: Bearer $bearer"Notes:
/2/tweets/search/all./2/tweets/search/recent; last-7-days style window only.tweet.fields=context_annotations forces max_results <= 100; X returns 400 if paired with max_results=500.Alternative: OAuth 1.0a User Context (for posting and account actions)
Tokens do not expire — no refresh dance needed.
x_consumer_key — API Key (OAuth 1.0a consumer key)x_consumer_secret — API Key Secret (OAuth 1.0a consumer secret)x_access_token — Access Token (OAuth 1.0a, format: numeric-alphanumeric)x_access_token_secret — Access Token Secret (OAuth 1.0a)shitrat-joel appUse these scoped secret names for Joel's personal X account so they don't collide with the @joelclaw bot app:
x_joelhooks_consumer_keyx_joelhooks_consumer_secretx_joelhooks_bearer_tokenx_joelhooks_access_tokenx_joelhooks_access_token_secretPIN OAuth note, 2026-06-28: https://api.x.com/oauth/authorize?... showed “There is no request token.” Regenerating with https://api.twitter.com/oauth/request_token and opening https://twitter.com/oauth/authorize?force_login=true&oauth_token=... worked for shitrat-joel → @joelhooks.
Auth verification, 2026-06-28: x_joelhooks_access_token + x_joelhooks_access_token_secret verified with GET https://api.twitter.com/2/users/me as @joelhooks / user id 12087242.
Bookmark caveat: GET /2/users/12087242/bookmarks returned 403 Unsupported Authentication with OAuth 1.0a. X requires OAuth 2.0 User Context / PKCE with bookmark.read, tweet.read, and users.read for bookmarks. Do not assume the saved OAuth 1.0a token can read bookmarks.
OAuth 1.0a requires cryptographic signing. Use requests-oauthlib (Python) or equivalent:
export CK=$(secrets lease x_consumer_key)
export CS=$(secrets lease x_consumer_secret)
export AT=$(secrets lease x_access_token)
export ATS=$(secrets lease x_access_token_secret)
uv run --with requests-oauthlib python3 << 'PYEOF'
import os
from requests_oauthlib import OAuth1Session
client = OAuth1Session(
os.environ['CK'],
client_secret=os.environ['CS'],
resource_owner_key=os.environ['AT'],
resource_owner_secret=os.environ['ATS'],
)
r = client.get("https://api.twitter.com/2/users/me")
print(r.json())
PYEOFsecrets revoke --allshitrat-joelbookmark.readRead-only lookups can use the app bearer-token flow above.
Posting, replying, following, deleting, and account-scoped actions require OAuth 1.0a signing. Use the Python pattern from Authentication section above, then:
# Check mentions
r = client.get("https://api.twitter.com/2/users/2022779096049311744/mentions",
params={"max_results": 10, "tweet.fields": "created_at,author_id,text",
"expansions": "author_id", "user.fields": "username,name"})
# Get my timeline
r = client.get("https://api.twitter.com/2/users/2022779096049311744/tweets",
params={"max_results": 10, "tweet.fields": "created_at,public_metrics"})
# Post a tweet
r = client.post("https://api.twitter.com/2/tweets", json={"text": "your tweet"})
# Reply to a tweet
r = client.post("https://api.twitter.com/2/tweets",
json={"text": "@user reply", "reply": {"in_reply_to_tweet_id": "TWEET_ID"}})
# Search recent tweets
r = client.get("https://api.twitter.com/2/tweets/search/recent",
params={"query": "joelclaw", "max_results": 10, "tweet.fields": "created_at,author_id,text"})
# Get user by username
r = client.get("https://api.twitter.com/2/users/by/username/USERNAME",
params={"user.fields": "description,public_metrics"})
# Follow a user
my_id = "2022779096049311744"
r = client.post(f"https://api.twitter.com/2/users/{my_id}/following",
json={"target_user_id": "TARGET_USER_ID"})
# Delete a tweet
r = client.delete("https://api.twitter.com/2/tweets/TWEET_ID")First try the v2 tweet lookup with tweet.fields=article. Some X Articles return article.title and article.plain_text directly in the tweet payload. Store that API payload as the source receipt when available.
If article.plain_text is missing, the tweet body is only a t.co link, or the article endpoint/browser auth blocks extraction, use agent-browser as the fallback:
agent-browser open "https://x.com/USERNAME/status/TWEET_ID"
agent-browser snapshot
agent-browser closeUse this for any tweet where article.title is present in the API response or the expanded_url points to x.com/i/article/... but the API does not include usable article text.
For capturing X posts/articles as discoveries, use joelclaw discover URL -c "context" — the discovery pipeline will handle enrichment. If the pipeline can't extract content (auth-gated), fall back to X API + reply/context pass + manual Brain/source note.
979b138
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.