Part 5: Building PhysicianTechnologist - Twitter/X Thread Automation That Actually Works
The frustrating journey from 'Post Now' button that did nothing to a fully functional Twitter/X thread publisher. Database mismatches, API permissions, and why you shouldn't test post to Twitter at 2am.

Dr. Chad Okay
NHS Resident Doctor & Physician-Technologist
Building PhysicianTechnologist Part 7: Twitter/X Thread Automation That Actually Works
"When I click post now, nothing gets posted to Twitter."
That was the moment I realised my beautifully designed Twitter thread curator—complete with AI generation, manual editing, and a lovely preview interface—was essentially a very expensive text editor. The "Post Now" button? Purely decorative.
The Vision vs Reality
Here's what I thought I was building: A sophisticated system that would take my long-form healthcare technology articles and transform them into engaging Twitter threads with a single click. GPT-4 would handle the summarisation, maintaining NHS terminology and UK spelling throughout. Editors could refine the output, preview how it would look on Twitter, and publish directly from the admin dashboard.
Here's what I actually built initially: A button that logged "Thread has no tweets" to the console whilst I stared at the screen wondering if I'd forgotten how databases work.
The Architecture (When It Finally Worked)
Let me show you the setup that now successfully posts threads to Twitter/X:
// lib/x-client.ts - The beating heart of our Twitter integration
export class XClient {
private client: TwitterApi;
private isConfigured: boolean;
constructor() {
// Check if all required environment variables are present
this.isConfigured = !!(
process.env.X_API_KEY &&
process.env.X_API_SECRET &&
process.env.X_ACCESS_TOKEN &&
process.env.X_ACCESS_TOKEN_SECRET
);
if (this.isConfigured) {
this.client = new TwitterApi({
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
accessToken: process.env.X_ACCESS_TOKEN!,
accessSecret: process.env.X_ACCESS_TOKEN_SECRET!,
});
} else {
console.warn('[XClient] X API credentials not configured. Publishing will be disabled.');
}
}
The database schema tracks everything meticulously:
CREATE TABLE twitter_threads (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID REFERENCES posts(id),
thread_tweets JSONB NOT NULL, -- This innocent column name caused me pain
status TEXT CHECK (status IN ('draft', 'ready', 'publishing', 'published', 'failed')),
x_thread_url TEXT,
publishing_error TEXT,
published_at TIMESTAMP WITH TIME ZONE
);
The Bug That Made Me Question Everything
Picture this: It's 1:30am. I'm testing the thread publisher for the fifteenth time. The console keeps showing:
ERROR: API Error: VALIDATION_FAILED {
message: 'Thread has no tweets'
}
But I can SEE the tweets. They're right there in the database. They're displaying in the preview. The AI generated them perfectly. What could possibly be wrong?
The answer was embarrassingly simple:
// What the API was checking (WRONG):
if (!thread.tweets || thread.tweets.length === 0) {
return APIResponseSender.error(res, 'VALIDATION_FAILED', 'Thread has no tweets');
}
// What it should have been checking (CORRECT):
if (!thread.thread_tweets || thread.thread_tweets.length === 0) {
return APIResponseSender.error(res, 'VALIDATION_FAILED', 'Thread has no tweets');
}
One underscore. thread.tweets
vs thread.thread_tweets
. Three hours of debugging for an underscore.
The Permission Dance
After fixing the database column mismatch, I hit the next wall:
Error: Not authorized. Your X app may need elevated access.
This led me down the rabbit hole of Twitter's developer portal, which has apparently been redesigned by someone who enjoys watching developers suffer. Here's what you actually need to do:
- •
Find the Hidden Settings: Navigate to your app (it might be under "Projects" or "Standalone Apps" depending on when Mercury is in retrograde)
- •
The Magic Checkbox: Look for "User authentication settings" → Set "App permissions" to "Read and write" (not just "Read", which is the sneaky default)
- •
The Token Regeneration Ritual: After changing permissions, you MUST regenerate your access tokens. The old ones won't work. Twitter won't tell you this directly.
- •
The Free Tier Reality Check: With the free tier, you get 17 posts per 24 hours. For a healthcare blog, that's actually plenty. For testing at 2am when you're convinced the API is broken? Not so much.
The Working Implementation
Here's the thread posting logic that actually works:
async postThread(tweets: string[]): Promise<ThreadPostResult> {
if (!this.isConfigured) {
throw new Error('X API credentials not configured');
}
// Validate tweet lengths (yes, it's still 280 characters)
for (let i = 0; i < tweets.length; i++) {
if (tweets[i].length > 280) {
throw new Error(`Tweet ${i + 1} exceeds 280 characters (${tweets[i].length} chars)`);
}
}
try {
let lastTweetId: string | undefined;
const tweetIds: string[] = [];
// Post each tweet, threading them together
for (let i = 0; i < tweets.length; i++) {
console.log(`[XClient] Posting tweet ${i + 1}/${tweets.length}`);
const tweetData: any = {
text: tweets[i],
};
// If this isn't the first tweet, make it a reply to the previous one
if (lastTweetId) {
tweetData.reply = {
in_reply_to_tweet_id: lastTweetId
};
}
const response = await this.client.v2.tweet(tweetData);
lastTweetId = response.data.id;
tweetIds.push(response.data.id);
// Small delay to avoid rate limiting (learned this the hard way)
if (i < tweets.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
const threadUrl = `https://x.com/${username}/status/${tweetIds[0]}`;
console.log(`[XClient] Thread posted successfully: ${threadUrl}`);
return {
id: tweetIds[0],
url: threadUrl,
tweetIds
};
} catch (error: any) {
// Parse Twitter API errors for better messages
if (error.code === 429) {
throw new Error('Rate limit exceeded. Please wait before posting again.');
} else if (error.code === 403) {
throw new Error('Not authorized. Your X app may need elevated access.');
} else {
throw new Error(error.message || 'Failed to post thread');
}
}
}
The Admin Interface That Makes It Worth It
After all the API wrestling, the admin interface is actually quite pleasant to use:
The curator shows everything you need: AI-generated thread previews with character counts for each tweet (helpfully colour-coded to warn you when you're approaching the limit), inline editing capabilities for every tweet, real-time publishing status tracking, and error messages that actually make sense instead of cryptic API codes.
Here's the publishing flow:
// pages/api/admin/twitter-threads/publish.ts
async function handler(req: NextApiRequest, res: NextApiResponse, auth: AuthContext) {
const { threadId } = req.body;
// Get thread from database
const { data: thread, error } = await supabaseAdmin
.from('twitter_threads')
.select('*')
.eq('id', threadId)
.single();
if (!thread.thread_tweets || thread.thread_tweets.length === 0) {
return APIResponseSender.error(res, 'VALIDATION_FAILED', 'Thread has no tweets');
}
// Update status to publishing
await supabaseAdmin
.from('twitter_threads')
.update({
status: 'publishing',
publishing_error: null
})
.eq('id', threadId);
try {
// Post thread to X
console.log(`Publishing thread ${threadId} with ${thread.thread_tweets.length} tweets`);
const result = await xClient.postThread(thread.thread_tweets);
// Update database with success
await supabaseAdmin
.from('twitter_threads')
.update({
status: 'published',
published_at: new Date().toISOString(),
x_thread_url: result.url,
publishing_error: null
})
.eq('id', threadId);
return APIResponseSender.success(res, {
message: 'Thread published successfully',
url: result.url
});
} catch (error: any) {
// Update database with failure
await supabaseAdmin
.from('twitter_threads')
.update({
status: 'failed',
publishing_error: error.message
})
.eq('id', threadId);
return APIResponseSender.error(res, 'INTERNAL_ERROR',
'Failed to publish thread', 500, {
error: error.message
});
}
}
The Debugging Journey (A Comedy in Three Acts)
Act 1: The Mysterious 403
Me: "Why is it saying not authorised? I have the tokens!"
API: "403 Forbidden"
Me: "But I literally just created these tokens!"
API: "403 Forbidden"
Me: *Discovers the app has read-only permissions*
Act 2: The Token Regeneration
Me: "I've changed the permissions to read-write"
API: "403 Forbidden"
Me: "BUT I CHANGED THE PERMISSIONS"
Twitter Docs: *whispers* "regenerate your tokens"
Me: "Oh."
Act 3: The Working Test
Me: *clicks Post Now nervously*
Console: "Publishing thread with 1 tweets"
Console: "[XClient] Posting tweet 1/1"
Console: "Thread posted successfully"
Me: *checks Twitter*
Tweet: "AI changes everything for UK healthcare..."
Me: *single tear of joy*
Lessons Learned (The Hard Way)
- •
Always check your database column names: That underscore cost me three hours.
thread.tweets
vsthread.thread_tweets
. I will never forget this. - •
Twitter permissions are not what they seem: "Read" doesn't mean you can read everything. "Write" requires explicit permission. And changing permissions requires token regeneration, which they don't mention prominently.
- •
Free tier is actually fine: 17 posts per day sounds limiting until you realise that's more than enough for a healthcare blog. Just don't test extensively at 2am.
- •
Error messages lie: "Your app may need elevated access" actually meant "You forgot to set write permissions". Thanks, Twitter.
- •
Delay between tweets matters: That 1-second delay between posts isn't just being polite—it prevents rate limiting issues that manifest in mysterious ways.
The Sweet Success
After all the debugging, permission wrangling, and that one memorable underscore incident, the system now works beautifully. I can generate Twitter threads from any blog post using GPT-4, edit and refine them in a clean interface that shows exactly how they'll appear on Twitter, and post them with a single click. The system tracks publishing status in real-time and provides clear error messages if anything goes wrong.
The best part? It maintains UK spelling throughout (optimise, not optimize), uses NHS terminology correctly, and even adds that professional healthcare touch that distinguishes PhysicianTechnologist content.
What's Next?
Now that the Twitter integration is working, I'm planning several enhancements. Thread scheduling will let me post at optimal times for healthcare professional engagement (typically early morning or lunch breaks). Analytics integration will track thread performance directly in the dashboard, showing impressions, engagement rates, and click-throughs. LinkedIn support is next on the list because, let's face it, healthcare professionals love LinkedIn. And thread templates will provide common formats for different types of content, from breaking research summaries to clinical case discussions.
The Code That Makes It All Work
For those brave enough to implement their own Twitter integration, here's my advice:
- •Start with the environment variables:
X_API_KEY=your_api_key
X_API_SECRET=your_api_secret
X_ACCESS_TOKEN=your_access_token # Regenerate after permission changes!
X_ACCESS_TOKEN_SECRET=your_access_token_secret
- •
Check permissions three times: Seriously. Read and write. Not just read.
- •
Test with single tweets first: Don't try threading immediately. Make sure basic posting works.
- •
Log everything during development: You'll thank yourself when debugging at 2am.
The Bottom Line
Building Twitter/X integration taught me that the distance between "this should work" and "this actually works" is measured in underscores, permission checkboxes, and regenerated tokens. But when that first thread successfully posts, when you see your carefully crafted healthcare content reaching a wider audience with a single click—it's worth every debugging session.
The "Post Now" button works now. And yes, I've clicked it more than necessary just to watch it work. Don't judge me.
Building PhysicianTechnologist has been a journey of small victories and memorable debugging sessions. If you're building your own platform or struggling with Twitter API integration, connect with me on Twitter/X (where the threads now actually post) or LinkedIn. I promise my response time is better than Twitter's API documentation.
Next up in the series: "Why I Decided to Build My Own Analytics Dashboard (Spoiler: Google Analytics Made Me Cry)"
Share this article

Dr. Chad Okay
I am a London‑based NHS Resident Doctor with 8+ years' experience in primary care, emergency and intensive care medicine. I'm developing an AI‑native wearable to tackle metabolic disease. I combine bedside insight with end‑to‑end tech skills, from sensor integration to data visualisation, to deliver practical tools that extend healthy years.