AGENTS.md
Rama develop · réplica de lectura del repositorio original.
<!-- DO NOT EDIT. Generated by scripts/gen-agents.mjs from lib/playbooks.mjs (the per-platform sections). Run `node scripts/gen-agents.mjs --write` after editing playbooks. The contract preamble is authored in that script. -->
# AGENTS.md
pendpost is a local-first, MCP-native social planner with a human approval gate.
An agent drafts, schedules, and (once trusted) publishes across Instagram,
Facebook, LinkedIn, X, YouTube, Telegram, Discord, Reddit, Pinterest, TikTok,
Mastodon, WordPress, Ghost, Nostr, and Google Business Profile; a human approves
before anything goes out. Reddit, Pinterest, TikTok, and Google Business Profile
are beta lanes (each gated by a vendor trial/audit/access step, called out in its
playbook below).
This file is the contract for an AI agent operating pendpost. The per-platform
sections at the end are generated from lib/playbooks.mjs.
## Mock-first
With `PENDPOST_MODE=mock`, pendpost runs the full draft -> approve -> schedule ->
publish -> insights loop through the credential-free mock driver (no network;
nothing leaves the machine). Exercise everything there before connecting a real
platform. Without it pendpost is live: a lane with no credential is
live-but-unauthenticated and waits to be connected rather than publishing.
## Connecting as an agent
- One-click: install the Claude Desktop `.mcpb` bundle - it self-boots
`npx -y pendpost --stdio`, so there is no separate server to start.
- Or run `npx -y pendpost --stdio` and point any stdio MCP client at it.
- Dev / Claude Code over HTTP (the server must already be running via
`npx pendpost`): `claude mcp add --transport http pendpost http://127.0.0.1:8090/mcp`.
## The approval gate (non-negotiable)
- Every post carries an approval state; a missing or `draft` state means it will
NOT publish.
- An actor can never approve its own post (no self-approval): the actor passed to
`plan_create_post` cannot later `approve_post` it. Only `actor:"owner"` is exempt,
as the approval authority; an agent approves ONLY on the owner's explicit
instruction. `plan_update_post` can never touch approval.
- Auto-approve (the progressive-autonomy policy) is OWNER-ONLY: only `actor:"owner"`
can enable it via `config_set` `set.posting.autoApprove` - an agent can never turn
it on for itself. When enabled it approves under a distinct policy actor, never the
post's creator, so the no-self-approval rule still holds. Its platforms scope is
fail-closed: an empty `platforms` list approves NOTHING (the owner must trust at
least one platform), while empty `campaigns`/`types` lists mean no constraint on
that axis.
- `publish_due_run` does REAL publishes and needs `confirm:true` - never call it
unless the owner asks.
## Secrets never flow through you
You NEVER read, type, paste, or write a raw secret or token. `config_set` can
set non-secret identifiers only, never a secret. Secrets are entered ONLY by the
owner, in a local ceremony the owner runs themselves - the per-engine CLI or the
dashboard Setup page - which writes `.env` at mode 0600; the token never passes
through the agent or the chat.
## Per-platform setup loop
For each platform that is not validated-live or skipped:
1. Call `pendpost_health` and read `setup.<platform>` -
`{ status, validation, missing[], connectAction, playbook }`.
2. Open `playbook.portalUrl`, create the app, add the products, request the
scopes, and work `playbook.steps` in order. For each step, name the exact env
var / identifier it sets (`step.env` / `step.field`).
3. A missing IDENTIFIER (`kind:"identifier"`): ask the owner and write it with
`config_set` (`set.identifiers`). A missing SECRET (`kind:"secret"`): give the
owner the exact CLI to run (the step's `cli` / the `connectAction`); the owner
runs it and the localhost callback writes `.env`. On a multi-client workspace the
command must carry `--client <client-id>` so the ceremony targets the right client
- `connectAction` from `pendpost_health` already fills it in; the playbook prose
shows it as a `<client-id>` placeholder (single-client installs omit the flag).
4. Call `health_recheck{platform}` to VALIDATE - a read-only liveness probe that
surfaces the REAL pass/fail (`validation.ok` + `.detail`). On failure, relay
`validation.fix` and re-probe.
`setup.ready` is true ONLY when every platform is validated-live (a passing probe)
or explicitly skipped - a connected-but-unproven lane is NOT ready. The dashboard
Setup page reads the same `setup` field.
## The per-platform playbooks
The sections below are generated from lib/playbooks.mjs (the single prose source).
They are what a cold agent needs to drive each developer portal; at runtime the
same data arrives live (with per-lane status) via `pendpost_health`'s
`setup.<platform>.playbook`.
## Meta (Instagram + Facebook)
- **Portal:** https://developers.facebook.com/apps
- **App to create:** a Business app
- **Products to add:** Instagram Graph API, Facebook Login for Business
- **Scopes to request:** `instagram_basic instagram_content_publish instagram_manage_comments pages_show_list pages_read_engagement pages_manage_posts business_management`
**Steps**
1. **Create a Business app** - In the Meta App Dashboard, create a new app and choose the "Business" type. Note its App ID and App Secret (App Settings -> Basic). _(sets env `META_APP_ID, META_APP_SECRET`)_
2. **Add the publishing products** - Add the "Instagram Graph API" and "Facebook Login for Business" products to the app. Instagram publishing requires a Facebook Page connected to an Instagram professional (Business or Creator) account.
3. **Link the Page to the Instagram account** - In the Page settings, link the Facebook Page to the Instagram professional account that will publish. Record the Page ID and the Instagram User ID the app will post as. _(sets identifier `metaPageId, metaIgUserId`)_
4. **Mint a long-lived System User token** - In Business Settings -> System Users, create a System User, assign it the Page with full control, and generate a token granting the scopes above. Then exchange it for a long-lived Page token via the CLI below - the token never touches this dashboard. _(sets env `META_PAGE_ID, META_IG_USER_ID, META_SYSTEM_USER_TOKEN`)_
```bash
node scripts/meta-social.mjs setup-system-user --system-user-token <SYSTEM_USER_TOKEN> --page-id <ID> --app-id <ID> --app-secret <S> --client <client-id>
```
**Common failures**
- _Publishing returns a permissions error even though the token works for reads._ The token was minted without the instagram_content_publish scope. **Fix:** Re-generate the System User token with instagram_content_publish (and instagram_basic) granted, then re-run the setup CLI.
- _The API reports no Instagram account is available for the Page._ The Facebook Page is not linked to an Instagram professional account. **Fix:** Link the Page to an Instagram Business or Creator account in Page settings, then re-check the lane.
- _Facebook photo or multi-photo posts fail with a permissions error while reels and Instagram publish._ The token lacks pages_manage_posts, which /{page-id}/photos and /{page-id}/feed require. **Fix:** Re-generate the System User token with pages_manage_posts granted (the Page assigned with full control), then re-run the setup CLI.
- _Calls 404 or post to the wrong place._ The configured Page ID belongs to a different Page than the one the token controls. **Fix:** Confirm the Page ID (META_PAGE_ID) matches the Page the System User was granted, and the IG User ID belongs to that Page.
- _Instagram comments show the author as "unknown", or replies to Instagram comments fail._ The token lacks instagram_manage_comments. Since August 2024 Meta only returns who wrote an Instagram comment, and only accepts replies, with that permission. **Fix:** Re-generate the System User token with instagram_manage_comments granted, then re-run the setup CLI.
## LinkedIn
- **Portal:** https://www.linkedin.com/developers/apps
- **App to create:** an app associated with a Company Page you administer
- **Products to add:** Community Management API, Share on LinkedIn, Sign In with LinkedIn using OpenID Connect
- **Scopes to request:** `w_member_social r_organization_social w_organization_social rw_organization_admin`
**Steps**
1. **Create the app** - Create a LinkedIn developer app and associate it with the Company Page you will publish for. You must hold an admin role on that Page for organization posting to work.
2. **Request the products** - Under the Products tab, request the "Community Management API" plus "Share on LinkedIn" and "Sign In with LinkedIn". Organization (Company Page) posting requires the Community Management API; member posting needs Share on LinkedIn. Some products require review and may stay pending until approved.
3. **Record the organization URN** - Find the Company Page ID and form its URN as urn:li:organization:<digits>. This is the target the engine posts to. _(sets identifier `linkedinOrgUrn`)_
4. **Run the OAuth flow** - Run the CLI below to complete the OAuth authorization in the browser and store the access + refresh tokens. The token exchange happens in the CLI - this dashboard never sees the raw token. With "Share on LinkedIn" + "Sign In with LinkedIn" granted, the flow also captures your personal-profile URN automatically, unlocking the per-post "Personal profile" option in the composer (the default stays the Company Page).
```bash
node scripts/linkedin-social.mjs auth --client <client-id>
```
**Common failures**
- _Organization posts are rejected with an authorization error._ The authorizing member is not an admin of the Company Page. **Fix:** Grant the member a page admin role on the Company Page, then re-run the auth flow.
- _The requested scope is denied at authorization time._ The Community Management API product is still pending review or not added. **Fix:** Add and wait for approval of the Community Management API product, then re-authorize.
- _A post set to publish on the personal profile is blocked ("member posting needs a captured person URN")._ The token was minted before member posting was set up, so no personal-profile URN was captured (w_member_social + Sign In with LinkedIn not both granted at auth time). **Fix:** Re-run the auth flow with "Share on LinkedIn" and "Sign In with LinkedIn" granted; the flow captures the person URN and the block clears.
- _Posts succeed but appear under the wrong organization (or 404)._ The configured org URN points at the wrong Company Page. **Fix:** Verify LINKEDIN_ORG_URN is urn:li:organization:<digits> for the intended Page.
## X
- **Portal:** https://developer.x.com/en/portal/dashboard
- **App to create:** a Project, then an App inside it
- **Products to add:** User authentication settings: OAuth 1.0a (Read and Write)
- **Scopes to request:** `tweet.read tweet.write users.read media.write offline.access dm.read like.read follows.read dm.write`
**Steps**
1. **Create a Project and an App** - In the X developer portal, create a Project and add an App inside it. Standalone apps cannot post; the App must live in a Project.
2. **Set User authentication to Read and Write FIRST** - Open the App's "User authentication settings" and set the app permissions to "Read and Write" BEFORE generating any tokens. Generating tokens while the app is Read-only mints read-only tokens.
3. **Generate OAuth 1.0a keys and tokens (recommended)** - OAuth 1.0a is the recommended path. Generate BOTH the API Key/Secret (consumer keys) AND the Access Token/Secret. Order matters: if you regenerate the API Key/Secret, regenerate the Access Token/Secret AFTERWARD, or the old access token is invalidated. _(sets env `X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET`)_
4. **OAuth 2.0 PKCE alternative** - If you prefer OAuth 2.0 with PKCE, request the scopes above and set the callback URL to http://localhost:8087/callback, then run the CLI below to authorize in the browser. The CLI handles the token exchange. _(sets identifier `xHandle`)_
```bash
node scripts/x-social.mjs auth --client <client-id>
```
**Common failures**
- _401 "Could not authenticate you" (code 32)._ A key or token was hand-typed and is subtly wrong, or the signature inputs are mismatched. **Fix:** Copy each value directly from the portal (do not retype), confirm all four OAuth 1.0a values belong to the same app, and re-run setup.
- _Auth worked yesterday but now fails._ The API Key/Secret was regenerated, which invalidated the previously issued Access Token/Secret. **Fix:** Regenerate the Access Token/Secret AFTER any API Key/Secret change, then update all four values.
- _Reads work but posting is forbidden._ The app was left at Read-only permissions when the tokens were generated. **Fix:** Set User authentication to "Read and Write", regenerate the Access Token/Secret, and re-run setup.
## YouTube
- **Portal:** https://console.cloud.google.com/apis/credentials
- **App to create:** a Google Cloud project with an OAuth 2.0 client
- **Products to add:** YouTube Data API v3
- **Scopes to request:** `https://www.googleapis.com/auth/youtube.force-ssl`
**Steps**
1. **Create a project and enable the API** - In Google Cloud Console, create a project and enable "YouTube Data API v3" under APIs & Services -> Library.
2. **Publish the consent screen to Production** - On the OAuth consent screen, add the scope https://www.googleapis.com/auth/youtube.force-ssl, then publish the app to Production (Google Auth Platform -> Audience -> Publish app). A Production app issues a durable refresh token and only asks you to confirm access once. Leaving it in Testing works for a quick trial but expires the token after 7 days - publish before you rely on it.
3. **Create the OAuth client** - Create an OAuth 2.0 client (Desktop or Web application). For a Web client, add the redirect URI http://localhost:8088/callback. Record the client ID and client secret. _(sets env `YT_CLIENT_ID, YT_CLIENT_SECRET`)_
4. **Run the OAuth flow to mint a refresh token** - Run the CLI below to authorize in the browser and store the refresh token. The token exchange happens in the CLI - this dashboard never sees the raw token. Expect a one-time "Google hasn't verified this app" screen - that is normal for your own bring-your-own app: click Advanced -> Go to pendpost (unsafe) to continue. No Google verification is needed. _(sets env `YT_REFRESH_TOKEN`)_
```bash
node scripts/yt-social.mjs auth --client <client-id>
```
**Common failures**
- _No refresh token is returned, so the lane cannot stay authenticated._ Google only issues a refresh token on the first consent for a client. **Fix:** Force a fresh consent (add prompt=consent / access_type=offline), or revoke prior access for the app and re-run auth.
- _The token stops working after about a week._ The consent screen is still in Testing mode, where refresh tokens expire in 7 days. **Fix:** Publish the app to Production (step 2) for a long-lived refresh token.
- _Uploads or updates are rejected with insufficient permissions._ The youtube.force-ssl scope was not granted during consent. **Fix:** Add https://www.googleapis.com/auth/youtube.force-ssl to the consent screen and re-authorize.
## Telegram
- **Portal:** https://t.me/BotFather
- **App to create:** a Bot (via @BotFather) that posts to your channel
- **Products to add:**
- **Scopes to request:** ``
**Steps**
1. **Create a bot with BotFather** - Open Telegram, message @BotFather, send /newbot, and follow the prompts. BotFather hands you an HTTP API token - that is the only credential pendpost needs. _(sets env `TELEGRAM_BOT_TOKEN`)_
2. **Create the destination channel** - Create the channel you want to post to. A public channel with an @username gives clean post permalinks; a private channel works too but has no shareable links. Record its @username (or numeric chat id). _(sets env `TELEGRAM_CHANNEL_ID`)_
3. **Add the bot as a channel admin** - In the channel, add the bot as an administrator and grant it "Post Messages". Without admin rights the bot cannot publish.
4. **Confirm the connection** - Run the CLI below to verify the token authenticates and the bot can reach the channel. There is no token exchange - the bot token is static.
```bash
node scripts/telegram-social.mjs auth --client <client-id>
```
**Common failures**
- _Publishing returns "Unauthorized"._ The bot token is wrong or was revoked. **Fix:** Get a fresh token from @BotFather (/token), update TELEGRAM_BOT_TOKEN, and re-run the auth check.
- _Publishing returns "chat not found" or "not enough rights"._ The channel id is wrong, or the bot is not an admin with Post Messages. **Fix:** Confirm TELEGRAM_CHANNEL_ID and add the bot as a channel administrator with posting rights.
## Discord
- **Portal:** https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks
- **App to create:** an Incoming Webhook on the target channel
- **Products to add:**
- **Scopes to request:** ``
**Steps**
1. **Create a channel webhook** - In your Discord server, open the target channel's settings -> Integrations -> Webhooks -> New Webhook. Name it (e.g. pendpost) and pick the channel it posts to.
2. **Copy the full webhook URL** - Click "Copy Webhook URL" and copy the ENTIRE value - it ends in a long token. A truncated URL fails with "Invalid Webhook Token". _(sets env `DISCORD_WEBHOOK_URL`)_
3. **Confirm the connection** - Run the CLI below to verify the webhook resolves. There is no OAuth and no token to mint - the URL is the credential.
```bash
node scripts/discord-social.mjs auth --client <client-id>
```
**Common failures**
- _Publishing returns "Invalid Webhook Token"._ The webhook URL was copied incompletely, or the webhook was deleted. **Fix:** Recreate or re-copy the full webhook URL (Channel -> Integrations -> Webhooks), update DISCORD_WEBHOOK_URL, and re-run the auth check.
- _Posts 404 after working before._ The webhook (or its channel) was deleted in Discord. **Fix:** Create a new webhook on the channel and update DISCORD_WEBHOOK_URL.
## Reddit (beta)
- **Portal:** https://www.reddit.com/prefs/apps
- **App to create:** a "script" type app under your Reddit account
- **Products to add:**
- **Scopes to request:** `submit identity`
**Steps**
1. **Create a script app** - Sign in to Reddit, open the apps page, and click "create another app". Choose the "script" type, give it a name, and set the redirect uri to http://localhost:8088 (unused by a script app but required). The id under the app name is your client id; the "secret" field is your client secret. _(sets env `REDDIT_CLIENT_ID`)_
2. **Record the client secret** - Copy the secret shown next to the app and store it. Treat it like a password. _(sets env `REDDIT_CLIENT_SECRET`)_
3. **Set the posting account credentials** - A script app authenticates as the Reddit account that owns it, using a password grant. Set the username and password of that account. Use a dedicated posting account, not a shared admin login. _(sets env `REDDIT_USERNAME, REDDIT_PASSWORD`)_
4. **Pick the target subreddit** - Set the subreddit you publish to (without the r/ prefix). The account must have permission to post there, and the subreddit rules must allow the kind of content you send. _(sets env `REDDIT_SUBREDDIT`)_
5. **Confirm the connection** - Run the CLI below. It mints a short-lived token with the password grant and reads your identity back. Note the API free tier is non-commercial and rate limited; high-volume or commercial use needs an approved plan.
```bash
node scripts/reddit-social.mjs auth --client <client-id>
```
6. **Radar search (beta)** - The SAME script app also powers Radar (beta) social listening: its `radar` verb searches Reddit for your saved buyer-intent queries. No extra credentials are needed - the four keys above (REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET / REDDIT_USERNAME / REDDIT_PASSWORD) are enough. Reddit Data API Terms restrict large-scale/commercial data use, so keep it BYO-key and rate-limited; single-project self-hosted search is in-bounds.
```bash
node scripts/reddit-social.mjs radar --query '{"keywords":["buffer alternative"],"subreddits":["SocialMediaMarketing"]}'
```
**Common failures**
- _auth returns "401 Unauthorized"._ The client id/secret pair is wrong, or the app is not a "script" type. **Fix:** Recreate the app as a "script" type and copy the id (under the name) and secret exactly into REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET.
- _auth returns "invalid_grant"._ The account username or password is wrong, or the account has two-factor login enabled. **Fix:** Check REDDIT_USERNAME / REDDIT_PASSWORD. Password grant does not support 2FA accounts; use a posting account without 2FA.
- _Publishing returns "SUBREDDIT_NOTALLOWED" or a rules error._ The account cannot post to that subreddit, or the content breaks subreddit rules. **Fix:** Confirm REDDIT_SUBREDDIT, the account has posting access there, and the post matches the subreddit rules.
## Pinterest (beta)
- **Portal:** https://developers.pinterest.com/apps/
- **App to create:** a Pinterest developer app with the v5 API enabled
- **Products to add:**
- **Scopes to request:** `boards:read pins:read pins:write media:write`
**Steps**
1. **Create a developer app** - Open the Pinterest developer portal and create an app. New apps start with Trial access, which can post only to the app owner's own account; Standard access (a separate review) is needed for broader use. Trial access is enough to publish to your own boards. _(sets env `PINTEREST_APP_ID`)_
2. **Record the app secret** - Copy the app secret from the app settings and store it. It is used as HTTP Basic auth on the token endpoint. _(sets env `PINTEREST_APP_SECRET`)_
3. **Add the redirect uri** - In the app settings, add the redirect uri exactly: http://127.0.0.1:8088/oauth/pinterest/callback . The auth command runs a local loopback server on that address to receive the authorization code.
4. **Pick the target board** - Choose the board pins are published to and record its id. The auth command can list your boards after you connect. _(sets env `PINTEREST_BOARD_ID`)_
5. **Authorize** - Run the CLI below. It opens the consent screen, exchanges the code for an access + refresh token, and stores the expiry. The access token is short-lived and is refreshed automatically before it expires.
```bash
node scripts/pinterest-social.mjs auth --client <client-id>
```
**Common failures**
- _The consent screen rejects the redirect uri._ The redirect uri in the app settings does not match the loopback address exactly. **Fix:** Add http://127.0.0.1:8088/oauth/pinterest/callback to the app, with no trailing slash difference, and retry.
- _Publishing returns a 403 about access level._ The app is in Trial access and is targeting an account other than the app owner. **Fix:** Publish to the app owner's own boards while in Trial, or apply for Standard access to post more broadly.
- _Posting fails with an expired-token error._ The stored refresh token was revoked or rotated out. **Fix:** Re-run the auth command to mint a fresh access + refresh token pair.
- _A native video pin fails needing the media:write scope._ The connected token was minted before media:write was added to this app's scope request. **Fix:** Re-run the auth command to reconnect and grant media:write - image pins and board sections keep working on the old token in the meantime.
## TikTok (beta)
- **Portal:** https://developers.tiktok.com/apps/
- **App to create:** a TikTok developer app with Login Kit and the Content Posting API
- **Products to add:** Login Kit, Content Posting API
- **Scopes to request:** `user.info.basic video.upload video.publish`
**Steps**
1. **Create a developer app** - Open the TikTok developer portal and create an app. Add the Login Kit and Content Posting API products. Copy the client key and client secret from the app credentials. _(sets env `TIKTOK_CLIENT_KEY`)_
2. **Record the client secret** - Copy the client secret and store it. It is used on the token exchange and refresh calls. _(sets env `TIKTOK_CLIENT_SECRET`)_
3. **Add the redirect uri** - In the app settings, register the redirect uri exactly: http://127.0.0.1:8088/oauth/tiktok/callback . The auth command runs a local loopback server there to receive the authorization code. _(sets env `TIKTOK_REDIRECT_URI`)_
4. **Authorize** - Run the CLI below. It opens the consent screen for the user.info.basic, video.upload, and video.publish scopes, exchanges the code, and stores the access + refresh tokens with their expiry. The access token is refreshed automatically.
```bash
node scripts/tiktok-social.mjs auth --client <client-id>
```
5. **Note the audit gate** - Until your app passes TikTok content-posting audit, posts are restricted to SELF_ONLY (visible only to the posting account). Submit the app for audit to publish publicly.
**Common failures**
- _The consent screen rejects the redirect uri._ The redirect uri in the app settings does not match the loopback address used by the auth command. **Fix:** Register http://127.0.0.1:8088/oauth/tiktok/callback in the app and set TIKTOK_REDIRECT_URI to the same value.
- _A scope error during authorization._ The app does not have the Content Posting API or the requested scopes approved. **Fix:** Add the Content Posting API product and request the video.upload and video.publish scopes for the app.
- _Posts publish but are not publicly visible._ The app has not passed content-posting audit, so posts are forced to SELF_ONLY. **Fix:** Submit the app for TikTok audit; until then expect SELF_ONLY visibility.
## Mastodon
- **Portal:** https://joinmastodon.org/servers
- **App to create:** an application under your account's Preferences > Development (no developer program, no review)
- **Products to add:**
- **Scopes to request:** `read write:statuses write:media write:accounts write:follows`
**Steps**
1. **Pick your instance** - Mastodon is federated: your account lives on ONE instance (mastodon.social, your own server, ...) and the engine talks to that instance's API directly. Record its base URL, e.g. https://mastodon.social . _(sets env `MASTODON_INSTANCE_URL`)_
2. **Create an application** - Log in to the instance in a browser and open Preferences > Development > New application. Name it (e.g. pendpost), grant the read, write:statuses, write:media, write:accounts and write:follows scopes, and save. write:accounts covers pinning a status to the profile and editing the profile itself; write:follows covers follow/unfollow. No callback URL is needed - the engine uses the app's own access token.
3. **Copy the access token** - Open the application you just created and copy "Your access token" - a static token that never expires unless you regenerate it. _(sets env `MASTODON_ACCESS_TOKEN`)_
4. **Validate** - Run the CLI below. It calls verify_credentials, confirms the token authenticates, and records your @handle for the account link.
```bash
node scripts/mastodon-social.mjs auth --client <client-id>
```
**Common failures**
- _HTTP 401 on auth or publish._ The token was regenerated on the instance, or it belongs to a different instance than MASTODON_INSTANCE_URL. **Fix:** Confirm the instance URL matches where the app was created, regenerate the token there, and reconnect.
- _HTTP 422 on publish for a long post._ The instance caps status length (500 chars by default; instance-configurable). **Fix:** Shorten the caption (or the mastodonCaption override) below the instance cap.
- _Media posts fail while text posts work._ The application is missing the write:media scope. **Fix:** Recreate the application with read, write:statuses AND write:media, then reconnect with the new token.
- _Pin/unpin, follow/unfollow, or profile editing degrade to "needs_scope" (an "Authorize" prompt in the app)._ The token was minted before the application had the write:accounts and write:follows scopes. **Fix:** Recreate the application with ALL FIVE scopes (read, write:statuses, write:media, write:accounts, write:follows), then reconnect with the new token.
## WordPress
- **Portal:** https://wordpress.org/documentation/article/application-passwords/
- **App to create:** an application password on the posting user (no app, no OAuth, no review)
- **Products to add:**
- **Scopes to request:** ``
**Steps**
1. **Record the site URL** - The engine talks to the site's own REST API at <site>/wp-json/wp/v2 - enabled by default on WordPress 5.6+. Record the site root, e.g. https://blog.example.com . _(sets env `WORDPRESS_SITE_URL`)_
2. **Pick the posting user** - Posts publish as this user, so use an Author/Editor account you control. Record its username (login name, not the display name). _(sets env `WORDPRESS_USERNAME`)_
3. **Create an application password** - In wp-admin open Users > Profile > Application Passwords, name it (e.g. pendpost) and create it. Copy the generated password immediately - WordPress shows it once. Spaces in it are fine (they are part of the display format and accepted verbatim). _(sets env `WORDPRESS_APP_PASSWORD`)_
4. **Validate** - Run the CLI below. It calls /users/me and confirms the account can publish posts.
```bash
node scripts/wordpress-social.mjs auth --client <client-id>
```
**Common failures**
- _HTTP 401 with rest_cannot_access or a login error._ Application passwords are disabled (some security plugins turn them off) or the site forces basic-auth off over HTTP. **Fix:** Serve the site over HTTPS, re-enable application passwords in the security plugin, and mint a fresh password.
- _HTTP 403 on publish while auth succeeds._ The posting user's role cannot publish (Contributor drafts only). **Fix:** Give the user the Author role or higher.
- _Posts publish with broken formatting._ The markdown body uses constructs outside the engine's documented subset (lib/markdown.mjs). **Fix:** Stick to the subset (headings, lists, links, emphasis, code, quotes, images) - or paste pre-rendered HTML into the body.
## Ghost
- **Portal:** https://ghost.org/docs/admin-api/
- **App to create:** a custom integration under Settings > Integrations (no review)
- **Products to add:**
- **Scopes to request:** ``
**Steps**
1. **Record the site URL** - The engine talks to the Admin API at <site>/ghost/api/admin . Record the publication's root URL, e.g. https://blog.example.com . _(sets env `GHOST_SITE_URL`)_
2. **Create a custom integration** - In Ghost admin open Settings > Integrations > Add custom integration, name it (e.g. pendpost) and save. Copy the ADMIN API key - the long id:secret pair (the Content API key is read-only and not enough). _(sets env `GHOST_ADMIN_API_KEY`)_
3. **Validate** - Run the CLI below. It mints a short-lived JWT from the key and reads the site record.
```bash
node scripts/ghost-social.mjs auth --client <client-id>
```
4. **Newsletter opt-in (optional)** - A post with "also send as newsletter" enabled emails your members on publish, via the first ACTIVE newsletter. Ghost only sends that email on the draft-to-published transition - the engine handles this, but the flag cannot be re-fired later.
**Common failures**
- _HTTP 401 UNAUTHORIZED on every call._ The Content API key was copied instead of the Admin API key, or the integration was deleted. **Fix:** Copy the ADMIN API key (id:secret) from the custom integration and reconnect.
- _Publish succeeds but no newsletter email arrives._ No active newsletter exists, members are zero, or the post was already published without the flag. **Fix:** Activate a newsletter under Settings > Newsletters and re-check member signups; the email only fires on first publish.
## Nostr
- **Portal:** https://nostr.com
- **App to create:** nothing - Nostr has no accounts, only a keypair you mint yourself
- **Products to add:**
- **Scopes to request:** ``
**Steps**
1. **Mint (or import) a keypair** - Run the keygen CLI below to mint a fresh nsec/npub pair, or reuse an existing nsec from any Nostr client. The nsec IS the account - anyone holding it can post as you, so treat it like a password. _(sets env `NOSTR_PRIVATE_KEY`)_
```bash
node scripts/nostr-social.mjs keygen --save
```
2. **Choose relays** - Posts are published to every relay in the comma-separated list; one acceptance counts as published. Public defaults like wss://relay.damus.io,wss://nos.lol work; a private relay works too. _(sets env `NOSTR_RELAYS`)_
3. **Validate** - Run the CLI below. It derives your npub and proves at least one relay is reachable.
```bash
node scripts/nostr-social.mjs auth --client <client-id>
```
4. **Know the lane's shape** - Two shapes: kind-1 short notes and, via type=nostr-longform, NIP-23 long-form articles (kind 30023) that edit in place on re-publish. Media rides an optional NIP-96 file server (set NOSTR_MEDIA_SERVER): an article carries a header image and a short note embeds an attached image via a NIP-92 imeta tag; without a media server a media post publishes its text only. Deletion is a request (NIP-09) that relays may ignore.
**Common failures**
- _auth reports no reachable relay._ The relay URLs are wrong (must be ws:// or wss://) or the relay is down/blocking writes. **Fix:** Test with a known-good public relay (wss://relay.damus.io) and re-run auth.
- _The engine refuses to start with a WebSocket error._ Node is older than 22, so the global WebSocket client is missing. **Fix:** Upgrade Node to 22+ for the nostr lane (the other lanes run on 20).
## Google Business Profile (beta)
- **Portal:** https://console.cloud.google.com/apis/credentials
- **App to create:** a Google Cloud OAuth client (Desktop/Web) with the Business Profile APIs enabled
- **Products to add:** My Business Account Management API, My Business Business Information API, Google My Business API (v4)
- **Scopes to request:** `https://www.googleapis.com/auth/business.manage`
**Steps**
1. **Request Business Profile API access** - Google gates the Business Profile APIs behind a per-project access request (the form at https://developers.google.com/my-business/content/prereqs). Until approved, every call returns 403 - the lane stays beta.
2. **Create the OAuth client** - In the Cloud console create OAuth credentials, enable the Business Profile APIs above, and register the loopback redirect http://127.0.0.1:8088/oauth/gbp/callback . Copy the client id and secret. _(sets env `GBP_CLIENT_ID, GBP_CLIENT_SECRET`)_
3. **Authorize** - Run the CLI below. It opens the consent screen for business.manage, exchanges the code, stores the tokens, and - once the API access is approved - prints your account and location ids as candidates for the two identifier fields.
```bash
node scripts/gbp-social.mjs auth --client <client-id>
```
4. **Set the target location** - Set GBP_ACCOUNT_ID and GBP_LOCATION_ID to the numeric ids of the verified business location posts should appear on. _(sets env `GBP_ACCOUNT_ID, GBP_LOCATION_ID`)_
**Common failures**
- _Every API call returns 403 after a successful consent._ The Cloud project has not been approved for the Business Profile APIs yet. **Fix:** Submit the access request form and wait for approval; the OAuth token itself is fine.
- _Publish fails with a location error._ GBP_ACCOUNT_ID/GBP_LOCATION_ID are missing, or the location is not verified/owned by the authorized account. **Fix:** Run auth to list candidates, verify the location in the Business Profile manager, and set both ids.
- _The post is created but never becomes visible._ Google reviews local posts; REJECTED state means a content-policy hit. **Fix:** Check verify for the post state and adjust the content (no phone numbers in the summary, policy-safe imagery).
## Radar (beta) sources
Radar (beta) is opt-in, default-off social listening (`posting.radar.enabled`). It searches these sources for your saved buyer-intent queries and ranks hits by intent - it never publishes. Reddit search reuses the SAME script app as Reddit publishing (see the Reddit section above). Bluesky and Hacker News are search-ONLY sources (never publish targets):
### Bluesky
- **Portal:** https://bsky.app/settings/app-passwords
- **What to create:** a Bluesky app-password (Radar search only - beta)
**Steps**
1. **Create an app-password** - Sign in to Bluesky, open Settings -> App Passwords -> Add App Password, name it (e.g. "pendpost Radar"), and copy the generated password. This is NOT your account password - it is a scoped, revocable credential you can delete anytime. _(sets env `BLUESKY_APP_PASSWORD`)_
2. **Set your handle** - Set your Bluesky handle or DID (e.g. you.bsky.social) as the identifier the session logs in with. _(sets env `BLUESKY_IDENTIFIER`)_
3. **Confirm the connection (Radar search only)** - Run the CLI below to search a keyword and read posts back. Bluesky is a Radar SEARCH source in this release - pendpost does not publish to Bluesky here (publishing stays a cloud-side capability).
```bash
node scripts/bluesky-social.mjs radar --query '{"keywords":["pendpost"]}'
```
**Common failures**
- _createSession returns "Invalid identifier or password"._ The handle or app-password is wrong, or the account login password was used instead of an app-password. **Fix:** Use your full handle (you.bsky.social) as BLUESKY_IDENTIFIER and an APP-password (Settings -> App Passwords), not your login password, as BLUESKY_APP_PASSWORD.
- _searchPosts returns HTTP 429._ The PDS is rate-limiting the app-password session. **Fix:** Radar surfaces the rate-limit inline and retries on the next scan; reduce scan frequency or narrow the query.
### Hacker News
- **Portal:** https://hn.algolia.com/api
- **What to create:** nothing - the HN Algolia search index is public (no auth, no write API)
**Steps**
1. **No setup required** - Hacker News search needs NO credentials - the Algolia index is public. It is SURFACE-ONLY (no write API), so Radar marks HN signals copy-paste-only: there is no reply/queue action, you act on them by hand.
```bash
node scripts/hacker-news-social.mjs radar --query '{"keywords":["schedule social posts"]}'
```
**Common failures**
- _The HN source shows a rate-limit note._ The Algolia index throttled the request (~10k req/h shared). **Fix:** Radar retries on the next scan; no action needed. Narrow the query if it persists.