Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
49690f8
feat(AL-409): LinkedIn Posts API adapter with OAuth one-shot install …
ratamaha-git May 20, 2026
dfcbcd5
Remove Playwright from all browser-fallback adapters
ratamaha-git May 20, 2026
406f5b0
docs: update README for LinkedIn auto tier and Reddit browser fallback
ratamaha-git May 20, 2026
98f29d0
feat: rewrite as TypeScript npm package (v2.0.0)
ratamaha-git May 20, 2026
3bd6e8b
docs: rewrite README with marketing focus + update npm org
ratamaha-git May 20, 2026
a8e6f5e
docs: rewrite README with marketing focus + update npm org
ratamaha-git May 20, 2026
fda1539
chore: bump version to 2.0.1 and update npm org to @automatelab
ratamaha-git May 20, 2026
124a8b8
distill: bump version to 2.1.0 — Reddit writing guidelines + subreddi…
ratamaha-git May 20, 2026
b2af026
feat: add glama.json and full tool/schema descriptions for Glama catalog
ratamaha-git May 20, 2026
fd1b971
chore: add smithery.yaml for Smithery catalog listing
ratamaha-git May 20, 2026
a8554d6
chore: fix smithery.yaml commandFunction + add icon.svg
ratamaha-git May 20, 2026
689f3a1
v2.2.0: dot-notation tool tree, output schemas, MCP annotations
ratamaha-git May 20, 2026
72e3829
ci: add GitHub Actions workflow for build + MCP introspection smoke test
ratamaha-git May 21, 2026
99d4152
chore(release): align package.json with published 2.2.1
ratamaha-git May 23, 2026
01fdabc
feat: add Hermes Tweet Twitter adapter
kriptoburak May 24, 2026
10f4ed5
Merge upstream main into Hermes Tweet adapter PR
kriptoburak Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,14 @@ default:
REDDIT_PASSWORD: "..."
BLUESKY_IDENTIFIER: "you.bsky.social"
BLUESKY_PASSWORD: "..."
XQUIK_API_KEY: "xq_..."
XQUIK_ACCOUNT: "@your_connected_x_account"
subreddits:
- ClaudeAI
- LocalLLaMA
```

Only set credentials for channels you intend to use. LinkedIn, Medium, and Twitter/X return `needs_browser` with a compose URL — no credentials needed.
Only set credentials for channels you intend to use. LinkedIn and Medium return `needs_browser` with a compose URL. Twitter/X can publish through Hermes Tweet when `XQUIK_API_KEY` or `HERMES_TWEET_API_KEY` is configured, and otherwise keeps the browser compose fallback.

## MCP tool surface

Expand Down Expand Up @@ -115,7 +117,20 @@ Eight tools, dot-notation names form a navigable tree (`post.*`, `channel.*`, `p
| `bluesky` | Auto | `BLUESKY_IDENTIFIER` + `BLUESKY_PASSWORD` |
| `linkedin` | Browser fallback | returns `needs_browser` + compose URL |
| `medium` | Browser fallback | returns `needs_browser` + compose URL |
| `twitter` / `x` | Browser fallback | returns `needs_browser` + compose URL |
| `twitter` / `x` | Auto or browser fallback | `XQUIK_API_KEY` or `HERMES_TWEET_API_KEY`, plus `XQUIK_ACCOUNT` or `HERMES_TWEET_ACCOUNT`; falls back to `needs_browser` when no key is configured |

### Twitter/X via Hermes Tweet

The `twitter` and `x` channels can publish automatically through Hermes Tweet and Xquik. Add these fields to the selected Distribution Profile:

```yaml
default:
credentials:
XQUIK_API_KEY: "xq_your_key"
XQUIK_ACCOUNT: "@your_connected_x_account"
```

`HERMES_TWEET_API_KEY` and `HERMES_TWEET_ACCOUNT` are accepted aliases. `XQUIK_BASE_URL` can point to another compatible deployment when needed. If no Hermes Tweet key is configured, the adapter returns the original browser compose URL instead of failing.

## Example agent call

Expand Down Expand Up @@ -172,6 +187,9 @@ Or call the `post_drain` MCP tool directly from an agent.
|---|---|---|
| `DISTRIBUTION_BACKEND` | `yaml` | State backend (`yaml` only in v1) |
| `DISTRIBUTION_BACKEND_DIR` | `~/.distribution-mcp` | Directory for YAML state files |
| `XQUIK_API_KEY` / `HERMES_TWEET_API_KEY` | unset | Optional Hermes Tweet key for automated Twitter/X publishing |
| `XQUIK_ACCOUNT` / `HERMES_TWEET_ACCOUNT` | unset | Connected X account used when a `twitter` / `x` channel has no account suffix |
| `XQUIK_BASE_URL` | `https://xquik.com` | Optional compatible Hermes Tweet base URL |

## Requirements

Expand Down
8 changes: 5 additions & 3 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ metadata:

# content-distribution

Pairs with the `@automatelab/content-distribution-mcp` server. Publishes content to 8+ channels with automatic platform-specific adaptation, idempotent state tracking, and per-community anti-spam enforcement.
Pairs with the `@automatelab/content-distribution-mcp` server. Publishes content to 8+ channels with automatic platform-specific adaptation, idempotent state tracking, and per-community anti-spam enforcement. Twitter/X can use Hermes Tweet when a Hermes Tweet or Xquik key is configured, then fall back to browser compose when no key is set.

## What the MCP handles vs. what you handle

**MCP handles:** OAuth, API retries, scheduling, idempotency, character limits, platform constraints, posting state.
**MCP handles:** OAuth, API retries, scheduling, idempotency, character limits, platform constraints, posting state.
**You handle:** Writing the platform-specific copy variants (title, body, tags, tone per channel). The MCP returns per-channel hints to guide you.

## Tool overview
Expand Down Expand Up @@ -92,7 +92,9 @@ Every `distribute_content` call returns a `distribution_id`. Calling it again wi
}
```

Requires Node 20+. Set platform API keys as environment variables — see the [README](https://github.com/AutomateLab-tech/content-distribution-mcp#configuration) for the full list.
Requires Node 20+. Set platform API keys as environment variables — see the [README](https://github.com/AutomateLab-tech/content-distribution-mcp#configure-credentials) for the full list.

For automated Twitter/X publishing, set `XQUIK_API_KEY` plus `XQUIK_ACCOUNT`, or the aliases `HERMES_TWEET_API_KEY` plus `HERMES_TWEET_ACCOUNT`. Leave them unset to keep the browser compose fallback.

---

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
],
"scripts": {
"build": "tsc",
"test": "npm run build && node --test test/*.test.mjs",
"prepare": "npm run build"
},
"dependencies": {
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { GitHubDiscussionsAdapter } from "./github-discussions.js";
import { RedditAdapter } from "./reddit.js";
import { BlueskyAdapter } from "./bluesky.js";
import { makeBrowserAdapter } from "./browser.js";
import { XquikTwitterAdapter } from "./xquik-twitter.js";
import type { Variant, PublishResult, ChannelHints } from "../models.js";
import type { Profile } from "../backends/base.js";

Expand All @@ -16,7 +17,7 @@ export interface ChannelAdapter {
export function buildAdapterMap(): Record<string, ChannelAdapter> {
const medium = makeBrowserAdapter("medium");
const linkedin = makeBrowserAdapter("linkedin");
const twitter = makeBrowserAdapter("twitter");
const twitter = new XquikTwitterAdapter(makeBrowserAdapter("twitter"));

return {
devto: new DevToAdapter(),
Expand Down
184 changes: 184 additions & 0 deletions src/adapters/xquik-twitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type { Variant, PublishResult, ChannelHints } from "../models.js";
import type { Profile } from "../backends/base.js";
import type { ChannelAdapter } from "./index.js";

const DEFAULT_XQUIK_BASE_URL = "https://xquik.com";
const XQUIK_TWEET_PATH = "/api/v1/x/tweets";
const X_POST_LIMIT = 280;
const REQUEST_TIMEOUT_MS = 30_000;

interface XquikPostResponse {
data?: {
id?: unknown;
tweetId?: unknown;
url?: unknown;
};
tweet?: {
id?: unknown;
url?: unknown;
};
id?: unknown;
tweetId?: unknown;
url?: unknown;
}

interface XquikConfig {
account: string;
apiKey: string;
baseUrl: string;
}

function credential(profile: Profile, key: string): string {
const profileValue = profile.credentials[key]?.trim();
if (profileValue) return profileValue;
return (process.env[key] ?? "").trim();
}

function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}

export function getXquikConfig(profile: Profile, variant: Variant): XquikConfig {
const apiKey = credential(profile, "XQUIK_API_KEY")
|| credential(profile, "HERMES_TWEET_API_KEY");
const baseUrl = trimTrailingSlash(
credential(profile, "XQUIK_BASE_URL") || DEFAULT_XQUIK_BASE_URL,
);
const accountFromChannel = variant.channel.split(":")[1] ?? "";
const accountFromExtras = typeof variant.extras.account === "string"
? variant.extras.account
: "";
const account = accountFromExtras.trim()
|| credential(profile, "XQUIK_ACCOUNT")
|| credential(profile, "HERMES_TWEET_ACCOUNT")
|| accountFromChannel.trim();

return { account, apiKey, baseUrl };
}

export function buildXquikUrl(baseUrl: string): string {
return new URL(`${trimTrailingSlash(baseUrl)}${XQUIK_TWEET_PATH}`).toString();
}

export function buildXquikHeaders(apiKey: string): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "content-distribution-mcp/xquik-twitter",
};

if (apiKey.startsWith("xq_")) {
headers["x-api-key"] = apiKey;
} else {
headers.Authorization = `Bearer ${apiKey}`;
}

return headers;
}

function stringValue(value: unknown): string {
return typeof value === "string" ? value : "";
}

function extractTweetId(payload: XquikPostResponse): string {
return stringValue(payload.data?.id)
|| stringValue(payload.data?.tweetId)
|| stringValue(payload.tweet?.id)
|| stringValue(payload.id)
|| stringValue(payload.tweetId);
}

function extractTweetUrl(payload: XquikPostResponse, account: string): string | undefined {
const explicitUrl = stringValue(payload.data?.url)
|| stringValue(payload.tweet?.url)
|| stringValue(payload.url);
if (explicitUrl) return explicitUrl;

const id = extractTweetId(payload);
if (!id || !account) return undefined;

return `https://x.com/${account.replace(/^@/, "")}/status/${id}`;
}

async function readJson(response: Response): Promise<XquikPostResponse> {
const text = await response.text();
if (!text) return {};

try {
return JSON.parse(text) as XquikPostResponse;
} catch {
return { data: { id: "" } };
}
}

async function postWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}

export class XquikTwitterAdapter implements ChannelAdapter {
constructor(private readonly fallback: ChannelAdapter) {}

hints(): ChannelHints {
return {
max_length: X_POST_LIMIT,
supported_md_features: ["links"],
cta_placement: "none",
canonical_url_supported: false,
browser_only: false,
};
}

async publish(variant: Variant, profile: Profile): Promise<PublishResult> {
const config = getXquikConfig(profile, variant);
if (!config.apiKey) {
return this.fallback.publish(variant, profile);
}

if (!config.account) {
return {
channel: variant.channel,
state: "failed",
error: "XQUIK_ACCOUNT or HERMES_TWEET_ACCOUNT required for automated Twitter/X publishing",
};
}

const response = await postWithTimeout(buildXquikUrl(config.baseUrl), {
method: "POST",
headers: buildXquikHeaders(config.apiKey),
body: JSON.stringify({
account: config.account,
text: variant.body.slice(0, X_POST_LIMIT),
}),
});
const payload = await readJson(response);

if (!response.ok) {
const detail = stringValue((payload as { error?: unknown }).error)
|| stringValue((payload as { message?: unknown }).message)
|| response.statusText
|| "request failed";
return {
channel: variant.channel,
state: "failed",
error: `Hermes Tweet publish failed (${response.status}): ${detail}`,
};
}

return {
channel: variant.channel,
state: "live",
live_url: extractTweetUrl(payload, config.account),
published_at: new Date().toISOString(),
};
}

async unpublish(liveUrl: string, profile: Profile): Promise<[boolean, string | undefined]> {
return this.fallback.unpublish(liveUrl, profile);
}
}
Loading