Getting started with OpenPub
Publish a Markdown file to dev.to, Hashnode, WordPress, Ghost, Medium, Substack, GitHub Pages, and X Articles. OpenPub sets canonical URLs from your first public post and records each result. Update support varies by platform, so check the platform limits before you republish.
#Architecture Overview
Cross-posting tools typically keep your sensitive platform credentials on third-party servers, charge recurring monthly subscriptions, or quietly break when APIs update. OpenPub inverts this model: your drafts, credentials, and publish logs reside directly in your own local files or Cloudflare D1 SQLite database.
#Two Ways to Run OpenPub
Zero infrastructure required. Credentials live in ~/.openpub/config.json on your laptop. Run commands directly or let Claude Code / Cursor publish over MCP.
Deploy OpenPub to your Cloudflare account. Backed by D1 SQLite, R2 media storage, Queues with automatic retries, and a live web dashboard. KV binding is currently unused.
#Quickstart with the CLI
The CLI isn't on npm yet. In a clone of the repo, build it once with npm install && npm run cli:build, then store credentials, check them, and publish:
# Stored locally; publishing sends them only to that platform
node apps/cli/dist/index.js setup devto '{"api_key": "dv1_your_api_key"}'node apps/cli/dist/index.js status# Draft by default. Pass --status published to make it live
node apps/cli/dist/index.js publish post.md --to devto,hashnode,github-pages--status published, status: published in frontmatter, or defaults.status in your config. The self-hosted Worker and remote MCP publish live immediately; Substack creates drafts only.#Prerequisites
- Cloudflare account; resource quotas, account eligibility, and billing requirements vary
- Node.js 22 or newer
- Wrangler CLI:
npm install -g wrangler(or run vianpx wrangler) - Wrangler 4.x recommended; the repository pins
wrangler@^4as a dev dependency
Log in once so Wrangler can interact with your Cloudflare account:
wrangler login#1. Clone and Install
git clone https://github.com/princepal9120/openpub.git openpub
cd openpub
npm ci#2. Create Cloudflare Resources
OpenPub relies on five Cloudflare resource bindings matching wrangler.toml. Four are provisioned via the Wrangler CLI, plus Workers AI enabled on your account:
# D1 database (drafts, publish log, platform credentials)
wrangler d1 create openpub-db
# KV namespace (bound but currently unused)
wrangler kv namespace create KEYS
# R2 bucket (generated cover images)
wrangler r2 bucket create openpub-media
# Queue (async publish jobs, with consumer configured in wrangler.toml)
wrangler queues create openpub-publishEach command outputs a resource ID. Update your wrangler.toml with these values:
[[d1_databases]]
binding = "DB"
database_name = "openpub-db"
database_id = "<id from d1 create>"
[[kv_namespaces]]
binding = "KEYS"
id = "<id from kv namespace create>"
[[r2_buckets]]
binding = "MEDIA"
bucket_name = "openpub-media"
[[queues.producers]]
binding = "PUBLISH_QUEUE"
queue = "openpub-publish"
[[queues.consumers]]
queue = "openpub-publish"
max_batch_size = 5
max_retries = 3
[ai]
binding = "AI"#3. Apply Database Schema
wrangler d1 execute openpub-db --remote --file=./migrations/init.sqlThis migration initializes the platform, draft, publish, and dashboard-auth tables. Safe to re-run at any time because all statements utilize IF NOT EXISTS. Existing deployments should also apply migrations/002_dashboard_auth.sql before deploying this version.
#4. Set the Admin Secret
The ADMIN_KEY secret authenticates REST API and MCP clients. The dashboard uses it once to create a password; later sign-ins use that password. Generate a strong 32-byte hex key:
openssl rand -hex 32
wrangler secret put ADMIN_KEY
# paste the generated key when prompted#5. Configure BASE_URL
Generated cover image URLs and canonical links are constructed using BASE_URL. Update the [vars] section in wrangler.toml:
[vars]
BASE_URL = "https://openpub.<your-subdomain>.workers.dev"
DASHBOARD_URL = "https://openpub.<your-subdomain>.workers.dev"If you do not know your subdomain yet, deploy once in step 6, copy the printed URL, update BASE_URL, and deploy again.
#6. Deploy to Cloudflare
npm run deployThis builds the dashboard SPA (npm run build --workspace openpub-dashboard) to apps/dashboard/.output/public, then deploys the Worker and its static assets together with Wrangler.
#7. Verify Endpoints
Public liveness check:
curl https://openpub.<subdomain>.workers.dev/health
# {"ok":true,"name":"openpub"}Authenticated API status check:
curl -H "Authorization: Bearer $ADMIN_KEY" \
https://openpub.<subdomain>.workers.dev/api/status
# {"platforms":[],"available":["devto","hashnode","wordpress","ghost","medium","substack","github-pages","x"]}Streamable MCP handshake check:
curl https://openpub.<subdomain>.workers.dev/mcp
# {"name":"openpub","transport":"streamable-http","usage":"POST JSON-RPC with Authorization: Bearer ADMIN_KEY"}#Custom Domain (Optional)
- Add your domain zone to your Cloudflare dashboard.
- Navigate to Workers & Pages -> select
openpub-> Settings -> Domains & Routes -> Add Custom Domain. - Enter your preferred hostname (e.g.
publish.yourdomain.com). Cloudflare provisions TLS automatically. - Update
BASE_URLinwrangler.tomlto match and runnpm run deploy.
#Cloudflare Free Tier Allocation
| Resource | Free Tier Quota | Usage in OpenPub |
|---|---|---|
| Workers requests | 100,000 / day | REST API and remote MCP requests |
| D1 Database | 5 GB storage, 5M reads / day | Draft bodies, publish logs, and platform credentials |
| KV Namespace | 100,000 reads / day | Bound in configuration; currently unused |
| R2 Object Storage | 10 GB storage, $0 egress | Generated cover images and uploaded assets |
| Queues | Check current Cloudflare plan | Decoupled async publishing jobs with automatic retries |
| Workers AI | 10,000 neurons / day | Fast FLUX cover image generation |
#Updating OpenPub
git pull
npm ci
npm run deploy
# For database schema upgrades:
wrangler d1 execute openpub-db --remote --file=./migrations/<name>.sql#Set up the web console
After deploying, open the console at your deployed instance’s /app URL. The first visit asks for your ADMIN_KEY once and lets you choose a password; future visits need only that password. The password hash and platform credentials are stored in your instance's D1 database. Sign-in uses a secure HttpOnly session cookie, not localStorage.
After signing in, add platform credentials and drafts from the console. Publishing requires reviewing and confirming the destinations. Substack creates a draft for you to publish in its own editor.
#Next Steps
CLI Reference
The local-first openpub CLI runs directly on your machine with credentials stored in ~/.openpub/config.json (0600), letting you manage platforms, audit markdown, validate links, and fan out publications.
#Installation & Setup
Build the CLI from a clone of this repository (Node 20+):
git clone https://github.com/princepal9120/openpub.git
cd openpub
npm install && npm run cli:buildRun commands with node apps/cli/dist/index.js <command>, or run npm link --workspace openpub-cli once to make the openpub binary available globally in your PATH.
Initialize the local OpenPub configuration:
node apps/cli/dist/index.js initopenpub init creates ~/.openpub and prints the next setup command. Platform credentials are saved to ~/.openpub/config.json with restrictive permissions (0600) and are only sent directly to target platform APIs when publishing.
#Command Reference Matrix
| Command | Arguments & Flags | Purpose |
|---|---|---|
| init | - | Create ~/.openpub directory and print next steps |
| setup | <platform> <json-or-file> | Store platform credentials in ~/.openpub/config.json and verify |
| test | <platform> | Verify stored credentials against target platform API |
| forget | <platform> | Remove stored credentials for a platform |
| status | - | Show configured platforms, reachability, and defaults |
| publish | <file.md> [--to ...] [--status draft|published] [--canonical <url>] [--dry-run] [--force] | Fan out article to platforms (default status: draft) |
| preview | <file.md> | Render local markdown to HTML in ~/.openpub/preview/index.html |
| frontmatter | <file.md> [--format <f>] | Rewrite frontmatter (openpub, hugo, jekyll, astro, nextjs, devto, hashnode, ghost) |
| audit | <file.md> | Report quality issues (word count, headings, links, tags) and score out of 100 |
| check-links | <file.md> | HEAD-request every HTTP(S) URL in the post and report status |
#Command Examples
Publish a post to dev.to and Hashnode:
node apps/cli/dist/index.js publish my-article.md --to devto,hashnode --status draftCheck configured platforms status:
node apps/cli/dist/index.js status#Markdown Frontmatter Specification
Specify target platforms, canonical URLs, and tags directly inside your Markdown frontmatter:
---
title: "Shipping Edge Workers on Cloudflare"
canonical: "https://myblog.com/posts/shipping-edge-workers"
cover_image: "https://<worker>/covers/<uuid>.png"
platforms:
- devto
- hashnode
- ghost
- wordpress
tags:
- typescript
- cloudflare
- webdev
status: draft
---
## Introduction
Your article content starts here in standard Markdown...MCP Setup
OpenPub provides two ways to connect AI agents: a local stdio MCP server running on your machine, and a Streamable HTTP MCP server on your deployed Cloudflare Worker at POST /mcp.
#Local stdio vs Streamable HTTP
OpenPub ships a local stdio MCP server for direct integration with Claude Code, Cursor, Codex, and VS Code without requiring a Cloudflare deployment. When self-hosting on Cloudflare, OpenPub additionally exposes a Model Context Protocol endpoint over Streamable HTTP at POST /mcp, protected by your ADMIN_KEY secret.
#Build Local stdio Server from Repo
Build the local stdio MCP server from a clone of this repository (Node 20+):
git clone https://github.com/princepal9120/openpub.git
cd openpub/apps/mcp
npm install && npm run build#Available MCP Tools
OpenPub defines tool sets tailored to each transport: 10 local tools for stdio clients and 13 remote tools for Cloudflare Worker clients.
Local stdio MCP Tools (10 Tools · apps/mcp/handlers.ts)
Transport: stdio. Operates directly on local markdown files, ~/.openpub/config.json, and ~/.openpub/state.json.
| Tool | Purpose | Key Arguments |
|---|---|---|
| publish | Fan out local Markdown file to selected platforms (default: draft) | file (req), platforms[], status, canonical, dryRun, force |
| cross_publish | Write inline content to temp file and fan out (default: draft) | title (req), content (req), platforms[], status |
| list_published | Return entries from local ledger (~/.openpub/state.json) | - |
| setup | Store and verify credentials in ~/.openpub/config.json | platform (req), credentials (req) |
| status | List configured platforms and verification status | - |
| test_platform | Verify stored credentials against one platform API | platform (req) |
| content_audit | Score Markdown file with CLI audit parity quality checks | file (req) |
| check_links | HEAD-request every URL in Markdown file and report status | file (req) |
| frontmatter | Rewrite frontmatter into target format preserving fields | file (req), format (req) |
| x_article | Publish Markdown file to X platform only (default: draft) | file (req), status |
Remote Worker MCP Tools (13 Tools · src/mcp.ts)
Transport: Streamable HTTP (POST /mcp). Requires ADMIN_KEY authentication. Worker publish tools publish live immediately; save_draft only creates a draft. Operates on Cloudflare D1, R2, Queues, and Workers AI.
| Tool | Purpose | Key Arguments |
|---|---|---|
| publish | Publish a saved draft from D1 to one platform via Queue | draftId (req), platform (req) |
| cross_publish | Publish draft or inline content to all platforms with canonical wiring | draftId?, title?, content?, platforms[] |
| save_draft | Create a draft in Cloudflare D1 storage | content (req), platforms[] (req), title? |
| list_drafts | List existing drafts in D1 with status and platforms | - |
| get_draft | Retrieve draft details and markdown body from D1 | id (req) |
| delete_draft | Remove a draft from D1 storage | id (req) |
| setup | Store platform API credentials in D1 table platform_config | platform (req), credentials (req) |
| status | Show configured and available platforms from D1 | - |
| test_platform | Test stored credentials against target platform API | platform (req) |
| generate_cover | Generate cover image via Workers AI and store in R2 bucket | prompt (req), draftId? |
| list_posts | Inspect publish history from D1 publish_log with live URLs | - |
| check_links | HEAD-check URLs in Markdown body with SSRF protections | content (req) |
| frontmatter | Generate frontmatter block for Hugo, Jekyll, Astro, Next.js, etc. | title (req), format (req), tags? |
#Claude Code Configuration
For local stdio mode, add to .mcp.json in your project:
{
"mcpServers": {
"openpub": {
"command": "node",
"args": ["/absolute/path/to/openpub/apps/mcp/dist/index.js"]
}
}
}Or connect to your self-hosted Cloudflare Worker over HTTP:
claude mcp add --transport http openpub \
https://openpub.<subdomain>.workers.dev/mcp \
--header "Authorization: Bearer <ADMIN_KEY>"#Cursor & VS Code Configuration
Add to .cursor/mcp.json:
{
"mcpServers": {
"openpub": {
"command": "node",
"args": ["/absolute/path/to/openpub/apps/mcp/dist/index.js"]
}
}
}For VS Code GitHub Copilot, use .vscode/mcp.json with a "servers" key. For remote HTTP worker access, replace command/args with "url": "https://openpub.<subdomain>.workers.dev/mcp" and headers.
#Windsurf & Codex Configuration
Add to mcp_config.json in Windsurf:
{
"mcpServers": {
"openpub": {
"command": "node",
"args": ["/absolute/path/to/openpub/apps/mcp/dist/index.js"]
}
}
}For Codex CLI, add [mcp_servers.openpub] with command = "node" and args = ["/absolute/path/to/openpub/apps/mcp/dist/index.js"] in ~/.codex/config.toml.
#Generic HTTP / curl Verification
BASE=https://openpub.<subdomain>.workers.dev
KEY=<ADMIN_KEY>
# Call tools/list
curl -X POST $BASE/mcp \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'#Typical First Agent Session
Remote MCP cannot read local files. Include article text in your prompt. Worker publishing sends posts live immediately; use the local CLI or stdio MCP when you need draft-first publishing.
#Troubleshooting Guide
| Symptom | Cause | Resolution |
|---|---|---|
| 401 unauthorized | Incorrect or missing ADMIN_KEY | Verify Bearer header matches the secret set with wrangler secret put |
| Tools not showing | Client failed initial handshake | Reload editor window or restart MCP server connection |
GitHub Action
Release status for automated publishing on push.
#Not released yet
princepal9120/openpub@main or an unreleased tag as a runnable publishing Action.For now, build the CLI from source and run a dry run before publishing. Keep credentials in repository secrets if you invoke the CLI from your own CI workflow.
Platforms Table
OpenPub supports eight publishing platforms. Adapters read credential keys verbatim; self-hosted Worker credentials are stored as plaintext JSON in D1.
#Storing and Testing Credentials
Store Worker credentials via REST API using POST /api/platforms/:name. The local CLI stores credentials separately in ~/.openpub/config.json and does not configure the Worker:
# Via REST API:
curl -X POST \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"api_key": "your-devto-key"}' \
https://<worker>/api/platforms/devto
# Test connectivity:
curl -X POST -H "Authorization: Bearer $ADMIN_KEY" \
https://<worker>/api/platforms/devto/test
# {"ok":true}#Quick Reference Matrix
| Platform | Slug | Credential Keys | Token Source | Behavior |
|---|---|---|---|---|
| dev.to | devto | api_key | Settings > Extensions | Live publish, 4 tags max, canonical URL, cover |
| Hashnode | hashnode | token, publication_id? | Account > Developer > PAT | GraphQL publishPost, 5 tags, canonical URL |
| WordPress | wordpress | site_url, username, app_password | WP Admin > Users > App Passwords | REST API /wp-json/wp/v2/posts, HTML body |
| Ghost | ghost | site_url, admin_key | Ghost Admin > Integrations | Admin API key (id:secret), HS256 JWT, HTML body |
| Medium | medium | integration_token | Settings > Security and apps | Creates public or draft, canonical URL, no updates |
| Substack | substack | publication_url, connect_sid | Browser DevTools Cookies | Internal drafts API, ProseMirror format, manual send |
| GitHub Pages | github-pages | token, repo, dir?, branch? | GitHub Developer Settings > PAT | Git commit markdown with frontmatter into repo |
| X Articles | x | access_token | developer.x.com OAuth 2.0 | Articles API v2, requires X Premium on account |
#dev.to (devto)
Scope: read and write articles. Go to dev.to -> Settings -> Extensions -> DEV Community API Keys. Create a key named openpub and paste into credentials:
{
"api_key": "xxxxxxxxxxxxxxxxxxxxxxxx"
}#Hashnode (hashnode)
Go to hashnode.com -> Account Settings -> Developer Settings -> Personal Access Tokens:
{
"token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"publication_id": "65f1a2b3c4d5e6f7a8b9c0d1"
}#WordPress (wordpress)
Go to WP Admin -> Users -> Profile -> Application Passwords. Generate an application password:
{
"site_url": "https://yourblog.com",
"username": "your-wp-username",
"app_password": "xxxx xxxx xxxx xxxx xxxx xxxx"
}#Ghost (ghost)
Go to Ghost Admin -> Settings -> Integrations -> Add custom integration. Copy the Admin API Key (format <id>:<secret>):
{
"site_url": "https://yourblog.ghost.io",
"admin_key": "64f1a2b3c4d5e6f7a8b9c0d1:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}#Medium (medium)
Integration tokens available from medium.com/me/settings -> Security and apps:
{
"integration_token": "2a1b3c4d5e6f..."
}#Substack (substack)
Copy connect.sid from browser DevTools Cookies on your publication domain:
{
"publication_url": "https://yourpub.substack.com",
"connect_sid": "s%3Axxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}#GitHub Pages (github-pages)
Directly commits Markdown files into your static site repository (Astro, Hugo, Jekyll, Next.js, mdBook):
{
"token": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"repo": "youruser/yourblog",
"dir": "content/posts",
"branch": "main"
}#X Articles (x)
Publishes articles via the X Articles API v2. Requires an account with active X Premium:
{
"access_token": "xxxxxxxxxxxxxxxxxxxxxxxx"
}Architecture & API
OpenPub relies entirely on Cloudflare edge primitives. No servers, no containers, and no external database dependencies.
#System Diagram
+---------------------------------------------+
MCP clients ----------->| Cloudflare Worker (Hono) |
(Claude Code, Cursor) | |
Dashboard ------------->| /api/* REST /mcp JSON-RPC |
+------+-+------------------+-+---------------+
| | | |
+------------v-+ | +---------v-+-+
| D1 | | Queue |
| drafts, creds,| | openpub- |
| publish_log | | publish |
+------------+-+-+ +---------+-+-+
| | | | consumer (retry x3)
| | | v
| | +--------+-------+
| | | Platform |---> dev.to API
| | | adapters |---> Hashnode GraphQL
| | | |---> WP REST
| | | |---> Ghost Admin API
| | | |---> Medium API
| | | |---> Substack drafts
| | | |---> GitHub Contents API
| | | |---> X Articles API
| | +--------+-------+
| | | canonical URL of first
| +-----------------+ success feeds the rest
v
KV: KEYS (unused) R2: MEDIA (cover images) Workers AI: FLUX schnell#Core Edge Components
Cloudflare Worker (Hono)
Edge request router handling high-performance REST APIs and the Streamable HTTP MCP JSON-RPC protocol in a single unified deployment.
Cloudflare D1 Database
Serverless SQLite database maintaining drafts, platform credentials, and publish logs. Failed and retryable log entries can be removed during retries.
Cloudflare Queues
Asynchronous task runner isolating each platform dispatch. If one external API times out, remaining platforms complete without failure.
R2 Bucket & Workers AI
On-demand AI cover illustration using FLUX.1 schnell, stored in R2 and served directly by the edge worker with zero egress fees.
#Publish Lifecycle & Canonical Injection
- Draft Creation: Content is submitted through the dashboard or remote MCP API with title, tags, and target platforms. Worker tag parsing accepts inline
tags: [a, b]only (max 6); YAML block-list tags are ignored. The local CLI and stdio MCP publish directly to platform APIs instead of this Worker. - Queue Dispatch: Individual queue messages are enqueued for each platform adapter.
- First-Success Canonical Wiring: The first platform that successfully creates a public post sets the post URL as the article canonical URL.
- Cross-Post Attribution: Subsequent platform publications automatically include that canonical tag, preserving your original search engine ranking equity.
#Idempotent Republishing
The local CLI hashes content in ~/.openpub/state.json; unchanged posts are skipped, and supported platforms update in place when remote IDs exist. X Articles, Substack, and Medium cannot be updated by the CLI. The Worker uses a separate queue: platforms already marked successful are skipped rather than updated.
#REST API Reference
| Method | Endpoint | Description |
|---|---|---|
| GET | /health | Unauthenticated liveness check |
| GET | /api/status | Configured and available platform adapters |
| POST | /api/platforms/:name | Store platform credentials JSON |
| POST | /api/platforms/:name/test | Verify platform credentials against upstream API |
| GET | /api/drafts | List stored drafts in D1 |
| POST | /api/drafts | Create a draft {title, content, platforms, cover_image} |
| PUT | /api/drafts/:id | Update draft fields; send null to clear title or cover_image |
| DELETE | /api/drafts/:id | Delete draft and publish history |
| POST | /api/publish | Queue all draft platforms or a requested subset |
| GET | /api/history | Fetch publish log with URLs and error diagnostics |
| POST | /api/history/:draftId/:platform/retry | Retry a failed platform publish |
| POST | /api/images/generate | Generate cover image from prompt via Workers AI |
| POST | /mcp | Streamable HTTP MCP JSON-RPC 2.0 endpoint |