Repository files navigation

postflow

⚠️Disclaimer: postflow is in active development. It is not tested enough yet to guarantee correct behavior in all scenarios. Use at your own risk.

postflow is a lightweight social publishing service with:

  • Web UI
  • HTTP API
  • MCP endpoint (Streamable HTTP)
  • CLI (postflow)

This README is a basic setup guide.

1) Local Setup (5 minutes)

Requirements

  • Go 1.26.5+
  • Optional: Homebrew (for installing CLI binary)

Start locally

git clone https://github.com/antoniolg/postflow.git
cd postflow
cp .env.example .env

Generate required secrets:

# 32-byte base64 key (required)
openssl rand -base64 32
# API token (recommended)
openssl rand -hex 32

Put those values in .env:

POSTFLOW_MASTER_KEY=<base64-from-openssl>API_TOKEN=<hex-token>PUBLIC_BASE_URL=http://localhost:8080OWNER_EMAIL=owner@example.comOWNER_PASSWORD_HASH=<bcrypt-hash>POSTFLOW_DRIVER=mock

Generate OWNER_PASSWORD_HASH with the helper script in this repo:

go run ./scripts/hash-password.go 'replace-with-your-password'

If you store it in a local .env, quote the value because bcrypt hashes contain $:

OWNER_PASSWORD_HASH='$2a$10$...'

Run:

go run ./cmd/postflow-server

Open:

  • UI: http://localhost:8080
  • MCP: http://localhost:8080/mcp

2) Environment Variables (and where to get them)

Use .env.example as template. These are the key ones:

Core (recommended in all setups)

VariableRequiredWhere it comes from
POSTFLOW_MASTER_KEYYesGenerate locally: openssl rand -base64 32
API_TOKENRecommendedGenerate locally (random token), kept for API/MCP auth for CLI, Codex, Claude, and other legacy clients
OWNER_EMAILRecommended for UI/ChatGPTOwner email for the single-user local login
OWNER_PASSWORD_HASHRecommended for UI/ChatGPTBcrypt hash for the owner password
PUBLIC_BASE_URLYes for OAuth and Instagram media URLsYour app URL (http://localhost:8080 locally, your public HTTPS domain in prod)
UI_BASIC_USER / UI_BASIC_PASSTemporary compatibility onlyOptional legacy Basic Auth fallback for the UI

Storage/runtime

VariableDefaultNotes
PORT8080HTTP port
DATABASE_PATHpostflow.dbSQLite DB path
DATA_DIRdataUploaded media path

Network credentials (only if you use that network)

NetworkVariablesWhere to get them
XX_CLIENT_ID, X_CLIENT_SECRETX Developer Portal OAuth 2.0 app credentials for account connection
LinkedInLINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRETLinkedIn Developer app with member posting enabled. Company page connections additionally require organization posting/admin scopes.
Facebook/InstagramMETA_APP_ID, META_APP_SECRETMeta Developers app

Important:

  • If you want real publishing, set POSTFLOW_DRIVER=live.
  • For local testing without real publishing, keep POSTFLOW_DRIVER=mock.
  • OAuth account connection is available for X, LinkedIn, Facebook, and Instagram.
  • LinkedIn OAuth connects personal profiles by default. Use the LinkedIn organization connection action, or account_kind=organization on /oauth/linkedin/start, to request company page scopes.
  • LinkedIn root posts with a first http(s) link and no attached media are published as article posts at publish time so PostFlow can send explicit unfurl metadata. If media is attached, media wins and link unfurl is skipped.
  • In the web UI, if an OAuth provider returns multiple accounts, PostFlow shows a selection step before saving them.
  • OAuth accounts in error show a reauthorize action. Recovery is bound to the existing account identity, and credentials are replaced only when the provider returns the same account.
  • Publish failure emails are configured from Settings, the CLI, or MCP. SMTP passwords are stored encrypted with POSTFLOW_MASTER_KEY.
  • In production (Coolify), set secrets in the platform UI, not in committed files.
  • In Coolify, mark OWNER_PASSWORD_HASH as a literal/secret value so $ is not interpolated.

3) MCP Setup (for LLMs)

Endpoint:

http://localhost:8080/mcp

For Codex, Claude, CLI, and other legacy clients, if API_TOKEN is set, send:

Authorization: Bearer <API_TOKEN>

For ChatGPT / remote MCP clients with OAuth:

  • Authorization metadata: http://localhost:8080/.well-known/oauth-authorization-server
  • Protected resource metadata: http://localhost:8080/.well-known/oauth-protected-resource
  • The login page is http://localhost:8080/login
  • Dynamic client registration is available at POST /oauth/register
  • PostFlow allows MCP discovery requests without auth (initialize, notifications/initialized, ping, and tools/list) so ChatGPT can complete the handshake cleanly.
  • Actual MCP tool execution (tools/call) remains protected and requires OAuth bearer auth (or the legacy API_TOKEN flow for non-OAuth clients).

Main MCP tools available:

  • postflow_health
  • postflow_list_schedule
  • postflow_list_drafts
  • postflow_list_accounts
  • postflow_create_static_account
  • postflow_connect_account
  • postflow_disconnect_account
  • postflow_reauthorize_account
  • postflow_set_x_premium
  • postflow_delete_account
  • postflow_list_failed
  • postflow_create_post
  • postflow_cancel_post
  • postflow_schedule_post
  • postflow_edit_post
  • postflow_delete_post
  • postflow_validate_post
  • postflow_upload_media
  • postflow_list_media
  • postflow_delete_media
  • postflow_requeue_failed
  • postflow_delete_failed
  • postflow_set_timezone

Thread payload support (same shape in API/MCP/CLI):

  • segments: [{ "text": "...", "media_ids": ["med_x"] }]
  • If segments is present, step 1 is the root post and steps 2..N are follow-ups.
  • Publishing semantics: X follow-ups are chained as replies; other supported thread platforms publish follow-ups as comments on the root post.
  • Backward compatibility is preserved for legacy text + media_ids.
  • postflow_edit_post accepts optional media_ids to replace media on editable posts ([] clears all media).
  • Editing without intent and without scheduled_at preserves the current scheduling state.

Codex CLI

codex mcp add postflow --url http://localhost:8080/mcp

~/.codex/config.toml example:

[mcp_servers.postflow]
url = "http://localhost:8080/mcp"bearer_token_env_var = "POSTFLOW_API_TOKEN"

Then:

export POSTFLOW_API_TOKEN="<same-value-as-API_TOKEN>"

Claude Code

claude mcp add -t http postflow http://localhost:8080/mcp --header "Authorization: Bearer <API_TOKEN>"

Tip: in the app UI (settings) you can copy ready-to-use MCP snippets for Claude and Codex.


4) CLI Setup (postflow)

Option A: Homebrew (recommended)

brew tap antoniolg/tap
brew install antoniolg/tap/postflow
postflow --help

Option B: Run from source

go run ./cmd/postflow --help

Configure CLI env:

export POSTFLOW_BASE_URL="http://localhost:8080"export POSTFLOW_API_TOKEN="<API_TOKEN>"

Common commands:

postflow health
postflow schedule list --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow schedule list --view posts --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow drafts list --limit 20
postflow posts validate --account-id acc_xxx --text "hello"
postflow posts validate --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1"}]'
postflow posts create --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1","media_ids":["med_x"]}]' --scheduled-at 2026-03-01T10:00:00Z
postflow posts schedule --id pst_xxx --scheduled-at 2026-03-01T10:00:00Z
postflow posts edit --id pst_xxx --text "copy updated" --intent schedule --scheduled-at 2026-03-01T10:30:00Z
postflow posts edit --id pst_xxx --segments-json '[{"text":"root updated"},{"text":"reply updated"}]'
postflow posts edit --id pst_xxx --text "copy + media" --replace-media --media-id med_a --media-id med_b
postflow posts delete --id pst_xxx
postflow posts cancel --id pst_xxx
postflow accounts list
postflow accounts reauthorize --id acc_xxx
postflow settings set-timezone --timezone Europe/Madrid
postflow settings set-smtp --host smtp.sendgrid.net --port 587 --username apikey --password "$SMTP_PASSWORD" --from postflow@example.com --to owner@example.com
postflow media list --limit 20

--text and --segments-json are mutually exclusive on posts create, posts validate, and posts edit.

schedule list returns grouped publications by default. Use --view posts to inspect the raw per-post/thread rows.


5) Deploy (Coolify + GHCR image)

You can deploy from prebuilt image (no build on server):

ghcr.io/antoniolg/postflow:latest

or pinned:

ghcr.io/antoniolg/postflow:vX.Y.Z

Full production runbook:


6) Troubleshooting

  • 401 unauthorized:
    • check API_TOKEN
    • check Authorization: Bearer ... in MCP/API clients
  • OAuth callback errors:
    • verify PUBLIC_BASE_URL matches your real public domain
    • for X, verify X_CLIENT_ID is set and the callback URL is registered in the X app settings
  • Instagram media create errors (code=9004, error_subcode=2207052):
    • verify PUBLIC_BASE_URL is public/reachable from the internet (not localhost in production)
    • for image posts, upload JPEG or PNG (.jpg / .jpeg / .png)
    • for video posts, use MP4 or MOV
    • media uploads are capped at 512 MiB
  • CLI auth errors:
    • verify POSTFLOW_API_TOKEN matches server API_TOKEN

7) Additional Docs

About

No description, website, or topics provided.

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

postflow

⚠️Disclaimer: postflow is in active development. It is not tested enough yet to guarantee correct behavior in all scenarios. Use at your own risk.

postflow is a lightweight social publishing service with:

  • Web UI
  • HTTP API
  • MCP endpoint (Streamable HTTP)
  • CLI (postflow)

This README is a basic setup guide.

1) Local Setup (5 minutes)

Requirements

  • Go 1.26.5+
  • Optional: Homebrew (for installing CLI binary)

Start locally

git clone https://github.com/antoniolg/postflow.git
cd postflow
cp .env.example .env

Generate required secrets:

# 32-byte base64 key (required)
openssl rand -base64 32
# API token (recommended)
openssl rand -hex 32

Put those values in .env:

POSTFLOW_MASTER_KEY=<base64-from-openssl>API_TOKEN=<hex-token>PUBLIC_BASE_URL=http://localhost:8080OWNER_EMAIL=owner@example.comOWNER_PASSWORD_HASH=<bcrypt-hash>POSTFLOW_DRIVER=mock

Generate OWNER_PASSWORD_HASH with the helper script in this repo:

go run ./scripts/hash-password.go 'replace-with-your-password'

If you store it in a local .env, quote the value because bcrypt hashes contain $:

OWNER_PASSWORD_HASH='$2a$10$...'

Run:

go run ./cmd/postflow-server

Open:

  • UI: http://localhost:8080
  • MCP: http://localhost:8080/mcp

2) Environment Variables (and where to get them)

Use .env.example as template. These are the key ones:

Core (recommended in all setups)

VariableRequiredWhere it comes from
POSTFLOW_MASTER_KEYYesGenerate locally: openssl rand -base64 32
API_TOKENRecommendedGenerate locally (random token), kept for API/MCP auth for CLI, Codex, Claude, and other legacy clients
OWNER_EMAILRecommended for UI/ChatGPTOwner email for the single-user local login
OWNER_PASSWORD_HASHRecommended for UI/ChatGPTBcrypt hash for the owner password
PUBLIC_BASE_URLYes for OAuth and Instagram media URLsYour app URL (http://localhost:8080 locally, your public HTTPS domain in prod)
UI_BASIC_USER / UI_BASIC_PASSTemporary compatibility onlyOptional legacy Basic Auth fallback for the UI

Storage/runtime

VariableDefaultNotes
PORT8080HTTP port
DATABASE_PATHpostflow.dbSQLite DB path
DATA_DIRdataUploaded media path

Network credentials (only if you use that network)

NetworkVariablesWhere to get them
XX_CLIENT_ID, X_CLIENT_SECRETX Developer Portal OAuth 2.0 app credentials for account connection
LinkedInLINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRETLinkedIn Developer app with member posting enabled. Company page connections additionally require organization posting/admin scopes.
Facebook/InstagramMETA_APP_ID, META_APP_SECRETMeta Developers app

Important:

  • If you want real publishing, set POSTFLOW_DRIVER=live.
  • For local testing without real publishing, keep POSTFLOW_DRIVER=mock.
  • OAuth account connection is available for X, LinkedIn, Facebook, and Instagram.
  • LinkedIn OAuth connects personal profiles by default. Use the LinkedIn organization connection action, or account_kind=organization on /oauth/linkedin/start, to request company page scopes.
  • LinkedIn root posts with a first http(s) link and no attached media are published as article posts at publish time so PostFlow can send explicit unfurl metadata. If media is attached, media wins and link unfurl is skipped.
  • In the web UI, if an OAuth provider returns multiple accounts, PostFlow shows a selection step before saving them.
  • OAuth accounts in error show a reauthorize action. Recovery is bound to the existing account identity, and credentials are replaced only when the provider returns the same account.
  • Publish failure emails are configured from Settings, the CLI, or MCP. SMTP passwords are stored encrypted with POSTFLOW_MASTER_KEY.
  • In production (Coolify), set secrets in the platform UI, not in committed files.
  • In Coolify, mark OWNER_PASSWORD_HASH as a literal/secret value so $ is not interpolated.

3) MCP Setup (for LLMs)

Endpoint:

http://localhost:8080/mcp

For Codex, Claude, CLI, and other legacy clients, if API_TOKEN is set, send:

Authorization: Bearer <API_TOKEN>

For ChatGPT / remote MCP clients with OAuth:

  • Authorization metadata: http://localhost:8080/.well-known/oauth-authorization-server
  • Protected resource metadata: http://localhost:8080/.well-known/oauth-protected-resource
  • The login page is http://localhost:8080/login
  • Dynamic client registration is available at POST /oauth/register
  • PostFlow allows MCP discovery requests without auth (initialize, notifications/initialized, ping, and tools/list) so ChatGPT can complete the handshake cleanly.
  • Actual MCP tool execution (tools/call) remains protected and requires OAuth bearer auth (or the legacy API_TOKEN flow for non-OAuth clients).

Main MCP tools available:

  • postflow_health
  • postflow_list_schedule
  • postflow_list_drafts
  • postflow_list_accounts
  • postflow_create_static_account
  • postflow_connect_account
  • postflow_disconnect_account
  • postflow_reauthorize_account
  • postflow_set_x_premium
  • postflow_delete_account
  • postflow_list_failed
  • postflow_create_post
  • postflow_cancel_post
  • postflow_schedule_post
  • postflow_edit_post
  • postflow_delete_post
  • postflow_validate_post
  • postflow_upload_media
  • postflow_list_media
  • postflow_delete_media
  • postflow_requeue_failed
  • postflow_delete_failed
  • postflow_set_timezone

Thread payload support (same shape in API/MCP/CLI):

  • segments: [{ "text": "...", "media_ids": ["med_x"] }]
  • If segments is present, step 1 is the root post and steps 2..N are follow-ups.
  • Publishing semantics: X follow-ups are chained as replies; other supported thread platforms publish follow-ups as comments on the root post.
  • Backward compatibility is preserved for legacy text + media_ids.
  • postflow_edit_post accepts optional media_ids to replace media on editable posts ([] clears all media).
  • Editing without intent and without scheduled_at preserves the current scheduling state.

Codex CLI

codex mcp add postflow --url http://localhost:8080/mcp

~/.codex/config.toml example:

[mcp_servers.postflow]
url = "http://localhost:8080/mcp"bearer_token_env_var = "POSTFLOW_API_TOKEN"

Then:

export POSTFLOW_API_TOKEN="<same-value-as-API_TOKEN>"

Claude Code

claude mcp add -t http postflow http://localhost:8080/mcp --header "Authorization: Bearer <API_TOKEN>"

Tip: in the app UI (settings) you can copy ready-to-use MCP snippets for Claude and Codex.


4) CLI Setup (postflow)

Option A: Homebrew (recommended)

brew tap antoniolg/tap
brew install antoniolg/tap/postflow
postflow --help

Option B: Run from source

go run ./cmd/postflow --help

Configure CLI env:

export POSTFLOW_BASE_URL="http://localhost:8080"export POSTFLOW_API_TOKEN="<API_TOKEN>"

Common commands:

postflow health
postflow schedule list --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow schedule list --view posts --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow drafts list --limit 20
postflow posts validate --account-id acc_xxx --text "hello"
postflow posts validate --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1"}]'
postflow posts create --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1","media_ids":["med_x"]}]' --scheduled-at 2026-03-01T10:00:00Z
postflow posts schedule --id pst_xxx --scheduled-at 2026-03-01T10:00:00Z
postflow posts edit --id pst_xxx --text "copy updated" --intent schedule --scheduled-at 2026-03-01T10:30:00Z
postflow posts edit --id pst_xxx --segments-json '[{"text":"root updated"},{"text":"reply updated"}]'
postflow posts edit --id pst_xxx --text "copy + media" --replace-media --media-id med_a --media-id med_b
postflow posts delete --id pst_xxx
postflow posts cancel --id pst_xxx
postflow accounts list
postflow accounts reauthorize --id acc_xxx
postflow settings set-timezone --timezone Europe/Madrid
postflow settings set-smtp --host smtp.sendgrid.net --port 587 --username apikey --password "$SMTP_PASSWORD" --from postflow@example.com --to owner@example.com
postflow media list --limit 20

--text and --segments-json are mutually exclusive on posts create, posts validate, and posts edit.

schedule list returns grouped publications by default. Use --view posts to inspect the raw per-post/thread rows.


5) Deploy (Coolify + GHCR image)

You can deploy from prebuilt image (no build on server):

ghcr.io/antoniolg/postflow:latest

or pinned:

ghcr.io/antoniolg/postflow:vX.Y.Z

Full production runbook:


6) Troubleshooting

  • 401 unauthorized:
    • check API_TOKEN
    • check Authorization: Bearer ... in MCP/API clients
  • OAuth callback errors:
    • verify PUBLIC_BASE_URL matches your real public domain
    • for X, verify X_CLIENT_ID is set and the callback URL is registered in the X app settings
  • Instagram media create errors (code=9004, error_subcode=2207052):
    • verify PUBLIC_BASE_URL is public/reachable from the internet (not localhost in production)
    • for image posts, upload JPEG or PNG (.jpg / .jpeg / .png)
    • for video posts, use MP4 or MOV
    • media uploads are capped at 512 MiB
  • CLI auth errors:
    • verify POSTFLOW_API_TOKEN matches server API_TOKEN

7) Additional Docs

About

No description, website, or topics provided.

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

postflow

⚠️Disclaimer: postflow is in active development. It is not tested enough yet to guarantee correct behavior in all scenarios. Use at your own risk.

postflow is a lightweight social publishing service with:

  • Web UI
  • HTTP API
  • MCP endpoint (Streamable HTTP)
  • CLI (postflow)

This README is a basic setup guide.

1) Local Setup (5 minutes)

Requirements

  • Go 1.26.5+
  • Optional: Homebrew (for installing CLI binary)

Start locally

git clone https://github.com/antoniolg/postflow.git
cd postflow
cp .env.example .env

Generate required secrets:

# 32-byte base64 key (required)
openssl rand -base64 32
# API token (recommended)
openssl rand -hex 32

Put those values in .env:

POSTFLOW_MASTER_KEY=<base64-from-openssl>API_TOKEN=<hex-token>PUBLIC_BASE_URL=http://localhost:8080OWNER_EMAIL=owner@example.comOWNER_PASSWORD_HASH=<bcrypt-hash>POSTFLOW_DRIVER=mock

Generate OWNER_PASSWORD_HASH with the helper script in this repo:

go run ./scripts/hash-password.go 'replace-with-your-password'

If you store it in a local .env, quote the value because bcrypt hashes contain $:

OWNER_PASSWORD_HASH='$2a$10$...'

Run:

go run ./cmd/postflow-server

Open:

  • UI: http://localhost:8080
  • MCP: http://localhost:8080/mcp

2) Environment Variables (and where to get them)

Use .env.example as template. These are the key ones:

Core (recommended in all setups)

VariableRequiredWhere it comes from
POSTFLOW_MASTER_KEYYesGenerate locally: openssl rand -base64 32
API_TOKENRecommendedGenerate locally (random token), kept for API/MCP auth for CLI, Codex, Claude, and other legacy clients
OWNER_EMAILRecommended for UI/ChatGPTOwner email for the single-user local login
OWNER_PASSWORD_HASHRecommended for UI/ChatGPTBcrypt hash for the owner password
PUBLIC_BASE_URLYes for OAuth and Instagram media URLsYour app URL (http://localhost:8080 locally, your public HTTPS domain in prod)
UI_BASIC_USER / UI_BASIC_PASSTemporary compatibility onlyOptional legacy Basic Auth fallback for the UI

Storage/runtime

VariableDefaultNotes
PORT8080HTTP port
DATABASE_PATHpostflow.dbSQLite DB path
DATA_DIRdataUploaded media path

Network credentials (only if you use that network)

NetworkVariablesWhere to get them
XX_CLIENT_ID, X_CLIENT_SECRETX Developer Portal OAuth 2.0 app credentials for account connection
LinkedInLINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRETLinkedIn Developer app with member posting enabled. Company page connections additionally require organization posting/admin scopes.
Facebook/InstagramMETA_APP_ID, META_APP_SECRETMeta Developers app

Important:

  • If you want real publishing, set POSTFLOW_DRIVER=live.
  • For local testing without real publishing, keep POSTFLOW_DRIVER=mock.
  • OAuth account connection is available for X, LinkedIn, Facebook, and Instagram.
  • LinkedIn OAuth connects personal profiles by default. Use the LinkedIn organization connection action, or account_kind=organization on /oauth/linkedin/start, to request company page scopes.
  • LinkedIn root posts with a first http(s) link and no attached media are published as article posts at publish time so PostFlow can send explicit unfurl metadata. If media is attached, media wins and link unfurl is skipped.
  • In the web UI, if an OAuth provider returns multiple accounts, PostFlow shows a selection step before saving them.
  • OAuth accounts in error show a reauthorize action. Recovery is bound to the existing account identity, and credentials are replaced only when the provider returns the same account.
  • Publish failure emails are configured from Settings, the CLI, or MCP. SMTP passwords are stored encrypted with POSTFLOW_MASTER_KEY.
  • In production (Coolify), set secrets in the platform UI, not in committed files.
  • In Coolify, mark OWNER_PASSWORD_HASH as a literal/secret value so $ is not interpolated.

3) MCP Setup (for LLMs)

Endpoint:

http://localhost:8080/mcp

For Codex, Claude, CLI, and other legacy clients, if API_TOKEN is set, send:

Authorization: Bearer <API_TOKEN>

For ChatGPT / remote MCP clients with OAuth:

  • Authorization metadata: http://localhost:8080/.well-known/oauth-authorization-server
  • Protected resource metadata: http://localhost:8080/.well-known/oauth-protected-resource
  • The login page is http://localhost:8080/login
  • Dynamic client registration is available at POST /oauth/register
  • PostFlow allows MCP discovery requests without auth (initialize, notifications/initialized, ping, and tools/list) so ChatGPT can complete the handshake cleanly.
  • Actual MCP tool execution (tools/call) remains protected and requires OAuth bearer auth (or the legacy API_TOKEN flow for non-OAuth clients).

Main MCP tools available:

  • postflow_health
  • postflow_list_schedule
  • postflow_list_drafts
  • postflow_list_accounts
  • postflow_create_static_account
  • postflow_connect_account
  • postflow_disconnect_account
  • postflow_reauthorize_account
  • postflow_set_x_premium
  • postflow_delete_account
  • postflow_list_failed
  • postflow_create_post
  • postflow_cancel_post
  • postflow_schedule_post
  • postflow_edit_post
  • postflow_delete_post
  • postflow_validate_post
  • postflow_upload_media
  • postflow_list_media
  • postflow_delete_media
  • postflow_requeue_failed
  • postflow_delete_failed
  • postflow_set_timezone

Thread payload support (same shape in API/MCP/CLI):

  • segments: [{ "text": "...", "media_ids": ["med_x"] }]
  • If segments is present, step 1 is the root post and steps 2..N are follow-ups.
  • Publishing semantics: X follow-ups are chained as replies; other supported thread platforms publish follow-ups as comments on the root post.
  • Backward compatibility is preserved for legacy text + media_ids.
  • postflow_edit_post accepts optional media_ids to replace media on editable posts ([] clears all media).
  • Editing without intent and without scheduled_at preserves the current scheduling state.

Codex CLI

codex mcp add postflow --url http://localhost:8080/mcp

~/.codex/config.toml example:

[mcp_servers.postflow]
url = "http://localhost:8080/mcp"bearer_token_env_var = "POSTFLOW_API_TOKEN"

Then:

export POSTFLOW_API_TOKEN="<same-value-as-API_TOKEN>"

Claude Code

claude mcp add -t http postflow http://localhost:8080/mcp --header "Authorization: Bearer <API_TOKEN>"

Tip: in the app UI (settings) you can copy ready-to-use MCP snippets for Claude and Codex.


4) CLI Setup (postflow)

Option A: Homebrew (recommended)

brew tap antoniolg/tap
brew install antoniolg/tap/postflow
postflow --help

Option B: Run from source

go run ./cmd/postflow --help

Configure CLI env:

export POSTFLOW_BASE_URL="http://localhost:8080"export POSTFLOW_API_TOKEN="<API_TOKEN>"

Common commands:

postflow health
postflow schedule list --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow schedule list --view posts --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow drafts list --limit 20
postflow posts validate --account-id acc_xxx --text "hello"
postflow posts validate --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1"}]'
postflow posts create --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1","media_ids":["med_x"]}]' --scheduled-at 2026-03-01T10:00:00Z
postflow posts schedule --id pst_xxx --scheduled-at 2026-03-01T10:00:00Z
postflow posts edit --id pst_xxx --text "copy updated" --intent schedule --scheduled-at 2026-03-01T10:30:00Z
postflow posts edit --id pst_xxx --segments-json '[{"text":"root updated"},{"text":"reply updated"}]'
postflow posts edit --id pst_xxx --text "copy + media" --replace-media --media-id med_a --media-id med_b
postflow posts delete --id pst_xxx
postflow posts cancel --id pst_xxx
postflow accounts list
postflow accounts reauthorize --id acc_xxx
postflow settings set-timezone --timezone Europe/Madrid
postflow settings set-smtp --host smtp.sendgrid.net --port 587 --username apikey --password "$SMTP_PASSWORD" --from postflow@example.com --to owner@example.com
postflow media list --limit 20

--text and --segments-json are mutually exclusive on posts create, posts validate, and posts edit.

schedule list returns grouped publications by default. Use --view posts to inspect the raw per-post/thread rows.


5) Deploy (Coolify + GHCR image)

You can deploy from prebuilt image (no build on server):

ghcr.io/antoniolg/postflow:latest

or pinned:

ghcr.io/antoniolg/postflow:vX.Y.Z

Full production runbook:


6) Troubleshooting

  • 401 unauthorized:
    • check API_TOKEN
    • check Authorization: Bearer ... in MCP/API clients
  • OAuth callback errors:
    • verify PUBLIC_BASE_URL matches your real public domain
    • for X, verify X_CLIENT_ID is set and the callback URL is registered in the X app settings
  • Instagram media create errors (code=9004, error_subcode=2207052):
    • verify PUBLIC_BASE_URL is public/reachable from the internet (not localhost in production)
    • for image posts, upload JPEG or PNG (.jpg / .jpeg / .png)
    • for video posts, use MP4 or MOV
    • media uploads are capped at 512 MiB
  • CLI auth errors:
    • verify POSTFLOW_API_TOKEN matches server API_TOKEN

7) Additional Docs

About

No description, website, or topics provided.

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

postflow

⚠️Disclaimer: postflow is in active development. It is not tested enough yet to guarantee correct behavior in all scenarios. Use at your own risk.

postflow is a lightweight social publishing service with:

  • Web UI
  • HTTP API
  • MCP endpoint (Streamable HTTP)
  • CLI (postflow)

This README is a basic setup guide.

1) Local Setup (5 minutes)

Requirements

  • Go 1.26.5+
  • Optional: Homebrew (for installing CLI binary)

Start locally

git clone https://github.com/antoniolg/postflow.git
cd postflow
cp .env.example .env

Generate required secrets:

# 32-byte base64 key (required)
openssl rand -base64 32
# API token (recommended)
openssl rand -hex 32

Put those values in .env:

POSTFLOW_MASTER_KEY=<base64-from-openssl>API_TOKEN=<hex-token>PUBLIC_BASE_URL=http://localhost:8080OWNER_EMAIL=owner@example.comOWNER_PASSWORD_HASH=<bcrypt-hash>POSTFLOW_DRIVER=mock

Generate OWNER_PASSWORD_HASH with the helper script in this repo:

go run ./scripts/hash-password.go 'replace-with-your-password'

If you store it in a local .env, quote the value because bcrypt hashes contain $:

OWNER_PASSWORD_HASH='$2a$10$...'

Run:

go run ./cmd/postflow-server

Open:

  • UI: http://localhost:8080
  • MCP: http://localhost:8080/mcp

2) Environment Variables (and where to get them)

Use .env.example as template. These are the key ones:

Core (recommended in all setups)

VariableRequiredWhere it comes from
POSTFLOW_MASTER_KEYYesGenerate locally: openssl rand -base64 32
API_TOKENRecommendedGenerate locally (random token), kept for API/MCP auth for CLI, Codex, Claude, and other legacy clients
OWNER_EMAILRecommended for UI/ChatGPTOwner email for the single-user local login
OWNER_PASSWORD_HASHRecommended for UI/ChatGPTBcrypt hash for the owner password
PUBLIC_BASE_URLYes for OAuth and Instagram media URLsYour app URL (http://localhost:8080 locally, your public HTTPS domain in prod)
UI_BASIC_USER / UI_BASIC_PASSTemporary compatibility onlyOptional legacy Basic Auth fallback for the UI

Storage/runtime

VariableDefaultNotes
PORT8080HTTP port
DATABASE_PATHpostflow.dbSQLite DB path
DATA_DIRdataUploaded media path

Network credentials (only if you use that network)

NetworkVariablesWhere to get them
XX_CLIENT_ID, X_CLIENT_SECRETX Developer Portal OAuth 2.0 app credentials for account connection
LinkedInLINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRETLinkedIn Developer app with member posting enabled. Company page connections additionally require organization posting/admin scopes.
Facebook/InstagramMETA_APP_ID, META_APP_SECRETMeta Developers app

Important:

  • If you want real publishing, set POSTFLOW_DRIVER=live.
  • For local testing without real publishing, keep POSTFLOW_DRIVER=mock.
  • OAuth account connection is available for X, LinkedIn, Facebook, and Instagram.
  • LinkedIn OAuth connects personal profiles by default. Use the LinkedIn organization connection action, or account_kind=organization on /oauth/linkedin/start, to request company page scopes.
  • LinkedIn root posts with a first http(s) link and no attached media are published as article posts at publish time so PostFlow can send explicit unfurl metadata. If media is attached, media wins and link unfurl is skipped.
  • In the web UI, if an OAuth provider returns multiple accounts, PostFlow shows a selection step before saving them.
  • OAuth accounts in error show a reauthorize action. Recovery is bound to the existing account identity, and credentials are replaced only when the provider returns the same account.
  • Publish failure emails are configured from Settings, the CLI, or MCP. SMTP passwords are stored encrypted with POSTFLOW_MASTER_KEY.
  • In production (Coolify), set secrets in the platform UI, not in committed files.
  • In Coolify, mark OWNER_PASSWORD_HASH as a literal/secret value so $ is not interpolated.

3) MCP Setup (for LLMs)

Endpoint:

http://localhost:8080/mcp

For Codex, Claude, CLI, and other legacy clients, if API_TOKEN is set, send:

Authorization: Bearer <API_TOKEN>

For ChatGPT / remote MCP clients with OAuth:

  • Authorization metadata: http://localhost:8080/.well-known/oauth-authorization-server
  • Protected resource metadata: http://localhost:8080/.well-known/oauth-protected-resource
  • The login page is http://localhost:8080/login
  • Dynamic client registration is available at POST /oauth/register
  • PostFlow allows MCP discovery requests without auth (initialize, notifications/initialized, ping, and tools/list) so ChatGPT can complete the handshake cleanly.
  • Actual MCP tool execution (tools/call) remains protected and requires OAuth bearer auth (or the legacy API_TOKEN flow for non-OAuth clients).

Main MCP tools available:

  • postflow_health
  • postflow_list_schedule
  • postflow_list_drafts
  • postflow_list_accounts
  • postflow_create_static_account
  • postflow_connect_account
  • postflow_disconnect_account
  • postflow_reauthorize_account
  • postflow_set_x_premium
  • postflow_delete_account
  • postflow_list_failed
  • postflow_create_post
  • postflow_cancel_post
  • postflow_schedule_post
  • postflow_edit_post
  • postflow_delete_post
  • postflow_validate_post
  • postflow_upload_media
  • postflow_list_media
  • postflow_delete_media
  • postflow_requeue_failed
  • postflow_delete_failed
  • postflow_set_timezone

Thread payload support (same shape in API/MCP/CLI):

  • segments: [{ "text": "...", "media_ids": ["med_x"] }]
  • If segments is present, step 1 is the root post and steps 2..N are follow-ups.
  • Publishing semantics: X follow-ups are chained as replies; other supported thread platforms publish follow-ups as comments on the root post.
  • Backward compatibility is preserved for legacy text + media_ids.
  • postflow_edit_post accepts optional media_ids to replace media on editable posts ([] clears all media).
  • Editing without intent and without scheduled_at preserves the current scheduling state.

Codex CLI

codex mcp add postflow --url http://localhost:8080/mcp

~/.codex/config.toml example:

[mcp_servers.postflow]
url = "http://localhost:8080/mcp"bearer_token_env_var = "POSTFLOW_API_TOKEN"

Then:

export POSTFLOW_API_TOKEN="<same-value-as-API_TOKEN>"

Claude Code

claude mcp add -t http postflow http://localhost:8080/mcp --header "Authorization: Bearer <API_TOKEN>"

Tip: in the app UI (settings) you can copy ready-to-use MCP snippets for Claude and Codex.


4) CLI Setup (postflow)

Option A: Homebrew (recommended)

brew tap antoniolg/tap
brew install antoniolg/tap/postflow
postflow --help

Option B: Run from source

go run ./cmd/postflow --help

Configure CLI env:

export POSTFLOW_BASE_URL="http://localhost:8080"export POSTFLOW_API_TOKEN="<API_TOKEN>"

Common commands:

postflow health
postflow schedule list --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow schedule list --view posts --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow drafts list --limit 20
postflow posts validate --account-id acc_xxx --text "hello"
postflow posts validate --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1"}]'
postflow posts create --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1","media_ids":["med_x"]}]' --scheduled-at 2026-03-01T10:00:00Z
postflow posts schedule --id pst_xxx --scheduled-at 2026-03-01T10:00:00Z
postflow posts edit --id pst_xxx --text "copy updated" --intent schedule --scheduled-at 2026-03-01T10:30:00Z
postflow posts edit --id pst_xxx --segments-json '[{"text":"root updated"},{"text":"reply updated"}]'
postflow posts edit --id pst_xxx --text "copy + media" --replace-media --media-id med_a --media-id med_b
postflow posts delete --id pst_xxx
postflow posts cancel --id pst_xxx
postflow accounts list
postflow accounts reauthorize --id acc_xxx
postflow settings set-timezone --timezone Europe/Madrid
postflow settings set-smtp --host smtp.sendgrid.net --port 587 --username apikey --password "$SMTP_PASSWORD" --from postflow@example.com --to owner@example.com
postflow media list --limit 20

--text and --segments-json are mutually exclusive on posts create, posts validate, and posts edit.

schedule list returns grouped publications by default. Use --view posts to inspect the raw per-post/thread rows.


5) Deploy (Coolify + GHCR image)

You can deploy from prebuilt image (no build on server):

ghcr.io/antoniolg/postflow:latest

or pinned:

ghcr.io/antoniolg/postflow:vX.Y.Z

Full production runbook:


6) Troubleshooting

  • 401 unauthorized:
    • check API_TOKEN
    • check Authorization: Bearer ... in MCP/API clients
  • OAuth callback errors:
    • verify PUBLIC_BASE_URL matches your real public domain
    • for X, verify X_CLIENT_ID is set and the callback URL is registered in the X app settings
  • Instagram media create errors (code=9004, error_subcode=2207052):
    • verify PUBLIC_BASE_URL is public/reachable from the internet (not localhost in production)
    • for image posts, upload JPEG or PNG (.jpg / .jpeg / .png)
    • for video posts, use MP4 or MOV
    • media uploads are capped at 512 MiB
  • CLI auth errors:
    • verify POSTFLOW_API_TOKEN matches server API_TOKEN

7) Additional Docs

About

No description, website, or topics provided.

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

postflow

⚠️Disclaimer: postflow is in active development. It is not tested enough yet to guarantee correct behavior in all scenarios. Use at your own risk.

postflow is a lightweight social publishing service with:

  • Web UI
  • HTTP API
  • MCP endpoint (Streamable HTTP)
  • CLI (postflow)

This README is a basic setup guide.

1) Local Setup (5 minutes)

Requirements

  • Go 1.26.5+
  • Optional: Homebrew (for installing CLI binary)

Start locally

git clone https://github.com/antoniolg/postflow.git
cd postflow
cp .env.example .env

Generate required secrets:

# 32-byte base64 key (required)
openssl rand -base64 32
# API token (recommended)
openssl rand -hex 32

Put those values in .env:

POSTFLOW_MASTER_KEY=<base64-from-openssl>API_TOKEN=<hex-token>PUBLIC_BASE_URL=http://localhost:8080OWNER_EMAIL=owner@example.comOWNER_PASSWORD_HASH=<bcrypt-hash>POSTFLOW_DRIVER=mock

Generate OWNER_PASSWORD_HASH with the helper script in this repo:

go run ./scripts/hash-password.go 'replace-with-your-password'

If you store it in a local .env, quote the value because bcrypt hashes contain $:

OWNER_PASSWORD_HASH='$2a$10$...'

Run:

go run ./cmd/postflow-server

Open:

  • UI: http://localhost:8080
  • MCP: http://localhost:8080/mcp

2) Environment Variables (and where to get them)

Use .env.example as template. These are the key ones:

Core (recommended in all setups)

VariableRequiredWhere it comes from
POSTFLOW_MASTER_KEYYesGenerate locally: openssl rand -base64 32
API_TOKENRecommendedGenerate locally (random token), kept for API/MCP auth for CLI, Codex, Claude, and other legacy clients
OWNER_EMAILRecommended for UI/ChatGPTOwner email for the single-user local login
OWNER_PASSWORD_HASHRecommended for UI/ChatGPTBcrypt hash for the owner password
PUBLIC_BASE_URLYes for OAuth and Instagram media URLsYour app URL (http://localhost:8080 locally, your public HTTPS domain in prod)
UI_BASIC_USER / UI_BASIC_PASSTemporary compatibility onlyOptional legacy Basic Auth fallback for the UI

Storage/runtime

VariableDefaultNotes
PORT8080HTTP port
DATABASE_PATHpostflow.dbSQLite DB path
DATA_DIRdataUploaded media path

Network credentials (only if you use that network)

NetworkVariablesWhere to get them
XX_CLIENT_ID, X_CLIENT_SECRETX Developer Portal OAuth 2.0 app credentials for account connection
LinkedInLINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRETLinkedIn Developer app with member posting enabled. Company page connections additionally require organization posting/admin scopes.
Facebook/InstagramMETA_APP_ID, META_APP_SECRETMeta Developers app

Important:

  • If you want real publishing, set POSTFLOW_DRIVER=live.
  • For local testing without real publishing, keep POSTFLOW_DRIVER=mock.
  • OAuth account connection is available for X, LinkedIn, Facebook, and Instagram.
  • LinkedIn OAuth connects personal profiles by default. Use the LinkedIn organization connection action, or account_kind=organization on /oauth/linkedin/start, to request company page scopes.
  • LinkedIn root posts with a first http(s) link and no attached media are published as article posts at publish time so PostFlow can send explicit unfurl metadata. If media is attached, media wins and link unfurl is skipped.
  • In the web UI, if an OAuth provider returns multiple accounts, PostFlow shows a selection step before saving them.
  • OAuth accounts in error show a reauthorize action. Recovery is bound to the existing account identity, and credentials are replaced only when the provider returns the same account.
  • Publish failure emails are configured from Settings, the CLI, or MCP. SMTP passwords are stored encrypted with POSTFLOW_MASTER_KEY.
  • In production (Coolify), set secrets in the platform UI, not in committed files.
  • In Coolify, mark OWNER_PASSWORD_HASH as a literal/secret value so $ is not interpolated.

3) MCP Setup (for LLMs)

Endpoint:

http://localhost:8080/mcp

For Codex, Claude, CLI, and other legacy clients, if API_TOKEN is set, send:

Authorization: Bearer <API_TOKEN>

For ChatGPT / remote MCP clients with OAuth:

  • Authorization metadata: http://localhost:8080/.well-known/oauth-authorization-server
  • Protected resource metadata: http://localhost:8080/.well-known/oauth-protected-resource
  • The login page is http://localhost:8080/login
  • Dynamic client registration is available at POST /oauth/register
  • PostFlow allows MCP discovery requests without auth (initialize, notifications/initialized, ping, and tools/list) so ChatGPT can complete the handshake cleanly.
  • Actual MCP tool execution (tools/call) remains protected and requires OAuth bearer auth (or the legacy API_TOKEN flow for non-OAuth clients).

Main MCP tools available:

  • postflow_health
  • postflow_list_schedule
  • postflow_list_drafts
  • postflow_list_accounts
  • postflow_create_static_account
  • postflow_connect_account
  • postflow_disconnect_account
  • postflow_reauthorize_account
  • postflow_set_x_premium
  • postflow_delete_account
  • postflow_list_failed
  • postflow_create_post
  • postflow_cancel_post
  • postflow_schedule_post
  • postflow_edit_post
  • postflow_delete_post
  • postflow_validate_post
  • postflow_upload_media
  • postflow_list_media
  • postflow_delete_media
  • postflow_requeue_failed
  • postflow_delete_failed
  • postflow_set_timezone

Thread payload support (same shape in API/MCP/CLI):

  • segments: [{ "text": "...", "media_ids": ["med_x"] }]
  • If segments is present, step 1 is the root post and steps 2..N are follow-ups.
  • Publishing semantics: X follow-ups are chained as replies; other supported thread platforms publish follow-ups as comments on the root post.
  • Backward compatibility is preserved for legacy text + media_ids.
  • postflow_edit_post accepts optional media_ids to replace media on editable posts ([] clears all media).
  • Editing without intent and without scheduled_at preserves the current scheduling state.

Codex CLI

codex mcp add postflow --url http://localhost:8080/mcp

~/.codex/config.toml example:

[mcp_servers.postflow]
url = "http://localhost:8080/mcp"bearer_token_env_var = "POSTFLOW_API_TOKEN"

Then:

export POSTFLOW_API_TOKEN="<same-value-as-API_TOKEN>"

Claude Code

claude mcp add -t http postflow http://localhost:8080/mcp --header "Authorization: Bearer <API_TOKEN>"

Tip: in the app UI (settings) you can copy ready-to-use MCP snippets for Claude and Codex.


4) CLI Setup (postflow)

Option A: Homebrew (recommended)

brew tap antoniolg/tap
brew install antoniolg/tap/postflow
postflow --help

Option B: Run from source

go run ./cmd/postflow --help

Configure CLI env:

export POSTFLOW_BASE_URL="http://localhost:8080"export POSTFLOW_API_TOKEN="<API_TOKEN>"

Common commands:

postflow health
postflow schedule list --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow schedule list --view posts --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow drafts list --limit 20
postflow posts validate --account-id acc_xxx --text "hello"
postflow posts validate --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1"}]'
postflow posts create --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1","media_ids":["med_x"]}]' --scheduled-at 2026-03-01T10:00:00Z
postflow posts schedule --id pst_xxx --scheduled-at 2026-03-01T10:00:00Z
postflow posts edit --id pst_xxx --text "copy updated" --intent schedule --scheduled-at 2026-03-01T10:30:00Z
postflow posts edit --id pst_xxx --segments-json '[{"text":"root updated"},{"text":"reply updated"}]'
postflow posts edit --id pst_xxx --text "copy + media" --replace-media --media-id med_a --media-id med_b
postflow posts delete --id pst_xxx
postflow posts cancel --id pst_xxx
postflow accounts list
postflow accounts reauthorize --id acc_xxx
postflow settings set-timezone --timezone Europe/Madrid
postflow settings set-smtp --host smtp.sendgrid.net --port 587 --username apikey --password "$SMTP_PASSWORD" --from postflow@example.com --to owner@example.com
postflow media list --limit 20

--text and --segments-json are mutually exclusive on posts create, posts validate, and posts edit.

schedule list returns grouped publications by default. Use --view posts to inspect the raw per-post/thread rows.


5) Deploy (Coolify + GHCR image)

You can deploy from prebuilt image (no build on server):

ghcr.io/antoniolg/postflow:latest

or pinned:

ghcr.io/antoniolg/postflow:vX.Y.Z

Full production runbook:


6) Troubleshooting

  • 401 unauthorized:
    • check API_TOKEN
    • check Authorization: Bearer ... in MCP/API clients
  • OAuth callback errors:
    • verify PUBLIC_BASE_URL matches your real public domain
    • for X, verify X_CLIENT_ID is set and the callback URL is registered in the X app settings
  • Instagram media create errors (code=9004, error_subcode=2207052):
    • verify PUBLIC_BASE_URL is public/reachable from the internet (not localhost in production)
    • for image posts, upload JPEG or PNG (.jpg / .jpeg / .png)
    • for video posts, use MP4 or MOV
    • media uploads are capped at 512 MiB
  • CLI auth errors:
    • verify POSTFLOW_API_TOKEN matches server API_TOKEN

7) Additional Docs

About

No description, website, or topics provided.

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

postflow

⚠️Disclaimer: postflow is in active development. It is not tested enough yet to guarantee correct behavior in all scenarios. Use at your own risk.

postflow is a lightweight social publishing service with:

  • Web UI
  • HTTP API
  • MCP endpoint (Streamable HTTP)
  • CLI (postflow)

This README is a basic setup guide.

1) Local Setup (5 minutes)

Requirements

  • Go 1.26.5+
  • Optional: Homebrew (for installing CLI binary)

Start locally

git clone https://github.com/antoniolg/postflow.git
cd postflow
cp .env.example .env

Generate required secrets:

# 32-byte base64 key (required)
openssl rand -base64 32
# API token (recommended)
openssl rand -hex 32

Put those values in .env:

POSTFLOW_MASTER_KEY=<base64-from-openssl>API_TOKEN=<hex-token>PUBLIC_BASE_URL=http://localhost:8080OWNER_EMAIL=owner@example.comOWNER_PASSWORD_HASH=<bcrypt-hash>POSTFLOW_DRIVER=mock

Generate OWNER_PASSWORD_HASH with the helper script in this repo:

go run ./scripts/hash-password.go 'replace-with-your-password'

If you store it in a local .env, quote the value because bcrypt hashes contain $:

OWNER_PASSWORD_HASH='$2a$10$...'

Run:

go run ./cmd/postflow-server

Open:

  • UI: http://localhost:8080
  • MCP: http://localhost:8080/mcp

2) Environment Variables (and where to get them)

Use .env.example as template. These are the key ones:

Core (recommended in all setups)

VariableRequiredWhere it comes from
POSTFLOW_MASTER_KEYYesGenerate locally: openssl rand -base64 32
API_TOKENRecommendedGenerate locally (random token), kept for API/MCP auth for CLI, Codex, Claude, and other legacy clients
OWNER_EMAILRecommended for UI/ChatGPTOwner email for the single-user local login
OWNER_PASSWORD_HASHRecommended for UI/ChatGPTBcrypt hash for the owner password
PUBLIC_BASE_URLYes for OAuth and Instagram media URLsYour app URL (http://localhost:8080 locally, your public HTTPS domain in prod)
UI_BASIC_USER / UI_BASIC_PASSTemporary compatibility onlyOptional legacy Basic Auth fallback for the UI

Storage/runtime

VariableDefaultNotes
PORT8080HTTP port
DATABASE_PATHpostflow.dbSQLite DB path
DATA_DIRdataUploaded media path

Network credentials (only if you use that network)

NetworkVariablesWhere to get them
XX_CLIENT_ID, X_CLIENT_SECRETX Developer Portal OAuth 2.0 app credentials for account connection
LinkedInLINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRETLinkedIn Developer app with member posting enabled. Company page connections additionally require organization posting/admin scopes.
Facebook/InstagramMETA_APP_ID, META_APP_SECRETMeta Developers app

Important:

  • If you want real publishing, set POSTFLOW_DRIVER=live.
  • For local testing without real publishing, keep POSTFLOW_DRIVER=mock.
  • OAuth account connection is available for X, LinkedIn, Facebook, and Instagram.
  • LinkedIn OAuth connects personal profiles by default. Use the LinkedIn organization connection action, or account_kind=organization on /oauth/linkedin/start, to request company page scopes.
  • LinkedIn root posts with a first http(s) link and no attached media are published as article posts at publish time so PostFlow can send explicit unfurl metadata. If media is attached, media wins and link unfurl is skipped.
  • In the web UI, if an OAuth provider returns multiple accounts, PostFlow shows a selection step before saving them.
  • OAuth accounts in error show a reauthorize action. Recovery is bound to the existing account identity, and credentials are replaced only when the provider returns the same account.
  • Publish failure emails are configured from Settings, the CLI, or MCP. SMTP passwords are stored encrypted with POSTFLOW_MASTER_KEY.
  • In production (Coolify), set secrets in the platform UI, not in committed files.
  • In Coolify, mark OWNER_PASSWORD_HASH as a literal/secret value so $ is not interpolated.

3) MCP Setup (for LLMs)

Endpoint:

http://localhost:8080/mcp

For Codex, Claude, CLI, and other legacy clients, if API_TOKEN is set, send:

Authorization: Bearer <API_TOKEN>

For ChatGPT / remote MCP clients with OAuth:

  • Authorization metadata: http://localhost:8080/.well-known/oauth-authorization-server
  • Protected resource metadata: http://localhost:8080/.well-known/oauth-protected-resource
  • The login page is http://localhost:8080/login
  • Dynamic client registration is available at POST /oauth/register
  • PostFlow allows MCP discovery requests without auth (initialize, notifications/initialized, ping, and tools/list) so ChatGPT can complete the handshake cleanly.
  • Actual MCP tool execution (tools/call) remains protected and requires OAuth bearer auth (or the legacy API_TOKEN flow for non-OAuth clients).

Main MCP tools available:

  • postflow_health
  • postflow_list_schedule
  • postflow_list_drafts
  • postflow_list_accounts
  • postflow_create_static_account
  • postflow_connect_account
  • postflow_disconnect_account
  • postflow_reauthorize_account
  • postflow_set_x_premium
  • postflow_delete_account
  • postflow_list_failed
  • postflow_create_post
  • postflow_cancel_post
  • postflow_schedule_post
  • postflow_edit_post
  • postflow_delete_post
  • postflow_validate_post
  • postflow_upload_media
  • postflow_list_media
  • postflow_delete_media
  • postflow_requeue_failed
  • postflow_delete_failed
  • postflow_set_timezone

Thread payload support (same shape in API/MCP/CLI):

  • segments: [{ "text": "...", "media_ids": ["med_x"] }]
  • If segments is present, step 1 is the root post and steps 2..N are follow-ups.
  • Publishing semantics: X follow-ups are chained as replies; other supported thread platforms publish follow-ups as comments on the root post.
  • Backward compatibility is preserved for legacy text + media_ids.
  • postflow_edit_post accepts optional media_ids to replace media on editable posts ([] clears all media).
  • Editing without intent and without scheduled_at preserves the current scheduling state.

Codex CLI

codex mcp add postflow --url http://localhost:8080/mcp

~/.codex/config.toml example:

[mcp_servers.postflow]
url = "http://localhost:8080/mcp"bearer_token_env_var = "POSTFLOW_API_TOKEN"

Then:

export POSTFLOW_API_TOKEN="<same-value-as-API_TOKEN>"

Claude Code

claude mcp add -t http postflow http://localhost:8080/mcp --header "Authorization: Bearer <API_TOKEN>"

Tip: in the app UI (settings) you can copy ready-to-use MCP snippets for Claude and Codex.


4) CLI Setup (postflow)

Option A: Homebrew (recommended)

brew tap antoniolg/tap
brew install antoniolg/tap/postflow
postflow --help

Option B: Run from source

go run ./cmd/postflow --help

Configure CLI env:

export POSTFLOW_BASE_URL="http://localhost:8080"export POSTFLOW_API_TOKEN="<API_TOKEN>"

Common commands:

postflow health
postflow schedule list --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow schedule list --view posts --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow drafts list --limit 20
postflow posts validate --account-id acc_xxx --text "hello"
postflow posts validate --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1"}]'
postflow posts create --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1","media_ids":["med_x"]}]' --scheduled-at 2026-03-01T10:00:00Z
postflow posts schedule --id pst_xxx --scheduled-at 2026-03-01T10:00:00Z
postflow posts edit --id pst_xxx --text "copy updated" --intent schedule --scheduled-at 2026-03-01T10:30:00Z
postflow posts edit --id pst_xxx --segments-json '[{"text":"root updated"},{"text":"reply updated"}]'
postflow posts edit --id pst_xxx --text "copy + media" --replace-media --media-id med_a --media-id med_b
postflow posts delete --id pst_xxx
postflow posts cancel --id pst_xxx
postflow accounts list
postflow accounts reauthorize --id acc_xxx
postflow settings set-timezone --timezone Europe/Madrid
postflow settings set-smtp --host smtp.sendgrid.net --port 587 --username apikey --password "$SMTP_PASSWORD" --from postflow@example.com --to owner@example.com
postflow media list --limit 20

--text and --segments-json are mutually exclusive on posts create, posts validate, and posts edit.

schedule list returns grouped publications by default. Use --view posts to inspect the raw per-post/thread rows.


5) Deploy (Coolify + GHCR image)

You can deploy from prebuilt image (no build on server):

ghcr.io/antoniolg/postflow:latest

or pinned:

ghcr.io/antoniolg/postflow:vX.Y.Z

Full production runbook:


6) Troubleshooting

  • 401 unauthorized:
    • check API_TOKEN
    • check Authorization: Bearer ... in MCP/API clients
  • OAuth callback errors:
    • verify PUBLIC_BASE_URL matches your real public domain
    • for X, verify X_CLIENT_ID is set and the callback URL is registered in the X app settings
  • Instagram media create errors (code=9004, error_subcode=2207052):
    • verify PUBLIC_BASE_URL is public/reachable from the internet (not localhost in production)
    • for image posts, upload JPEG or PNG (.jpg / .jpeg / .png)
    • for video posts, use MP4 or MOV
    • media uploads are capped at 512 MiB
  • CLI auth errors:
    • verify POSTFLOW_API_TOKEN matches server API_TOKEN

7) Additional Docs

About

No description, website, or topics provided.

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

postflow

⚠️Disclaimer: postflow is in active development. It is not tested enough yet to guarantee correct behavior in all scenarios. Use at your own risk.

postflow is a lightweight social publishing service with:

  • Web UI
  • HTTP API
  • MCP endpoint (Streamable HTTP)
  • CLI (postflow)

This README is a basic setup guide.

1) Local Setup (5 minutes)

Requirements

  • Go 1.26.5+
  • Optional: Homebrew (for installing CLI binary)

Start locally

git clone https://github.com/antoniolg/postflow.git
cd postflow
cp .env.example .env

Generate required secrets:

# 32-byte base64 key (required)
openssl rand -base64 32
# API token (recommended)
openssl rand -hex 32

Put those values in .env:

POSTFLOW_MASTER_KEY=<base64-from-openssl>API_TOKEN=<hex-token>PUBLIC_BASE_URL=http://localhost:8080OWNER_EMAIL=owner@example.comOWNER_PASSWORD_HASH=<bcrypt-hash>POSTFLOW_DRIVER=mock

Generate OWNER_PASSWORD_HASH with the helper script in this repo:

go run ./scripts/hash-password.go 'replace-with-your-password'

If you store it in a local .env, quote the value because bcrypt hashes contain $:

OWNER_PASSWORD_HASH='$2a$10$...'

Run:

go run ./cmd/postflow-server

Open:

  • UI: http://localhost:8080
  • MCP: http://localhost:8080/mcp

2) Environment Variables (and where to get them)

Use .env.example as template. These are the key ones:

Core (recommended in all setups)

VariableRequiredWhere it comes from
POSTFLOW_MASTER_KEYYesGenerate locally: openssl rand -base64 32
API_TOKENRecommendedGenerate locally (random token), kept for API/MCP auth for CLI, Codex, Claude, and other legacy clients
OWNER_EMAILRecommended for UI/ChatGPTOwner email for the single-user local login
OWNER_PASSWORD_HASHRecommended for UI/ChatGPTBcrypt hash for the owner password
PUBLIC_BASE_URLYes for OAuth and Instagram media URLsYour app URL (http://localhost:8080 locally, your public HTTPS domain in prod)
UI_BASIC_USER / UI_BASIC_PASSTemporary compatibility onlyOptional legacy Basic Auth fallback for the UI

Storage/runtime

VariableDefaultNotes
PORT8080HTTP port
DATABASE_PATHpostflow.dbSQLite DB path
DATA_DIRdataUploaded media path

Network credentials (only if you use that network)

NetworkVariablesWhere to get them
XX_CLIENT_ID, X_CLIENT_SECRETX Developer Portal OAuth 2.0 app credentials for account connection
LinkedInLINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRETLinkedIn Developer app with member posting enabled. Company page connections additionally require organization posting/admin scopes.
Facebook/InstagramMETA_APP_ID, META_APP_SECRETMeta Developers app

Important:

  • If you want real publishing, set POSTFLOW_DRIVER=live.
  • For local testing without real publishing, keep POSTFLOW_DRIVER=mock.
  • OAuth account connection is available for X, LinkedIn, Facebook, and Instagram.
  • LinkedIn OAuth connects personal profiles by default. Use the LinkedIn organization connection action, or account_kind=organization on /oauth/linkedin/start, to request company page scopes.
  • LinkedIn root posts with a first http(s) link and no attached media are published as article posts at publish time so PostFlow can send explicit unfurl metadata. If media is attached, media wins and link unfurl is skipped.
  • In the web UI, if an OAuth provider returns multiple accounts, PostFlow shows a selection step before saving them.
  • OAuth accounts in error show a reauthorize action. Recovery is bound to the existing account identity, and credentials are replaced only when the provider returns the same account.
  • Publish failure emails are configured from Settings, the CLI, or MCP. SMTP passwords are stored encrypted with POSTFLOW_MASTER_KEY.
  • In production (Coolify), set secrets in the platform UI, not in committed files.
  • In Coolify, mark OWNER_PASSWORD_HASH as a literal/secret value so $ is not interpolated.

3) MCP Setup (for LLMs)

Endpoint:

http://localhost:8080/mcp

For Codex, Claude, CLI, and other legacy clients, if API_TOKEN is set, send:

Authorization: Bearer <API_TOKEN>

For ChatGPT / remote MCP clients with OAuth:

  • Authorization metadata: http://localhost:8080/.well-known/oauth-authorization-server
  • Protected resource metadata: http://localhost:8080/.well-known/oauth-protected-resource
  • The login page is http://localhost:8080/login
  • Dynamic client registration is available at POST /oauth/register
  • PostFlow allows MCP discovery requests without auth (initialize, notifications/initialized, ping, and tools/list) so ChatGPT can complete the handshake cleanly.
  • Actual MCP tool execution (tools/call) remains protected and requires OAuth bearer auth (or the legacy API_TOKEN flow for non-OAuth clients).

Main MCP tools available:

  • postflow_health
  • postflow_list_schedule
  • postflow_list_drafts
  • postflow_list_accounts
  • postflow_create_static_account
  • postflow_connect_account
  • postflow_disconnect_account
  • postflow_reauthorize_account
  • postflow_set_x_premium
  • postflow_delete_account
  • postflow_list_failed
  • postflow_create_post
  • postflow_cancel_post
  • postflow_schedule_post
  • postflow_edit_post
  • postflow_delete_post
  • postflow_validate_post
  • postflow_upload_media
  • postflow_list_media
  • postflow_delete_media
  • postflow_requeue_failed
  • postflow_delete_failed
  • postflow_set_timezone

Thread payload support (same shape in API/MCP/CLI):

  • segments: [{ "text": "...", "media_ids": ["med_x"] }]
  • If segments is present, step 1 is the root post and steps 2..N are follow-ups.
  • Publishing semantics: X follow-ups are chained as replies; other supported thread platforms publish follow-ups as comments on the root post.
  • Backward compatibility is preserved for legacy text + media_ids.
  • postflow_edit_post accepts optional media_ids to replace media on editable posts ([] clears all media).
  • Editing without intent and without scheduled_at preserves the current scheduling state.

Codex CLI

codex mcp add postflow --url http://localhost:8080/mcp

~/.codex/config.toml example:

[mcp_servers.postflow]
url = "http://localhost:8080/mcp"bearer_token_env_var = "POSTFLOW_API_TOKEN"

Then:

export POSTFLOW_API_TOKEN="<same-value-as-API_TOKEN>"

Claude Code

claude mcp add -t http postflow http://localhost:8080/mcp --header "Authorization: Bearer <API_TOKEN>"

Tip: in the app UI (settings) you can copy ready-to-use MCP snippets for Claude and Codex.


4) CLI Setup (postflow)

Option A: Homebrew (recommended)

brew tap antoniolg/tap
brew install antoniolg/tap/postflow
postflow --help

Option B: Run from source

go run ./cmd/postflow --help

Configure CLI env:

export POSTFLOW_BASE_URL="http://localhost:8080"export POSTFLOW_API_TOKEN="<API_TOKEN>"

Common commands:

postflow health
postflow schedule list --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow schedule list --view posts --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow drafts list --limit 20
postflow posts validate --account-id acc_xxx --text "hello"
postflow posts validate --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1"}]'
postflow posts create --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1","media_ids":["med_x"]}]' --scheduled-at 2026-03-01T10:00:00Z
postflow posts schedule --id pst_xxx --scheduled-at 2026-03-01T10:00:00Z
postflow posts edit --id pst_xxx --text "copy updated" --intent schedule --scheduled-at 2026-03-01T10:30:00Z
postflow posts edit --id pst_xxx --segments-json '[{"text":"root updated"},{"text":"reply updated"}]'
postflow posts edit --id pst_xxx --text "copy + media" --replace-media --media-id med_a --media-id med_b
postflow posts delete --id pst_xxx
postflow posts cancel --id pst_xxx
postflow accounts list
postflow accounts reauthorize --id acc_xxx
postflow settings set-timezone --timezone Europe/Madrid
postflow settings set-smtp --host smtp.sendgrid.net --port 587 --username apikey --password "$SMTP_PASSWORD" --from postflow@example.com --to owner@example.com
postflow media list --limit 20

--text and --segments-json are mutually exclusive on posts create, posts validate, and posts edit.

schedule list returns grouped publications by default. Use --view posts to inspect the raw per-post/thread rows.


5) Deploy (Coolify + GHCR image)

You can deploy from prebuilt image (no build on server):

ghcr.io/antoniolg/postflow:latest

or pinned:

ghcr.io/antoniolg/postflow:vX.Y.Z

Full production runbook:


6) Troubleshooting

  • 401 unauthorized:
    • check API_TOKEN
    • check Authorization: Bearer ... in MCP/API clients
  • OAuth callback errors:
    • verify PUBLIC_BASE_URL matches your real public domain
    • for X, verify X_CLIENT_ID is set and the callback URL is registered in the X app settings
  • Instagram media create errors (code=9004, error_subcode=2207052):
    • verify PUBLIC_BASE_URL is public/reachable from the internet (not localhost in production)
    • for image posts, upload JPEG or PNG (.jpg / .jpeg / .png)
    • for video posts, use MP4 or MOV
    • media uploads are capped at 512 MiB
  • CLI auth errors:
    • verify POSTFLOW_API_TOKEN matches server API_TOKEN

7) Additional Docs

About

No description, website, or topics provided.

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

postflow

⚠️Disclaimer: postflow is in active development. It is not tested enough yet to guarantee correct behavior in all scenarios. Use at your own risk.

postflow is a lightweight social publishing service with:

  • Web UI
  • HTTP API
  • MCP endpoint (Streamable HTTP)
  • CLI (postflow)

This README is a basic setup guide.

1) Local Setup (5 minutes)

Requirements

  • Go 1.26.5+
  • Optional: Homebrew (for installing CLI binary)

Start locally

git clone https://github.com/antoniolg/postflow.git
cd postflow
cp .env.example .env

Generate required secrets:

# 32-byte base64 key (required)
openssl rand -base64 32
# API token (recommended)
openssl rand -hex 32

Put those values in .env:

POSTFLOW_MASTER_KEY=<base64-from-openssl>API_TOKEN=<hex-token>PUBLIC_BASE_URL=http://localhost:8080OWNER_EMAIL=owner@example.comOWNER_PASSWORD_HASH=<bcrypt-hash>POSTFLOW_DRIVER=mock

Generate OWNER_PASSWORD_HASH with the helper script in this repo:

go run ./scripts/hash-password.go 'replace-with-your-password'

If you store it in a local .env, quote the value because bcrypt hashes contain $:

OWNER_PASSWORD_HASH='$2a$10$...'

Run:

go run ./cmd/postflow-server

Open:

  • UI: http://localhost:8080
  • MCP: http://localhost:8080/mcp

2) Environment Variables (and where to get them)

Use .env.example as template. These are the key ones:

Core (recommended in all setups)

VariableRequiredWhere it comes from
POSTFLOW_MASTER_KEYYesGenerate locally: openssl rand -base64 32
API_TOKENRecommendedGenerate locally (random token), kept for API/MCP auth for CLI, Codex, Claude, and other legacy clients
OWNER_EMAILRecommended for UI/ChatGPTOwner email for the single-user local login
OWNER_PASSWORD_HASHRecommended for UI/ChatGPTBcrypt hash for the owner password
PUBLIC_BASE_URLYes for OAuth and Instagram media URLsYour app URL (http://localhost:8080 locally, your public HTTPS domain in prod)
UI_BASIC_USER / UI_BASIC_PASSTemporary compatibility onlyOptional legacy Basic Auth fallback for the UI

Storage/runtime

VariableDefaultNotes
PORT8080HTTP port
DATABASE_PATHpostflow.dbSQLite DB path
DATA_DIRdataUploaded media path

Network credentials (only if you use that network)

NetworkVariablesWhere to get them
XX_CLIENT_ID, X_CLIENT_SECRETX Developer Portal OAuth 2.0 app credentials for account connection
LinkedInLINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRETLinkedIn Developer app with member posting enabled. Company page connections additionally require organization posting/admin scopes.
Facebook/InstagramMETA_APP_ID, META_APP_SECRETMeta Developers app

Important:

  • If you want real publishing, set POSTFLOW_DRIVER=live.
  • For local testing without real publishing, keep POSTFLOW_DRIVER=mock.
  • OAuth account connection is available for X, LinkedIn, Facebook, and Instagram.
  • LinkedIn OAuth connects personal profiles by default. Use the LinkedIn organization connection action, or account_kind=organization on /oauth/linkedin/start, to request company page scopes.
  • LinkedIn root posts with a first http(s) link and no attached media are published as article posts at publish time so PostFlow can send explicit unfurl metadata. If media is attached, media wins and link unfurl is skipped.
  • In the web UI, if an OAuth provider returns multiple accounts, PostFlow shows a selection step before saving them.
  • OAuth accounts in error show a reauthorize action. Recovery is bound to the existing account identity, and credentials are replaced only when the provider returns the same account.
  • Publish failure emails are configured from Settings, the CLI, or MCP. SMTP passwords are stored encrypted with POSTFLOW_MASTER_KEY.
  • In production (Coolify), set secrets in the platform UI, not in committed files.
  • In Coolify, mark OWNER_PASSWORD_HASH as a literal/secret value so $ is not interpolated.

3) MCP Setup (for LLMs)

Endpoint:

http://localhost:8080/mcp

For Codex, Claude, CLI, and other legacy clients, if API_TOKEN is set, send:

Authorization: Bearer <API_TOKEN>

For ChatGPT / remote MCP clients with OAuth:

  • Authorization metadata: http://localhost:8080/.well-known/oauth-authorization-server
  • Protected resource metadata: http://localhost:8080/.well-known/oauth-protected-resource
  • The login page is http://localhost:8080/login
  • Dynamic client registration is available at POST /oauth/register
  • PostFlow allows MCP discovery requests without auth (initialize, notifications/initialized, ping, and tools/list) so ChatGPT can complete the handshake cleanly.
  • Actual MCP tool execution (tools/call) remains protected and requires OAuth bearer auth (or the legacy API_TOKEN flow for non-OAuth clients).

Main MCP tools available:

  • postflow_health
  • postflow_list_schedule
  • postflow_list_drafts
  • postflow_list_accounts
  • postflow_create_static_account
  • postflow_connect_account
  • postflow_disconnect_account
  • postflow_reauthorize_account
  • postflow_set_x_premium
  • postflow_delete_account
  • postflow_list_failed
  • postflow_create_post
  • postflow_cancel_post
  • postflow_schedule_post
  • postflow_edit_post
  • postflow_delete_post
  • postflow_validate_post
  • postflow_upload_media
  • postflow_list_media
  • postflow_delete_media
  • postflow_requeue_failed
  • postflow_delete_failed
  • postflow_set_timezone

Thread payload support (same shape in API/MCP/CLI):

  • segments: [{ "text": "...", "media_ids": ["med_x"] }]
  • If segments is present, step 1 is the root post and steps 2..N are follow-ups.
  • Publishing semantics: X follow-ups are chained as replies; other supported thread platforms publish follow-ups as comments on the root post.
  • Backward compatibility is preserved for legacy text + media_ids.
  • postflow_edit_post accepts optional media_ids to replace media on editable posts ([] clears all media).
  • Editing without intent and without scheduled_at preserves the current scheduling state.

Codex CLI

codex mcp add postflow --url http://localhost:8080/mcp

~/.codex/config.toml example:

[mcp_servers.postflow]
url = "http://localhost:8080/mcp"bearer_token_env_var = "POSTFLOW_API_TOKEN"

Then:

export POSTFLOW_API_TOKEN="<same-value-as-API_TOKEN>"

Claude Code

claude mcp add -t http postflow http://localhost:8080/mcp --header "Authorization: Bearer <API_TOKEN>"

Tip: in the app UI (settings) you can copy ready-to-use MCP snippets for Claude and Codex.


4) CLI Setup (postflow)

Option A: Homebrew (recommended)

brew tap antoniolg/tap
brew install antoniolg/tap/postflow
postflow --help

Option B: Run from source

go run ./cmd/postflow --help

Configure CLI env:

export POSTFLOW_BASE_URL="http://localhost:8080"export POSTFLOW_API_TOKEN="<API_TOKEN>"

Common commands:

postflow health
postflow schedule list --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow schedule list --view posts --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
postflow drafts list --limit 20
postflow posts validate --account-id acc_xxx --text "hello"
postflow posts validate --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1"}]'
postflow posts create --account-id acc_xxx --segments-json '[{"text":"root"},{"text":"reply 1","media_ids":["med_x"]}]' --scheduled-at 2026-03-01T10:00:00Z
postflow posts schedule --id pst_xxx --scheduled-at 2026-03-01T10:00:00Z
postflow posts edit --id pst_xxx --text "copy updated" --intent schedule --scheduled-at 2026-03-01T10:30:00Z
postflow posts edit --id pst_xxx --segments-json '[{"text":"root updated"},{"text":"reply updated"}]'
postflow posts edit --id pst_xxx --text "copy + media" --replace-media --media-id med_a --media-id med_b
postflow posts delete --id pst_xxx
postflow posts cancel --id pst_xxx
postflow accounts list
postflow accounts reauthorize --id acc_xxx
postflow settings set-timezone --timezone Europe/Madrid
postflow settings set-smtp --host smtp.sendgrid.net --port 587 --username apikey --password "$SMTP_PASSWORD" --from postflow@example.com --to owner@example.com
postflow media list --limit 20

--text and --segments-json are mutually exclusive on posts create, posts validate, and posts edit.

schedule list returns grouped publications by default. Use --view posts to inspect the raw per-post/thread rows.


5) Deploy (Coolify + GHCR image)

You can deploy from prebuilt image (no build on server):

ghcr.io/antoniolg/postflow:latest

or pinned:

ghcr.io/antoniolg/postflow:vX.Y.Z

Full production runbook:


6) Troubleshooting

  • 401 unauthorized:
    • check API_TOKEN
    • check Authorization: Bearer ... in MCP/API clients
  • OAuth callback errors:
    • verify PUBLIC_BASE_URL matches your real public domain
    • for X, verify X_CLIENT_ID is set and the callback URL is registered in the X app settings
  • Instagram media create errors (code=9004, error_subcode=2207052):
    • verify PUBLIC_BASE_URL is public/reachable from the internet (not localhost in production)
    • for image posts, upload JPEG or PNG (.jpg / .jpeg / .png)
    • for video posts, use MP4 or MOV
    • media uploads are capped at 512 MiB
  • CLI auth errors:
    • verify POSTFLOW_API_TOKEN matches server API_TOKEN

7) Additional Docs

About

No description, website, or topics provided.

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages