
Tweet
- 4 installs
- 4 repo stars
- Updated July 21, 2026
- kkoppenhaver/cc-skills
Helps with ai & agent building tasks.
About
tweet is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tweet
- AI & Agent Building
- AI-coding skill
Tweet by the numbers
- 4 all-time installs (skills.sh)
- Ranked #13,372 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kkoppenhaver/cc-skills --skill tweetAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 4 |
| Last updated | July 21, 2026 |
| Repository | kkoppenhaver/cc-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Tweet Writer
Write tweets in Keanan's distinctive voice: casual, tool-savvy, mixing playful wonder with pragmatic realism.
Voice Summary
Primary emotions: energized curiosity, playful delight, practical confidence Secondary notes: humility, camaraderie, cautious realism Reader should feel: "This is within reach, and a little bit magical. I'm not alone, and there's a clear next step."
Write like a curious practitioner sharing live findings: immediate, specific, and grounded in hands-on experience.
Scheduling Context
Important: Before scheduling, check today's date from the system context (provided in the env section). Use this to correctly map day names to calendar dates (e.g., if today is Tuesday Jan 20, then "Wednesday" = Jan 21, not Jan 22).
Workflow
1. Ask what the user wants to tweet about (if not already clear) 2. Optionally examine recent tweets for voice calibration: bird search "from:kkoppenhaver" -n 5 3. Check currently scheduled posts: python3 scripts/typefully_scheduler.py --list-scheduled 4. Draft the tweet following the voice guide in references/voice-guide.md 5. Run through preflight/postflight checklists 6. Present draft to user for approval 7. If approved, write to temp file and either schedule or save as draft via Typefully
Tools
bird CLI (read-only)
Use for research and voice calibration only. Do not use for posting.
- Search tweets:
bird search "from:kkoppenhaver" -n 10 - Read a tweet:
bird read <tweet-id-or-url>
Typefully Scheduler (scheduling)
Use scripts/typefully_scheduler.py to schedule tweets via Typefully API. The script is designed for automation (no interactive prompts).
Setup: Add to ~/.claude/settings.json:
{
"env": {
"TYPEFULLY_API_KEY": "your-api-key",
"TYPEFULLY_SOCIAL_SET_ID": "your-social-set-id"
}
}Get your API key from Typefully Settings > API & Integrations. To find your social set ID, run: python3 scripts/typefully_scheduler.py --list-social-sets
Usage:
# Check what's already scheduled (do this first to avoid conflicts)
python3 scripts/typefully_scheduler.py --list-scheduled
# Save as draft (no scheduling, just sends to Typefully drafts)
python3 scripts/typefully_scheduler.py --file /tmp/tweet.txt --draft
# Schedule a tweet from a file
python3 scripts/typefully_scheduler.py --file /tmp/tweet.txt --schedule next-free-slot
# Schedule for a specific time (ISO 8601 format)
python3 scripts/typefully_scheduler.py --file /tmp/tweet.txt --schedule 2026-01-20T18:00:00Z
# Publish immediately
python3 scripts/typefully_scheduler.py --file /tmp/tweet.txt --schedule now
# List social sets
python3 scripts/typefully_scheduler.py --list-social-sets
# View connected platforms
python3 scripts/typefully_scheduler.py --detailsScheduling workflow: 1. Write tweet content to a temp file (e.g., /tmp/tweet.txt) 2. Run the scheduler with --file and --schedule flags 3. The script will show existing scheduled posts before scheduling
Note: Posts about the same topic can go out on Twitter and LinkedIn at the same time - that's fine. Just avoid scheduling unrelated posts at conflicting times.
Quick Reference
Structure Blueprints
Micro Discovery Post: 1. Hook (spark) - one-line setup with ellipses or short exclamation 2. Context (1-2 lines) - minimal specifics: tools, files, goal 3. Reveal/Result - concise verdict, optionally with emoji 4. Nudge - actionable suggestion in second person 5. Caveat - quick realism note
Contrast Hack Post: 1. Frame - "Old pattern" pain in concrete details 2. Flip - "New pattern" with imperative nudge 3. Proof touch - tiny outcome or timing delta 4. Button - micro-CTA or emoji beat
Syntax Constraints
- Sentence length: 5-15 words average; include 1-2 ultra-short bursts (3-5 words)
- Paragraphs: 1-2 lines each with frequent line breaks
- Ellipses: 0-2 per tweet
- Exclamation marks: ≤1
- Questions: 0-2 per tweet
- Emojis: 0-2 per tweet at line ends
- Banned: emoji chains, over-hedging, corporate jargon, passive voice clusters
Preflight Checklist
- [ ] One core insight or win clearly defined
- [ ] At least one concrete tool/file/outcome
- [ ] Contrast or reveal beat present
- [ ] One candid caveat or edge case
- [ ] Emoji used sparingly if at all (0-1, only if it adds to the moment)
- [ ] Sentences mostly 5-15 words with a 3-5 word punch
- [ ] Direct, testable CTA in second person (if applicable)
- [ ] Light warmth without undercutting the result
Postflight Checklist
- [ ] No sentence >22 words
- [ ] Emojis ≤2
- [ ] Ellipses ≤2
- [ ] One caveat included
- [ ] Read-aloud rhythm is snappy
- [ ] Tone: confident but curious, not boastful
Detailed Voice Guide
For emotional mechanics, lexicon, and example patterns, see references/voice-guide.md.
Voice Guide
Detailed voice mechanics, lexicon, and example patterns for writing tweets in Keanan's voice.
Emotional Mechanics
These techniques create the specific emotional responses in readers:
Ellipses After Setup
- Creates suspense/anticipation
- Place "…" before the reveal
- Example: "Tried the new Claude feature..." → reveal
Contrast Pairs (Before/After)
- Provides clarity + momentum
- Label each side with concrete details
- Example: "Old way: 3 manual steps. New way: one command."
Self-Deprecating Aside
- Creates warmth + relatability
- Admit limits lightly, often with a grin emoji
- Example: "Still debugging that edge case 😅"
Rhetorical Question
- Drives engagement
- Pose a practical "what now?" then answer it
- Example: "What's the fastest way to X? Turns out..."
Imperative Nudge
- Gives reader agency
- Issue a simple, testable instruction
- Example: "Try running
npm startwith the --verbose flag."
Emoji as Punctuation
- Creates emotional spike
- Place after a revelation, not mid-clause
- Example: "It compiled on the first try 🎉"
Lexicon
Avoid → Prefer
| Avoid | Prefer |
|---|---|
| Abstract "do stuff" | Concrete "clone, install, run" |
| Heavy certainty | Light hedges with a plan |
| Jargon walls | Named tools and files |
| Generic praise | Specific outcomes ("boots locally," "compiles clean") |
Signature Vocabulary
Friction/flow words: edges, bloopers, spin-up, spiral, clean run
Micro-wins: tiny win, quick spin, first try energy
Outcome anchors: locally, end-to-end, on-device, in browser
Example Patterns
These patterns are for emotional effect reference. Never copy phrasing directly—use them to understand the structure.
Discovery Pattern
Structure: 1. Setup with curiosity hook 2. Minimal context (tool + goal) 3. Reveal with emoji punctuation 4. Light caveat 5. Optional CTA
Example structure (don't copy words):
Tried [thing]...
Just wanted to [simple goal]. Used [tool].
Actually worked on the first run 🎉
Fair warning: [edge case]. But for most use cases, try [specific action].Contrast Pattern
Structure: 1. Old way = friction (concrete pain) 2. New way = flow (specific solution) 3. Proof point (time saved, outcome) 4. Button emoji
Example structure (don't copy words):
Old workflow: [tedious multi-step process with specifics]
New workflow: [simple solution with named tool]
Saved [time/effort]. Works for [use case] ⚡Keanan's Preferences
Patterns learned from direct feedback:
Parallel Structure
Combine repeated structures into grammatically complete sentences rather than fragmenting them.
- Avoid: "It can't see your browser. Can't check your Notion. Can't interact with anything outside your files."
- Prefer: "It can't see your browser, can't check your Notion, and can't interact with anything outside your files."
Outcome Over Tool Name
Explain what a tool does rather than just naming it. Lead with the benefit.
- Avoid: "But Playwright is literally one line."
- Prefer: "But giving CC its own browser is literally one line."
Trust the Reader
Don't over-explain concepts inline. Link to the full post for details and keep the tweet focused on the hook and payoff.
Acceptable Shorthand
"CC" is acceptable shorthand for "Claude Code" when space is tight.
Specificity on the Payoff
Add concrete details to the exciting part. Make the outcome vivid.
- Avoid: "navigate a browser"
- Prefer: "navigate a real web browser"
Flow Over Fragments
Don't break related ideas into separate staccato sentences. Connect them into one flowing thought with "and" or commas. This applies to tweets just as much as blog posts.
- Avoid: "I tried it. Got garbled code. Text was unreadable."
- Prefer: "I tried it and I got garbled code, made-up descriptions, and text I couldn't even read."
No Announcement Phrases
Don't telegraph reveals with phrases like "The kicker:" or "Here's the thing:" — just make the point directly. Fold the payoff into the natural flow of the previous sentence instead of giving it its own fanfare.
- Avoid: "The kicker: when I needed to fix something, I just fixed it."
- Prefer: "...and when I needed to fix something, it just...fixed it."
Extend Conclusions With Next Steps
Don't stop at the conclusion. Add a practical follow-through so the reader knows what to actually do.
- Avoid: "code wins."
- Prefer: "code wins, then just export as a PDF if you need to print."
Don't Rush to Judgment
Let observations land naturally instead of rushing to a verdict. Softer pacing feels more honest.
- Avoid: "the cracks showed fast"
- Prefer: "the cracks started to show"
Personal Moments Over Abstract Transitions
Ground transitions in personal experience ("I saw," "I noticed") rather than abstract descriptions. Use accessible language over technical shorthand.
- Avoid: "The real difference showed up during iteration."
- Prefer: "I saw the real difference when I needed to make changes."
Brevity Over Narrative (Blog Promo Tweets)
When promoting a blog post, keep it short: 3 short paragraphs max. State the problem and solution simply, add one brief caveat, and link. Don't walk through the whole workflow or spell out every step of the "old way" — just summarize the friction in a few words. The blog post has the details; the tweet's job is to make someone want to click.
- Avoid: "My workflow used to be: open a Google Doc, select all, copy, paste into a local file, edit with Claude Code, copy the result back, hope the formatting survived. The GWS CLI cuts all of that. Pull a Doc down, edit it locally, push it back..."
- Prefer: "My Claude Code + Google Docs used to involve a ton of copy/pasting. Now with the Google Workspace CLI, I don't have to leave Claude Code. Still a little finicky with formatting, but it's much more efficient."
Just Share the Thing
Not every tweet needs a reveal structure, contrast pattern, or storytelling arc. Sometimes the best tweet is just: here's what I was doing, here's what changed, here's the link. Casual and direct beats clever and theatrical.
Voice Anti-Patterns
Things to avoid:
- Corporate jargon: "leverage," "synergy," "utilize"
- Over-hedging stacks: "I think maybe it might possibly work"
- Passive voice clusters: "The feature was tested and was found to be..."
- Emoji chains: "🔥🔥🔥💯💯"
- Vague praise: "This is amazing!" (say WHY it's useful)
- No specifics: Always name the tool, file, or outcome
#!/usr/bin/env python3
"""
Typefully Tweet Scheduler
Schedule tweets to X/Twitter via the Typefully API v2.
Required environment variables:
TYPEFULLY_API_KEY - API key from Typefully Settings > API & Integrations
TYPEFULLY_SOCIAL_SET_ID - Social set ID (run with --list-social-sets to find it)
Usage:
# Schedule a tweet from a file
python typefully_scheduler.py --file /path/to/tweet.txt --schedule next-free-slot
# Schedule for a specific time
python typefully_scheduler.py --file /path/to/tweet.txt --schedule 2026-01-20T18:00:00Z
# Publish immediately
python typefully_scheduler.py --file /path/to/tweet.txt --schedule now
# Show currently scheduled posts
python typefully_scheduler.py --list-scheduled
# List social sets
python typefully_scheduler.py --list-social-sets
"""
import argparse
import json
import os
import sys
import urllib.request
import urllib.error
from dataclasses import dataclass
from typing import Optional, List, Dict, Any
API_BASE = "https://api.typefully.com/v2"
@dataclass
class ScheduleResult:
success: bool
draft_id: Optional[str] = None
status: Optional[str] = None
scheduled_date: Optional[str] = None
share_url: Optional[str] = None
error: Optional[str] = None
class TypefullyScheduler:
def __init__(self, api_key: str, social_set_id: str):
self.api_key = api_key
self.social_set_id = social_set_id
def _make_request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict[str, Any]:
"""Make an authenticated request to the Typefully API."""
url = f"{API_BASE}{endpoint}"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
body = json.dumps(data).encode() if data else None
request = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(request) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as e:
error_body = e.read().decode() if e.fp else str(e)
try:
error_data = json.loads(error_body)
raise Exception(f"API Error ({e.code}): {error_data.get('message', error_body)}")
except json.JSONDecodeError:
raise Exception(f"API Error ({e.code}): {error_body}")
def list_social_sets(self) -> List[Dict[str, Any]]:
"""List all available social sets."""
response = self._make_request("GET", "/social-sets")
return response.get("results", [])
def get_social_set_details(self, social_set_id: str) -> Dict[str, Any]:
"""Get detailed info about a social set including connected platforms."""
return self._make_request("GET", f"/social-sets/{social_set_id}/")
def list_scheduled_drafts(self, limit: int = 20) -> List[Dict[str, Any]]:
"""List scheduled drafts, sorted by scheduled date."""
response = self._make_request(
"GET",
f"/social-sets/{self.social_set_id}/drafts?limit={limit}"
)
drafts = response.get("results", [])
# Filter to only scheduled posts and sort by date
scheduled = [d for d in drafts if d.get("status") == "scheduled" and d.get("scheduled_date")]
scheduled.sort(key=lambda d: d.get("scheduled_date", ""))
return scheduled
def schedule_tweet(
self,
text: str,
publish_at: Optional[str] = "next-free-slot",
share: bool = False,
platform: str = "x"
) -> ScheduleResult:
"""
Schedule a tweet via Typefully, or save as draft.
Args:
text: The tweet text
publish_at: When to publish - "now", "next-free-slot", ISO 8601 datetime, or None for draft only
share: Whether to generate a public share URL
platform: Target platform - "x", "linkedin", or "both"
Returns:
ScheduleResult with draft details or error
"""
platforms = {}
if platform in ("x", "both"):
platforms["x"] = {"enabled": True, "posts": [{"text": text}]}
if platform in ("linkedin", "both"):
platforms["linkedin"] = {"enabled": True, "posts": [{"text": text}]}
data = {
"platforms": platforms,
"share": share
}
if publish_at is not None:
data["publish_at"] = publish_at
try:
response = self._make_request(
"POST",
f"/social-sets/{self.social_set_id}/drafts",
data
)
return ScheduleResult(
success=True,
draft_id=response.get("id"),
status=response.get("status"),
scheduled_date=response.get("scheduled_date"),
share_url=response.get("share_url")
)
except Exception as e:
return ScheduleResult(success=False, error=str(e))
def schedule_thread(
self,
posts: List[str],
publish_at: Optional[str] = "next-free-slot",
share: bool = False
) -> ScheduleResult:
"""
Schedule a thread via Typefully, or save as draft.
Args:
posts: List of tweet texts (each becomes a post in the thread)
publish_at: When to publish - "now", "next-free-slot", ISO 8601 datetime, or None for draft only
share: Whether to generate a public share URL
Returns:
ScheduleResult with draft details or error
"""
data = {
"platforms": {
"x": {
"enabled": True,
"posts": [{"text": text} for text in posts]
}
},
"share": share
}
if publish_at is not None:
data["publish_at"] = publish_at
try:
response = self._make_request(
"POST",
f"/social-sets/{self.social_set_id}/drafts",
data
)
return ScheduleResult(
success=True,
draft_id=response.get("id"),
status=response.get("status"),
scheduled_date=response.get("scheduled_date"),
share_url=response.get("share_url")
)
except Exception as e:
return ScheduleResult(success=False, error=str(e))
def format_result(result: ScheduleResult) -> str:
"""Format a schedule result for display."""
lines = []
if result.success:
if result.status == "draft":
lines.append("Tweet saved as draft in Typefully")
else:
lines.append("Tweet scheduled successfully")
if result.draft_id:
lines.append(f" Draft ID: {result.draft_id}")
if result.status:
lines.append(f" Status: {result.status}")
if result.scheduled_date:
lines.append(f" Scheduled: {result.scheduled_date}")
if result.share_url:
lines.append(f" Share URL: {result.share_url}")
else:
lines.append("Failed to schedule tweet")
if result.error:
lines.append(f" Error: {result.error}")
return "\n".join(lines)
def format_social_sets(social_sets: List[Dict[str, Any]]) -> str:
"""Format social sets for display."""
if not social_sets:
return "No social sets found."
lines = ["Available Social Sets:\n"]
for ss in social_sets:
lines.append(f" ID: {ss.get('id')}")
lines.append(f" Name: {ss.get('name', 'Unnamed')}")
if ss.get('username'):
lines.append(f" Username: @{ss.get('username')}")
lines.append("")
return "\n".join(lines)
def format_social_set_details(details: Dict[str, Any]) -> str:
"""Format social set details for display."""
lines = [f"Social Set: {details.get('name', 'Unnamed')} (ID: {details.get('id')})\n"]
lines.append("Connected Platforms:")
platforms = details.get("platforms", {})
for name, info in platforms.items():
if info:
display_name = "X/Twitter" if name == "x" else name.title()
lines.append(f" {display_name}: @{info.get('username')} - {info.get('profile_url')}")
return "\n".join(lines)
def format_scheduled_drafts(drafts: List[Dict[str, Any]]) -> str:
"""Format scheduled drafts for display."""
if not drafts:
return "No posts currently scheduled."
lines = ["Currently scheduled posts:\n"]
for draft in drafts:
scheduled = draft.get("scheduled_date", "Unknown")
preview = draft.get("preview", "")[:50]
if len(draft.get("preview", "")) > 50:
preview += "..."
platforms = []
if draft.get("x_post_enabled"):
platforms.append("X")
if draft.get("linkedin_post_enabled"):
platforms.append("LinkedIn")
platform_str = ", ".join(platforms) if platforms else "Unknown"
lines.append(f" {scheduled} [{platform_str}]")
lines.append(f" {preview}")
lines.append("")
return "\n".join(lines)
def get_env_or_exit(name: str) -> str:
"""Get an environment variable or exit with an error."""
value = os.environ.get(name)
if not value:
print(f"Error: {name} environment variable is not set.")
print(f"\nTo set it, run:")
print(f" export {name}=your_value_here")
sys.exit(1)
return value
def main():
parser = argparse.ArgumentParser(
description="Schedule tweets via Typefully API",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --file tweet.txt --schedule next-free-slot
%(prog)s --file tweet.txt --schedule 2026-01-20T18:00:00Z
%(prog)s --file tweet.txt --schedule now
%(prog)s --list-scheduled
%(prog)s --list-social-sets
"""
)
parser.add_argument("--file", "-f", help="Path to file containing tweet text")
parser.add_argument("--schedule", "-s", default="next-free-slot",
help="When to publish: 'now', 'next-free-slot', or ISO 8601 datetime")
parser.add_argument("--draft", action="store_true",
help="Save as draft without scheduling (overrides --schedule)")
parser.add_argument("--platform", "-p", default="x", choices=["x", "linkedin", "both"],
help="Target platform: 'x', 'linkedin', or 'both' (default: x)")
parser.add_argument("--share", action="store_true", help="Generate a public share URL")
parser.add_argument("--list-scheduled", action="store_true", help="List currently scheduled posts")
parser.add_argument("--list-social-sets", action="store_true", help="List available social sets")
parser.add_argument("--details", action="store_true", help="Show social set details")
args = parser.parse_args()
api_key = get_env_or_exit("TYPEFULLY_API_KEY")
# Handle --list-social-sets
if args.list_social_sets:
scheduler = TypefullyScheduler(api_key, "")
try:
social_sets = scheduler.list_social_sets()
print(format_social_sets(social_sets))
except Exception as e:
print(f"Error fetching social sets: {e}")
sys.exit(1)
return
social_set_id = get_env_or_exit("TYPEFULLY_SOCIAL_SET_ID")
scheduler = TypefullyScheduler(api_key, social_set_id)
# Handle --details
if args.details:
try:
details = scheduler.get_social_set_details(social_set_id)
print(format_social_set_details(details))
except Exception as e:
print(f"Error fetching details: {e}")
sys.exit(1)
return
# Handle --list-scheduled
if args.list_scheduled:
try:
scheduled_drafts = scheduler.list_scheduled_drafts()
print(format_scheduled_drafts(scheduled_drafts))
except Exception as e:
print(f"Error fetching scheduled posts: {e}")
sys.exit(1)
return
# Schedule a tweet - requires --file
if not args.file:
parser.error("--file is required when scheduling a tweet")
# Read tweet content from file
try:
with open(args.file, "r") as f:
text = f.read().strip()
except FileNotFoundError:
print(f"Error: File not found: {args.file}")
sys.exit(1)
except Exception as e:
print(f"Error reading file: {e}")
sys.exit(1)
if not text:
print("Error: Tweet file is empty")
sys.exit(1)
# Show currently scheduled posts for context
try:
scheduled_drafts = scheduler.list_scheduled_drafts()
if scheduled_drafts:
print(format_scheduled_drafts(scheduled_drafts))
print()
except Exception:
pass # Non-fatal, continue with scheduling
# Schedule or draft the tweet
publish_at = None if args.draft else args.schedule
if args.draft:
print(f"Saving tweet as draft...")
else:
print(f"Scheduling tweet...")
print(f" Schedule: {args.schedule}")
print(f" Content preview: {text[:50]}{'...' if len(text) > 50 else ''}")
print()
result = scheduler.schedule_tweet(text, publish_at, args.share, platform=args.platform)
print(format_result(result))
if not result.success:
sys.exit(1)
if __name__ == "__main__":
main()