Publish Markdown as shareable web pages with a single command.
An open-source alternative to JotBird, self-hosted on Cloudflare's free tier.
- One command publishes Markdown to beautiful, permanent web pages
- Zero-config temporary publishing (
--temp, no login, auto-expires in 1h) - Local images auto-upload to cloud storage
username/slugnamespace for permanent URLs- Completely free for personal/small team use (Cloudflare Free Tier)
- Zero npm dependencies
- Node.js 18+
git clone https://github.com/PPsteven/openbird.git
cd openbird/cli
npm link
# Verify installation
openbird --version
# → openbird v0.1.0openbird loginYour browser will open a login page. Enter your credentials to get an API key, which is saved automatically.
No account? Use the demo account below, or deploy your own backend (see Part 2).
echo "# Hello OpenBird" > hello.md
openbird publish hello.md
# → ✨ Published → https://openbird.jhao.space/quiet-blue-lakeOpen the URL in your browser to see the rendered page.
Don't want to log in? Use
--tempfor a 1-hour temporary page:echo "# Quick Test" > /tmp/test.md openbird publish --temp /tmp/test.md # → ⚡ Published (temp, 1h) → https://openbird.jhao.space/warm-clear-seed
| Item | Value |
|---|---|
| Username | demo |
| Password | demo@123 |
| Backend URL | https://openbird.jhao.space |
Log in with the demo account to try all features. All documents are publicly visible — do not publish sensitive content.
Authenticate the CLI. The token is saved to ~/.config/openbird/credentials.
openbird loginYou can also provide the API key via environment variable (for CI/CD):
export OPENBIRD_API_KEY="ob_xxx"Publish or update a Markdown document.
# Publish a file
openbird publish my-doc.md
# Custom URL slug
openbird publish --slug my-custom-url my-doc.md
# Publish to namespace (permanent URL, slug auto-allocated)
openbird publish --namespace my-doc.md
# Namespace + custom slug
openbird publish --slug my-page --namespace my-doc.md
# Temporary publish (no login, auto-expires in 1 hour)
openbird publish --temp my-doc.md
# Publish from stdin
cat my-doc.md | openbird publish
# Supported file formats: .md .markdown .mdx .txt .text
openbird publish notes.txt| Flag | Description |
|---|---|
--slug <value> |
Custom URL slug (e.g. my-page, 3-60 chars, lowercase alphanumeric and hyphens) |
--namespace |
Publish to username/<slug> permanent URL, slug auto-allocated or specified with --slug |
--temp |
Temporary publish, no login required, auto-expires in 1 hour |
Output:
✨ Published → https://openbird.jhao.space/my-custom-url
Updating an existing document:
✓ Updated → https://openbird.jhao.space/my-custom-url
List all documents published by the current user.
openbird listOutput:
my-custom-url My Document Title
https://openbird.jhao.space/my-custom-url
ppsteven/my-page My Page
https://openbird.jhao.space/ppsteven/my-page
2 documents
Namespace documents are shown as username/slug.
Delete a published document.
# By filename (looks up from .openbird mapping)
openbird remove my-doc.md
# By slug directly
openbird remove my-custom-url
# Delete a namespaced document
openbird remove --namespace my-page
# Or pass username/slug directly
openbird remove ppsteven/my-pageOutput:
✓ Removed my-custom-url
| Variable | Description | Default |
|---|---|---|
OPENBIRD_API_URL |
Worker API URL | https://openbird.jhao.space (public instance) |
OPENBIRD_API_KEY |
API Key (takes precedence over credentials file) | None |
The API key is stored in ~/.config/openbird/credentials with permissions 0600. Managed automatically by openbird login.
# Manual setup
mkdir -p ~/.config/openbird
echo "ob_your_api_key" > ~/.config/openbird/credentials
chmod 600 ~/.config/openbird/credentialsWhen you publish a file, the CLI creates a .openbird file in the current directory to track filename-to-slug mappings:
# .openbird
my-doc.md = my-custom-url
about.md = ppsteven/my-page
Subsequent openbird publish my-doc.md calls will automatically update the same URL without needing --slug.
- Node.js 18+
- Cloudflare account (free sign-up)
- wrangler CLI 4.x+
npm install -g wrangler
wrangler logingit clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Configure environment variables
cp .env.example .env
# Edit .env, set your domain:
# Custom domain: OPENBIRD_DOMAIN=openbird.yourdomain.com
# workers.dev: OPENBIRD_DOMAIN=openbird.yoursubdomain.workers.dev
chmod +x deploy.sh
./deploy.shThe script automatically creates KV namespaces, R2 buckets, generates wrangler.toml, and deploys.
git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Create KV namespaces (note the output ids)
wrangler kv namespace create USERS
wrangler kv namespace create DOCS
# Create R2 buckets
wrangler r2 bucket create openbird-pages
wrangler r2 bucket create openbird-images
# Edit wrangler.toml, fill in the KV namespace ids above
# [[kv_namespaces]]
# binding = "USERS"
# id = "your-id"
# Deploy
wrangler deploy
# → Deployed "openbird" → https://openbird.your-subdomain.workers.devOptional: Bind a Custom Domain
After deployment, you can bind a custom domain:
- Cloudflare Dashboard → Workers & Pages → openbird → Settings → Domains & Routes
- Add a custom domain (e.g.
openbird.yourdomain.com)
export OPENBIRD_API_URL="https://openbird.your-subdomain.workers.dev"Set ADMIN_EMAIL and ADMIN_PASSWORD in .env before deployment. The admin account is auto-created on the first request.
Admins can create additional users with openbird register:
openbird register --email user@example.com --password "password" [--username custom-name]CLI → api.js → Worker /api/v1/*
↓
KV (USERS + DOCS index)
R2 (PAGES + IMAGES)
↓
Browser → Worker /:slug → R2 → HTML response
A single Cloudflare Worker handles everything: API, page serving, and image proxy.
| Storage | Purpose | Free Tier |
|---|---|---|
KV USERS |
User accounts, API key hashes, email index | 1 GB |
KV DOCS |
Document metadata (slug, title, expiry) | 1 GB |
R2 PAGES |
Rendered HTML pages | 10 GB |
R2 IMAGES |
User-uploaded images | 10 GB |
The Worker has a built-in zero-dependency Markdown renderer supporting:
- Headings (h1-h6)
- Bold, italic, inline code
- Links, images
- Unordered and ordered lists
- Blockquotes, horizontal rules
- Tables
- Fenced code blocks
Pages are returned as complete HTML documents with inline CSS, viewable directly in any browser.
All API endpoints require Authorization: Bearer ob_xxx header (except guest publish).
Admin only. Creates a new user account. Requires the admin's API key.
curl -X POST https://openbird.jhao.space/api/v1/register \
-H "Authorization: Bearer ob_admin_api_key" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"your-password","username":"optional-username"}'| Field | Type | Required | Description |
|---|---|---|---|
email |
string | yes | User email address |
password |
string | no | Auto-generated random password if omitted |
username |
string | no | Custom username, defaults to email local-part if omitted |
Response (201):
{
"userId": "user_a1b2c3d4e5f6",
"apiKey": "ob_xxx...",
"email": "user@example.com",
"username": "optional-username"
}Non-admin callers receive:
{
"error": "Registration is closed"
}Publish or update a document.
curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Authorization: Bearer ob_xxx" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello\n\nWorld","slug":"my-page"}'| Field | Type | Required | Description |
|---|---|---|---|
markdown |
string | yes | Markdown content (max 256KB) |
slug |
string | no | Custom URL slug, auto-generated if omitted |
namespaced |
boolean | no | Set to true to publish to username/slug (requires username) |
title |
string | no | Page title, extracted from first # Title if omitted |
Response (201 created / 200 updated):
{
"slug": "my-page",
"username": null,
"url": "https://openbird.jhao.space/my-page",
"title": "Hello",
"created": true
}No authentication required. Publishes a 1-hour temporary page. Must pass temp: true.
curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello Guest","temp":true}'| Field | Type | Required | Description |
|---|---|---|---|
markdown |
string | yes | Markdown content (max 256KB) |
temp |
boolean | yes | Must be true, otherwise returns 401 |
slug |
string | no | Custom slug, auto-generated if omitted |
title |
string | no | Page title |
Response (201):
{
"slug": "warm-clear-seed",
"url": "https://openbird.jhao.space/warm-clear-seed",
"title": "Hello Guest",
"expiresAt": "2026-07-04T11:00:00.000Z",
"ttlMinutes": 60,
"guest": true
}List all documents for the current user.
curl https://openbird.jhao.space/api/v1/documents \
-H "Authorization: Bearer ob_xxx"Response (200):
{
"documents": [
{
"slug": "my-page",
"username": null,
"title": "Hello",
"url": "https://openbird.jhao.space/my-page",
"source": "api",
"updatedAt": "2026-07-04T10:00:00.000Z",
"expiresAt": null
}
]
}Results are sorted by updatedAt descending. Namespace documents have a non-null username field.
Delete a document.
# Delete a regular document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page" \
-H "Authorization: Bearer ob_xxx"
# Delete a namespaced document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page&namespaced=true" \
-H "Authorization: Bearer ob_xxx"Response (200):
{ "ok": true }Upload an image.
curl -X POST https://openbird.jhao.space/api/v1/upload-image \
-H "Authorization: Bearer ob_xxx" \
-F "file=@photo.png"Supported formats: png, jpeg, gif, webp, svg. Max 10 MB.
Response (200):
{
"url": "https://openbird.jhao.space/images/user_abc123/a1b2c3d4.png"
}View a published page.
curl https://openbird.jhao.space/my-page
# → HTML documentView a namespaced page.
curl https://openbird.jhao.space/ppsteven/my-page
# → HTML document# Start local Worker (with KV + R2 simulation)
cd worker
wrangler dev
# → Ready on http://localhost:8787
# In another terminal, test
curl -X POST http://localhost:8787/api/v1/register \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"123456"}'Point the CLI to your local Worker:
cd cli
export OPENBIRD_API_URL="http://localhost:8787"
node src/cli.js publish test.mdpagebird/
├── AGENTS.md # AI Agent rules & conventions
├── README.md # Project documentation (English)
├── README.zh.md # Project documentation (Chinese)
├── docs/ # Design documents
│ ├── D0-reference.md # JotBird reverse engineering reference
│ ├── D1-worker-core.md # Worker backend spec
│ ├── D2-cli-core.md # CLI core spec
│ ├── D3-images.md # Image upload spec
│ ├── D4-namespace.md # Namespace spec
│ ├── D5-deployment.md # Deployment verification spec
│ ├── D6-documentation.md # Documentation spec
│ ├── architecture.md # Architecture & decisions
│ ├── status.md # Progress tracking
│ └── troubleshoot.md # Troubleshooting guide
├── worker/ # Cloudflare Worker
│ ├── src/index.js # Worker main program
│ ├── wrangler.toml # Worker configuration
│ └── package.json
└── cli/ # CLI tool
├── src/
│ ├── cli.js # CLI entry point
│ ├── api.js # API client
│ ├── config.js # Configuration management
│ ├── files.js # File type validation
│ ├── images.js # Image upload & rewriting
│ ├── login.js # Login flow
│ └── mapping.js # .openbird mapping management
└── package.json
| Layer | Technology |
|---|---|
| CLI | Node.js 18+ ESM, zero dependencies |
| Worker | Cloudflare Workers (V8 isolate) |
| Page Storage | Cloudflare R2 |
| Index Storage | Cloudflare KV |
| Image Storage | Cloudflare R2 |
| Deployment | wrangler CLI |
MIT