Documentation · Getting Started

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

1. Local CLI & MCP (No Server)

Zero infrastructure required. Credentials live in ~/.openpub/config.json on your laptop. Run commands directly or let Claude Code / Cursor publish over MCP.

Best for writers who work from a terminal or an agent.
2. Self-Hosted Cloudflare Worker

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.

Best when you want a web console, queued retries and MCP over HTTP.

#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:

Store credentials (dev.to example)
# Stored locally; publishing sends them only to that platform
node apps/cli/dist/index.js setup devto '{"api_key": "dv1_your_api_key"}'
Verify reachability
node apps/cli/dist/index.js status
Publish markdown to platforms
# Draft by default. Pass --status published to make it live
node apps/cli/dist/index.js publish post.md --to devto,hashnode,github-pages
CLI and local stdio MCP default to drafts. A post goes public only with --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 via npx wrangler)
  • Wrangler 4.x recommended; the repository pins wrangler@^4 as a dev dependency

Log in once so Wrangler can interact with your Cloudflare account:

terminal
wrangler login

#1. Clone and Install

terminal
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:

bash
# 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-publish

Each command outputs a resource ID. Update your wrangler.toml with these values:

wrangler.toml
[[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

bash
wrangler d1 execute openpub-db --remote --file=./migrations/init.sql

This 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:

bash
openssl rand -hex 32
wrangler secret put ADMIN_KEY
# paste the generated key when prompted
Store this key safely in your password manager. It remains necessary for MCP clients and one-time dashboard setup. The dashboard password is stored as a salted hash in D1, never as plaintext.

#5. Configure BASE_URL

Generated cover image URLs and canonical links are constructed using BASE_URL. Update the [vars] section in wrangler.toml:

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

bash
npm run deploy

This 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:

bash
curl https://openpub.<subdomain>.workers.dev/health
# {"ok":true,"name":"openpub"}

Authenticated API status check:

bash
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:

bash
curl https://openpub.<subdomain>.workers.dev/mcp
# {"name":"openpub","transport":"streamable-http","usage":"POST JSON-RPC with Authorization: Bearer ADMIN_KEY"}

#Custom Domain (Optional)

  1. Add your domain zone to your Cloudflare dashboard.
  2. Navigate to Workers & Pages -> select openpub -> Settings -> Domains & Routes -> Add Custom Domain.
  3. Enter your preferred hostname (e.g. publish.yourdomain.com). Cloudflare provisions TLS automatically.
  4. Update BASE_URL in wrangler.toml to match and run npm run deploy.

#Cloudflare Free Tier Allocation

ResourceFree Tier QuotaUsage in OpenPub
Workers requests100,000 / dayREST API and remote MCP requests
D1 Database5 GB storage, 5M reads / dayDraft bodies, publish logs, and platform credentials
KV Namespace100,000 reads / dayBound in configuration; currently unused
R2 Object Storage10 GB storage, $0 egressGenerated cover images and uploaded assets
QueuesCheck current Cloudflare planDecoupled async publishing jobs with automatic retries
Workers AI10,000 neurons / dayFast FLUX cover image generation

#Updating OpenPub

bash
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

Terminal Workflow

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+):

terminal
git clone https://github.com/princepal9120/openpub.git
cd openpub
npm install && npm run cli:build

Run 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:

bash
node apps/cli/dist/index.js init

openpub 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

CommandArguments & FlagsPurpose
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:

bash
node apps/cli/dist/index.js publish my-article.md --to devto,hashnode --status draft

Check configured platforms status:

bash
node apps/cli/dist/index.js status

#Markdown Frontmatter Specification

Specify target platforms, canonical URLs, and tags directly inside your Markdown frontmatter:

article.md
---
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...
Agent Integration

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+):

terminal
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.

ToolPurposeKey Arguments
publishFan out local Markdown file to selected platforms (default: draft)file (req), platforms[], status, canonical, dryRun, force
cross_publishWrite inline content to temp file and fan out (default: draft)title (req), content (req), platforms[], status
list_publishedReturn entries from local ledger (~/.openpub/state.json)-
setupStore and verify credentials in ~/.openpub/config.jsonplatform (req), credentials (req)
statusList configured platforms and verification status-
test_platformVerify stored credentials against one platform APIplatform (req)
content_auditScore Markdown file with CLI audit parity quality checksfile (req)
check_linksHEAD-request every URL in Markdown file and report statusfile (req)
frontmatterRewrite frontmatter into target format preserving fieldsfile (req), format (req)
x_articlePublish 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.

ToolPurposeKey Arguments
publishPublish a saved draft from D1 to one platform via QueuedraftId (req), platform (req)
cross_publishPublish draft or inline content to all platforms with canonical wiringdraftId?, title?, content?, platforms[]
save_draftCreate a draft in Cloudflare D1 storagecontent (req), platforms[] (req), title?
list_draftsList existing drafts in D1 with status and platforms-
get_draftRetrieve draft details and markdown body from D1id (req)
delete_draftRemove a draft from D1 storageid (req)
setupStore platform API credentials in D1 table platform_configplatform (req), credentials (req)
statusShow configured and available platforms from D1-
test_platformTest stored credentials against target platform APIplatform (req)
generate_coverGenerate cover image via Workers AI and store in R2 bucketprompt (req), draftId?
list_postsInspect publish history from D1 publish_log with live URLs-
check_linksHEAD-check URLs in Markdown body with SSRF protectionscontent (req)
frontmatterGenerate 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:

.mcp.json (local stdio)
{
  "mcpServers": {
    "openpub": {
      "command": "node",
      "args": ["/absolute/path/to/openpub/apps/mcp/dist/index.js"]
    }
  }
}

Or connect to your self-hosted Cloudflare Worker over HTTP:

terminal (remote worker)
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:

.cursor/mcp.json (local stdio)
{
  "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:

mcp_config.json
{
  "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

bash
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.

"Check these links, generate a cover image, then publish this article to dev.to and Hashnode: [paste article text]"

#Troubleshooting Guide

SymptomCauseResolution
401 unauthorizedIncorrect or missing ADMIN_KEYVerify Bearer header matches the secret set with wrangler secret put
Tools not showingClient failed initial handshakeReload editor window or restart MCP server connection
CI / CD Automation

GitHub Action

Release status for automated publishing on push.

#Not released yet

The CLI npm package and a tagged GitHub Action release are not published yet. This repository does not currently include an Action entry point. Do not use 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.

Adapters & Credentials

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:

bash
# 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

PlatformSlugCredential KeysToken SourceBehavior
dev.todevtoapi_keySettings > ExtensionsLive publish, 4 tags max, canonical URL, cover
Hashnodehashnodetoken, publication_id?Account > Developer > PATGraphQL publishPost, 5 tags, canonical URL
WordPresswordpresssite_url, username, app_passwordWP Admin > Users > App PasswordsREST API /wp-json/wp/v2/posts, HTML body
Ghostghostsite_url, admin_keyGhost Admin > IntegrationsAdmin API key (id:secret), HS256 JWT, HTML body
Mediummediumintegration_tokenSettings > Security and appsCreates public or draft, canonical URL, no updates
Substacksubstackpublication_url, connect_sidBrowser DevTools CookiesInternal drafts API, ProseMirror format, manual send
GitHub Pagesgithub-pagestoken, repo, dir?, branch?GitHub Developer Settings > PATGit commit markdown with frontmatter into repo
X Articlesxaccess_tokendeveloper.x.com OAuth 2.0Articles 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:

devto credentials JSON
{
  "api_key": "xxxxxxxxxxxxxxxxxxxxxxxx"
}

#Hashnode (hashnode)

Go to hashnode.com -> Account Settings -> Developer Settings -> Personal Access Tokens:

hashnode credentials JSON
{
  "token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "publication_id": "65f1a2b3c4d5e6f7a8b9c0d1"
}

#WordPress (wordpress)

Go to WP Admin -> Users -> Profile -> Application Passwords. Generate an application password:

wordpress credentials JSON
{
  "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>):

ghost credentials JSON
{
  "site_url": "https://yourblog.ghost.io",
  "admin_key": "64f1a2b3c4d5e6f7a8b9c0d1:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}

#Medium (medium)

Integration tokens available from medium.com/me/settings -> Security and apps:

medium credentials JSON
{
  "integration_token": "2a1b3c4d5e6f..."
}

#Substack (substack)

Copy connect.sid from browser DevTools Cookies on your publication domain:

substack credentials JSON
{
  "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):

github-pages credentials JSON
{
  "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:

x credentials JSON
{
  "access_token": "xxxxxxxxxxxxxxxxxxxxxxxx"
}
System Design

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

  1. 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.
  2. Queue Dispatch: Individual queue messages are enqueued for each platform adapter.
  3. First-Success Canonical Wiring: The first platform that successfully creates a public post sets the post URL as the article canonical URL.
  4. 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

MethodEndpointDescription
GET/healthUnauthenticated liveness check
GET/api/statusConfigured and available platform adapters
POST/api/platforms/:nameStore platform credentials JSON
POST/api/platforms/:name/testVerify platform credentials against upstream API
GET/api/draftsList stored drafts in D1
POST/api/draftsCreate a draft {title, content, platforms, cover_image}
PUT/api/drafts/:idUpdate draft fields; send null to clear title or cover_image
DELETE/api/drafts/:idDelete draft and publish history
POST/api/publishQueue all draft platforms or a requested subset
GET/api/historyFetch publish log with URLs and error diagnostics
POST/api/history/:draftId/:platform/retryRetry a failed platform publish
POST/api/images/generateGenerate cover image from prompt via Workers AI
POST/mcpStreamable HTTP MCP JSON-RPC 2.0 endpoint