@classytic/social
v0.3.1
Published
Unified social media provider SDK — YouTube, TikTok, Instagram, Facebook, LinkedIn, Telegram, WhatsApp, Twitter/X, Reddit. OAuth, video upload, publishing, scheduling.
Downloads
649
Readme
@classytic/social
One TypeScript SDK for nine social platforms. OAuth, posting, video upload, messaging — with batteries included for AI agents.
Platforms: YouTube · TikTok · Instagram · Facebook · LinkedIn · Telegram · WhatsApp · Twitter/X · Reddit
Why this package
- One client, every platform. Configure once, post anywhere.
- AI-ready. One import → typed tool definitions for OpenAI, Anthropic, MCP, and the Vercel AI SDK.
- Type-safe end-to-end. Zod v4 schemas double as runtime validators, JSON Schema, and form descriptors.
- Production-grade. Shared HTTP client with timeouts, retry, rate-limit handling, and SSRF guards.
- Tree-shakeable. Per-provider subpath imports — only ship what you use.
Install
npm install @classytic/social
# YouTube provider needs googleapis as a peer dep:
npm install googleapisThree-line quickstart
import { fromEnv } from '@classytic/social';
const client = fromEnv(); // reads TWITTER_*, FACEBOOK_*, etc.
await client.post({ text: 'Hello, internet!' }); // fans out to every configured platformThat's it. Configure credentials via env vars, call post(), and the SDK handles auth, rate limits, retries, and platform quirks.
Configure explicitly
import { SocialClient } from '@classytic/social';
const client = SocialClient.create({
twitter: { clientId, clientSecret, tokens: { access_token } },
linkedin: { clientId, clientSecret, tokens: { access_token }, authorUrn: 'urn:li:person:abc' },
facebook: { appId, appSecret, tokens: { access_token }, pageId, pageAccessToken },
telegram: { botToken: '123:abc', chatId: '@my-channel' },
});
// Fan out to all configured platforms
const result = await client.post({
text: 'Hello world',
media: [{ type: 'image', url: 'https://cdn.example.com/banner.png' }],
link: 'https://example.com/launch',
});
console.log(result.allOk, result.results); // per-platform pass/fail breakdownPer-platform calls
await client.twitter!.post({ text: 'Just a tweet' });
await client.telegram!.send('Hi from the bot');
await client.linkedin!.post({ text: 'Long-form content...' });
await client.youtube!.upload({ videoUrl: '...', title: 'Launch!' });Video upload to multiple platforms
await client.upload({
platforms: ['youtube', 'tiktok'],
videoUrl: 'https://cdn.example.com/clip.mp4',
title: 'Launch trailer',
description: 'Out now.',
privacy: 'public',
});Direct messaging
await client.message({ platform: 'telegram', text: 'Build deployed' });
await client.message({ platform: 'whatsapp', to: '+15551234567', text: 'Verification code: 1234' });AI agents — compose tools yourself
This package deliberately doesn't ship LLM-SDK-specific tool adapters. Tool format is an application concern that varies per stack and changes between SDK versions. Compose tools at the call site using primitives the package gives you: typed SocialClient methods + zod schemas from @classytic/social/schemas.
Vercel AI SDK v5
import { tool } from 'ai';
import { z } from 'zod';
import { SocialClient } from '@classytic/social';
const client = SocialClient.create({ /* ... */ });
export const tools = {
twitter_post: tool({
description: 'Post a tweet (≤280 characters)',
inputSchema: z.object({
text: z.string().max(280),
replyToTweetId: z.string().optional(),
}),
execute: ({ text, replyToTweetId }) => client.twitter!.post({
text,
perPlatform: { twitter: { replyTo: replyToTweetId } },
}),
}),
telegram_send: tool({
description: 'Send a message via the configured Telegram bot',
inputSchema: z.object({ text: z.string().min(1).max(4096) }),
execute: ({ text }) => client.telegram!.send(text),
}),
};OpenAI / Anthropic / MCP
Generate JSON Schema from any zod schema with z.toJSONSchema() (zod v4 builtin):
import { z } from 'zod';
const twitterPostInput = z.object({
text: z.string().max(280),
replyToTweetId: z.string().optional(),
});
// OpenAI Chat Completions
const openaiTool = {
type: 'function' as const,
function: {
name: 'twitter_post',
description: 'Post a tweet',
parameters: z.toJSONSchema(twitterPostInput),
},
};
// Anthropic Messages
const anthropicTool = {
name: 'twitter_post',
description: 'Post a tweet',
input_schema: z.toJSONSchema(twitterPostInput),
};
// MCP `tools/list`
const mcpTool = {
name: 'twitter_post',
description: 'Post a tweet',
inputSchema: z.toJSONSchema(twitterPostInput),
};
// Then dispatch a tool call to the bound client:
async function dispatch(name: string, input: unknown) {
switch (name) {
case 'twitter_post': {
const args = twitterPostInput.parse(input);
return client.twitter!.post({
text: args.text,
perPlatform: { twitter: { replyTo: args.replyToTweetId } },
});
}
// ... other tools
}
}That's 5 lines per tool. The package gives you:
- Methods —
client.twitter!.post(...),client.telegram!.send(...)etc. - Schemas — pre-built zod v4 schemas in
@classytic/social/schemas - Capabilities —
allCapabilities,providersWithCapability('messaging')for filtering
You compose them into whatever tool shape your LLM stack expects — and you control the input shape (e.g., flatten perPlatform.twitter.replyTo into a top-level replyToTweetId) without inheriting the package's opinions.
OAuth flows
Each sub-client exposes the standard OAuth lifecycle:
// 1. Build the consent URL
const url = await client.twitter!.authUrl('csrf-state-token');
// → redirect user
// 2. Exchange callback code (PKCE handled internally)
const tokens = await client.twitter!.exchangeCode(code, 'csrf-state-token');
// tokens are stored on `client.twitter.config.tokens`
// 3. Refresh later
const fresh = await client.twitter!.refresh();Telegram and WhatsApp use static tokens — no OAuth required.
Subpath exports
Tree-shakeable entrypoints — import only what you need.
| Subpath | Purpose |
|---|---|
| @classytic/social | SocialClient, fromEnv, all provider classes, errors |
| @classytic/social/client | Just the unified client + sub-clients |
| @classytic/social/schemas | Zod v4 schemas + capability map (for arc, OpenAPI, MCP) |
| @classytic/social/common | httpRequest, oauth2*, paginate, pollUntilComplete, assertPublicHttpUrl |
| @classytic/social/webhooks | verifyMetaWebhook, verifyTelegramWebhook, verifyTwitterWebhook, verifyYouTubeWebhook, verifyHmac — timing-safe signature verification |
| @classytic/social/<provider> | Direct access to a single provider class |
Schemas — design APIs from the SDK
Every credential, request input, and capability is a zod v4 schema. Generate JSON Schema, build forms, or validate at request boundaries:
import { z } from 'zod';
import {
YouTubeUploadParamsSchema,
TelegramSendMessageSchema,
allCapabilities,
providersWithCapability,
} from '@classytic/social/schemas';
// Validate input
const params = YouTubeUploadParamsSchema.parse(body);
// Generate OpenAPI / MCP tool spec
const jsonSchema = z.toJSONSchema(YouTubeUploadParamsSchema);
// Filter providers by feature
providersWithCapability('scheduling'); // → ['youtube', 'facebook']
providersWithCapability('messaging'); // → ['telegram', 'whatsapp']
// TikTok is NOT in the scheduling list — its Content Posting API has no
// schedule_time field. To schedule TikTok posts, queue them in your app
// and call `client.tiktok.upload(...)` at fire time. (Same for Twitter,
// Reddit, Telegram, WhatsApp, LinkedIn — see the capabilities matrix.)Capabilities matrix
All flags below come from verified live behavior — every claim is gated
by a contract test in tests/contracts/capabilities.contract.test.ts and
exposed at runtime via provider.getMetadata().capabilities.
| Provider | Auth | Text | Photo | Video | Carousel | Delete | Edit | Schedule | Caveats | |---|---|---|---|---|---|---|---|---|---| | YouTube | OAuth2 | — | — | ✓ | — | ✓ | ✓ | ✓ | — | | TikTok | OAuth2 + PKCE | — | ✓ | ✓ | — | ✗ | ✗ | ✗ | no DELETE in Content Posting API; native scheduling silently ignored | | Instagram | OAuth2 (Meta) | — | ✓ | ✓ | ✓ | ✗ | ✗ | — | published-media DELETE returns Meta #1 — manual delete in IG app | | Facebook | OAuth2 (Meta) | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | — | | LinkedIn (member) | OAuth2 | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | — | member DELETE silently no-ops via API | | LinkedIn (org) | OAuth2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | — | requires Community Mgmt API approval, separate app | | Twitter/X | OAuth2 + PKCE | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | — | edit gated to X Premium | | Reddit | OAuth2 | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | — | | Telegram | Bot Token | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | | WhatsApp | API Token | ✓ | ✓ | ✓ | — | ✗ | ✗ | — | 24h re-engagement window; sent messages can't be unsent |
Read these at runtime to decide UI affordances:
const meta = provider.getMetadata();
if (meta.capabilities.canDeletePost) {
// show "Delete" button
}
if (meta.capabilities.caveats?.length) {
// surface a warning chip listing them
}provider.getMetadata().supportsScheduling is kept as a back-compat
alias for capabilities.canSchedulePost. Prefer the new shape.
Error handling
All providers throw SocialError with a uniform shape:
import { SocialError } from '@classytic/social';
try {
await client.twitter!.post({ text: 'Hi' });
} catch (e) {
if (e instanceof SocialError) {
console.log(e.provider); // 'twitter'
console.log(e.statusCode); // 401, 429, 502, …
console.log(e.errorCode); // platform-specific code
console.log(e.retryable); // boolean | null
console.log(e.retryAfter); // seconds (for 429)
console.log(e.hint); // human-readable mitigation hint
}
}The unified client.post() / client.upload() never throw on per-platform failures — each result has ok: boolean and error? populated independently.
Custom HTTP / OAuth — build your own provider
import { httpRequest, oauth2ExchangeCode, paginate } from '@classytic/social/common';
// Same retry / timeout / 429 handling used internally
const { data } = await httpRequest('mastodon', {
method: 'POST',
url: 'https://mastodon.social/api/v1/statuses',
bearer: accessToken,
json: { status: 'Hello' },
timeout: 30_000,
retry: { attempts: 2 },
});Webhook signature verification
Every social platform signs incoming webhook deliveries. Re-implementing HMAC + constant-time comparison in every consumer is a recipe for forgery bugs. Import the per-platform helper:
import {
verifyMetaWebhook,
verifyTelegramWebhook,
verifyTwitterWebhook,
verifyYouTubeWebhook,
} from '@classytic/social/webhooks';
// Meta (Facebook / Instagram / WhatsApp / Threads — all use X-Hub-Signature-256)
const ok = verifyMetaWebhook({
rawBody: req.rawBody!, // raw bytes, NOT the parsed body
appSecret: process.env.META_APP_SECRET!,
xHubSignature256: req.headers['x-hub-signature-256'] as string,
});
if (!ok) return reply.code(401).send();| Provider | Algorithm | Encoding | Header |
|---|---|---|---|
| Meta (FB / IG / WhatsApp) | HMAC-SHA256 | hex | X-Hub-Signature-256: sha256=<hex> |
| Twitter / X | HMAC-SHA256 | base64 | X-Twitter-Webhooks-Signature: sha256=<base64> |
| YouTube PuSH | HMAC-SHA1 | hex | X-Hub-Signature: sha1=<hex> |
| Telegram | string equality | — | X-Telegram-Bot-Api-Secret-Token: <secret> |
All comparisons use crypto.timingSafeEqual — the class of "your webhook
is open to forgery" bugs is gone for every consumer that imports these.
Caveat: always pass the raw request bytes for HMAC-based providers.
JSON-parsing then re-serializing the body (via JSON.stringify) changes
whitespace/key order, breaking the signature. Use @fastify/raw-body or
your framework's equivalent to capture the bytes.
Environment variables (fromEnv())
Set any subset — only matching platforms are enabled.
# YouTube
YOUTUBE_CLIENT_ID=... YOUTUBE_CLIENT_SECRET=...
YOUTUBE_ACCESS_TOKEN=... YOUTUBE_REFRESH_TOKEN=...
# Twitter
TWITTER_CLIENT_ID=... TWITTER_CLIENT_SECRET=...
TWITTER_ACCESS_TOKEN=... TWITTER_REFRESH_TOKEN=...
# Facebook (Pages)
FACEBOOK_APP_ID=... FACEBOOK_APP_SECRET=...
FACEBOOK_ACCESS_TOKEN=... FACEBOOK_PAGE_ID=...
FACEBOOK_PAGE_ACCESS_TOKEN=...
# Instagram
INSTAGRAM_APP_ID=... INSTAGRAM_APP_SECRET=...
INSTAGRAM_ACCESS_TOKEN=... INSTAGRAM_USER_ID=...
# LinkedIn
LINKEDIN_CLIENT_ID=... LINKEDIN_CLIENT_SECRET=...
LINKEDIN_ACCESS_TOKEN=... LINKEDIN_AUTHOR_URN=urn:li:person:...
# Reddit
REDDIT_CLIENT_ID=... REDDIT_CLIENT_SECRET=...
REDDIT_USER_AGENT="web:my-app:v1.0 (by /u/yourname)"
REDDIT_ACCESS_TOKEN=... REDDIT_REFRESH_TOKEN=...
# TikTok
TIKTOK_CLIENT_KEY=... TIKTOK_CLIENT_SECRET=...
TIKTOK_ACCESS_TOKEN=... TIKTOK_REFRESH_TOKEN=...
# Telegram (bot)
TELEGRAM_BOT_TOKEN=123:abc TELEGRAM_CHAT_ID=@my-channel
# WhatsApp Business
WHATSAPP_ACCESS_TOKEN=... WHATSAPP_BUSINESS_ACCOUNT_ID=...
WHATSAPP_PHONE_NUMBER_ID=...Development
npm run build # tsdown → dist/ (+ attw + publint validation)
npm run typecheck # tsc --noEmit
npm test # unit + contract tests (503 tests, ~2s)
npm run test:contracts # contract probes only (149 tests, pins URLs/scopes/body shapes)
npm run test:e2e # real-API publish tests (opt-in; needs RUN_E2E=1 + provider creds)
npm run test:watch # watch modeTest pyramid
| Tier | Files | Purpose | When it runs |
|---|---|---|---|
| Unit | tests/*.test.ts | logic + parsers + URL/body construction (mocked HTTP) | every npm test |
| Contract | tests/contracts/*.contract.test.ts | pin URLs, scopes, body shapes, status enums, capabilities against platform docs | every npm test |
| e2e | tests/e2e/*.e2e.test.ts | real publish → verify → delete → assert gone | opt-in (RUN_E2E=1 + per-provider creds) |
Contract probes are the gate that catches "platform docs changed but unit tests still mock the old shape" — exactly the bug class that escapes mocked-only suites.
License
MIT
