{"name":"io.github.pendpost/pendpost","slug":"pendpost","title":null,"description":"Local-first social MCP: an agent drafts and schedules posts; you approve before anything publishes.","url":"https://mcp.market/server/pendpost","rating":null,"grade":"B","score":83,"certified":false,"status":"active","category":"ai","tags":["ai"],"presence":{"score":28,"stars":9,"forks":3,"downloads_week":null,"last_push_at":"2026-09-19T12:24:12.000Z","license":"MIT"},"uptime":null,"claimed":false,"transport":"npm","callable_via_gateway":false,"default_price_micros":0,"repository":"https://github.com/pendpost/pendpost","website":"https://pendpost.com","version":"2.6.0","remotes":[],"packages":[{"registryType":"npm","identifier":"pendpost","version":"2.6.0","runtimeHint":"npx","transport":{"type":"stdio"},"packageArguments":[{"type":"named","name":"--stdio"}]}],"tools":[{"name":"account_status","description":"Connection health per platform (Meta/LinkedIn/YouTube): configured, authenticated, token expiry, Meta action-block state (block.tracked is false until a block was ever recorded), scheduler state. Token values are never returned. Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"activity_log","description":"The pendpost audit feed: every publish attempt, scheduler start/stop and circuit-breaker event, newest first ({ts, campaign, postId, platform, action, ok, errorCode, errorMessage, lateMin, actor}). Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"agent_recheck","description":"Prove the operator's configured agent CLI (posting.radar.agent.provider) can actually run Radar research: spawns it once with a trivial prompt and pendpost's own MCP config, and requires it to call pendpost_health. Returns { ok, agent: { state, ok, detail, checkedAt } }. `state` is \"live\" ONLY if the tool call actually landed on this daemon - the agent's own answer is never taken as proof, because","write_action":false,"price_micros":0,"input_schema":null},{"name":"approve_post","description":"Approve a post for publishing (approval: approved). Required actor; the actor who created the post can NEVER approve it (no self-approval; only the actor \"owner\" is exempt as the approval authority). Per the standing rule, agents call this only on the owner's explicit instruction - approval always comes from the owner.","write_action":true,"price_micros":0,"input_schema":null},{"name":"asset_upload","description":"Ingest a new media file into data/media (the asset library). Provide filePath (a repo-local file to copy in) OR base64 bytes, plus the target filename. Refuses to overwrite an existing file and only accepts .mp4/.mov/.jpg/.png. The HTTP upload route in the pendpost UI uses the same implementation with a streamed binary body.","write_action":true,"price_micros":0,"input_schema":null},{"name":"assets_list","description":"List publishable renders in data/media with ffprobe specs, spec checks (9x16/4x5, h264, faststart), cover JPEG, which plan posts use each file, and the matching voiceover caption SRTs (captions[] with srtPath/srtUrl) - the canonical source when drafting social copy. Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"autonomy_revoke","description":"Revoke autonomy that unwinds (ux-audit R7 / AU4): return EVERY not-yet-published post the auto-approve policy already approved (approvalBy policy:auto-approve, status != posted - both auto-approved drafts and auto-posted Radar replies) to review, clearing the approval back to a pending draft. Owner-only (autonomy is owner-authorized); de-escalation only - it can never approve or publish anything. ","write_action":false,"price_micros":0,"input_schema":null},{"name":"brand_lint","description":"Lint caption/copy text against the editable brand rules in rules.json: platform hygiene (per-platform caption length cap, broken/empty links, ALL-CAPS shouting, hashtag-count sanity) plus a humanizer that flags AI-writing tells (AI-vocabulary, em-dash overuse, rule-of-three padding, negative parallelism, filler/hedging, promotional puffery). errors block publish; warns are advisory. Optional platf","write_action":false,"price_micros":0,"input_schema":null},{"name":"campaign_create","description":"Create a new campaign: writes data/plans/<id>/post-plan.json (empty posts) and registers it active in the manifest.","write_action":true,"price_micros":0,"input_schema":null},{"name":"campaign_set_active","description":"Activate/deactivate a campaign in the manifest. Inactive campaigns are ignored by the scheduler tick (explicitly-targeted runs still reach them).","write_action":false,"price_micros":0,"input_schema":null},{"name":"campaign_set_internal","description":"Flag a campaign internal (or not) in the manifest. Internal campaigns (e.g. validation/test plans) drop out of the operator views (Published/Planner/Approvals) by default while staying fully active and schedulable - so a live validation campaign can keep running without cluttering the operator UI. Display-only: no scheduler impact.","write_action":false,"price_micros":0,"input_schema":null},{"name":"clear_cover","description":"Remove a post's cover override (deletes the override JPEG and the post.cover field; the render-sibling JPEG becomes the cover again).","write_action":true,"price_micros":0,"input_schema":null},{"name":"client_archive","description":"Toggle a client between active and archived (reversible). Owner-gated: requires actor:\"owner\" and confirm:true. Refuses to archive the currently active client (switch first). ARCHIVE SAFETY: archiving computes the client's in-flight work first and returns it as inFlight { total, local, native, posts }. When the platform itself already holds scheduled objects (native > 0: FB scheduled post, YouTube","write_action":false,"price_micros":0,"input_schema":null},{"name":"client_create","description":"Create a new client workspace (non-secret profile only: id slug, displayName, optional logo/accent/timezone). Owner-gated: requires actor:\"owner\" and confirm:true (fail-closed). Scaffolds data/clients/<id>/. Never touches credentials. Prefer per-call clientId on other tools over switching the active client.","write_action":true,"price_micros":0,"input_schema":null},{"name":"client_list","description":"List the configured clients and the active one: { activeClientId, clients: [{ id, displayName, status, timezone?, accent?, logo?, schedulerRunning, actionBlocked }] }. schedulerRunning is the PER-CLIENT scheduler enabled flag (each client's own state.scheduler.enabled, default-ON, so it can differ per row - a client the operator stopped reads false while its siblings stay true); actionBlocked is t","write_action":false,"price_micros":0,"input_schema":null},{"name":"client_set_active","description":"Switch the GLOBAL active client. Owner-gated: requires actor:\"owner\" and confirm:true. PREFER passing per-call clientId on other tools instead - this mutates global default state and is the core \"posted to the wrong client\" risk. The target must exist and be active. Never touches credentials.","write_action":false,"price_micros":0,"input_schema":null},{"name":"client_update","description":"Update a client's non-secret profile (displayName/logo/accent/timezone); id is immutable. Owner-gated: requires actor:\"owner\" and confirm:true. Requires ifRev (echo the rev from client_list/GET /api/clients) for optimistic concurrency; a stale rev returns stale_write. Never touches credentials.","write_action":true,"price_micros":0,"input_schema":null},{"name":"clients_overview","description":"Cross-client roll-up of pending/overdue work, scheduler state and the Meta-368 breaker, one row per registered client: { activeClientId, clients: [{ id, displayName, status, ready, schedulerRunning, pending, overdue, metaBlocked, nextDue, error }] }. pending counts due posts in the horizon (waiting-due + overdue), overdue counts the past-due subset, nextDue is the soonest due ISO timestamp (or nul","write_action":false,"price_micros":0,"input_schema":null},{"name":"cloud_capabilities","description":"Read the lane-capability map the UI badges lanes with (cloud 24/7 / native / local-only), proxied from the cloud's PUBLIC unauthenticated /v1/capabilities. Needs NO workspace and NO api key (pre-purchase honesty), is cached, and degrades to the conservative baked-in fallback offline - it never fails. Twin of GET /api/cloud/capabilities. Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"cloud_clients","description":"Read every local client with its per-brand always-on flag plus the install-global cloud connection summary (the \"cloud clients\" view): { ok, connection, clients: [{ clientId, name, active, alwaysOn }] }. Pure local read; no secrets. Twin of GET /api/cloud/clients. Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"cloud_status","description":"Read the managed-cloud (pendpost-cloud) connection status for THIS install: { enabled, baseUrl, workspaceId, apiKey presence-only } plus `sync` - the always-on guarantee roll-up that drives the header dot. `sync.state` has FOUR values, do not collapse them to \"broken vs not\": green = every approved cloud-lane post is confirmed on the cloud; yellow = push pending (transient, normal for <=1 tick aft","write_action":false,"price_micros":0,"input_schema":null},{"name":"cloud_subscription","description":"Read the metered subscription view proxied from the cloud (the in-app meter): { alwaysOn, status, allowance, postsUsed, postsIncluded, billingMode, currentPeriodEnd, action, checkoutEligible }. The api key (server-side, never exposed) scopes it to the workspace; no Stripe ids, no secrets. Requires a connected workspace - returns a stable error (e.g. not_configured / no_api_key) when the cloud is n","write_action":false,"price_micros":0,"input_schema":null},{"name":"comment_inbox","description":"Read the aggregated UNANSWERED comments on YOUR OWN recently-published posts (the own-post comment inbox the monitor sweep maintains): { ok, enabled, lastSweep, intervalHours, windowDays, unanswered, posts:[{ campaign, postId, platform, lanes, caption, permalink, unanswered, comments:[{ lane, commentId, author, text, ts, permalink, foundAt }] }], sources:{ lane:{ ok }|{ ok:false, error, scope } } ","write_action":false,"price_micros":0,"input_schema":null},{"name":"comment_inbox_refresh","description":"Force a check-now sweep of your own recently-published posts for NEW comments, then return the fresh inbox (same shape as comment_inbox). Bypasses the interval cadence clock; OFF (posting.commentWatch.enabled=false) stays inert. Reaches the platforms read-only (never posts). Twin of POST /api/comments/inbox/refresh.","write_action":false,"price_micros":0,"input_schema":null},{"name":"comment_resolve","description":"Mark one own-post comment handled so it leaves the inbox and never re-surfaces: { ok, key, reason, removed }. `key` is the inbox item key `lane:postId:commentId` (from comment_inbox posts[].comments[]). reason is 'replied' or 'dismissed' (audit only). This is a LOCAL state stamp, NOT a platform write - the actual reply goes through reply_to_comment. Idempotent. Twin of POST /api/comments/inbox/res","write_action":false,"price_micros":0,"input_schema":null},{"name":"config_get","description":"Read the pendpost configuration: non-secret per-platform identifiers (Meta page/IG/app id, LinkedIn org urn + api version, YouTube redirect uri), posting variables (defaultLink, utm, hashtagPresets, defaultTimezone), and per-secret presence/tail/expiry (never the token value). Returns a rev for optimistic concurrency. Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"config_set","description":"Edit NON-SECRET config only: identifiers (written to .env via a whitelisted, hardened writer) and posting variables (config.json). Secrets are display-only and can NEVER be set here - rotate them via the engine CLI (node scripts/<engine>.mjs auth). Autonomy keys are OWNER-GATED: posting.autoApprove, the gate refinements posting.approvalExpiryHours / posting.slotSlipMinutes (both default null = off","write_action":false,"price_micros":0,"input_schema":null},{"name":"connect_discover","description":"Read who one connected lane authenticates as + which assets it can manage (x/youtube/discord/linkedin/wordpress/reddit/pinterest/gbp): { ok, platform, connected, assetKind, identity:{id,handle,name,avatarUrl?}, assets:[{ kind, id, name, current, meta? }], selected:{<identifierKey>:<value|null>}, needsScope?, scope?, error? }. assetKind is the lane noun (channel/board/location/page/section/guild). ","write_action":false,"price_micros":0,"input_schema":null},{"name":"delete_asset","description":"Delete one media file from data/media (the asset library), including its paired .jpg cover sibling. Confirm-gated + in-use-protected: if any plan post references the file it refuses with needs_confirm naming the using post(s) (campaign/postId) and deletes nothing unless confirm:true is passed (with confirm:true the plan rows are left dangling by design, mirroring plan_delete_post force). Rejects p","write_action":true,"price_micros":0,"input_schema":null},{"name":"engage_answer","description":"Answer ONE question from \"Respond for me\" (spec 50 row 8) so the reply can go out. Send the fact in one line, the way you would tell a colleague (\"the reviewer link is in every tier, including Starter\"); pendpost spawns the drafting agent with your answer as the AUTHORITATIVE fact, humanizes the result, re-runs every hard rule (brand lint, the stranger-link fence, the per-platform limits) and queu","write_action":false,"price_micros":0,"input_schema":null},{"name":"engage_asks_list","description":"Read what \"Respond for me\" needs a HUMAN for (spec 50 §7.7): { ok, mode, open, asks:[{ id, kind, status, lane, signalKey, actionId, question, draft, finalText, reasonLine, urgent, answer, createdAt, resolvedAt, signal:{ source, externalId, author, community, text, url, intentScore } | null }] }. Five kinds, each with exactly one resolution: `question` = the agent needs a fact only the owner has (p","write_action":false,"price_micros":0,"input_schema":null},{"name":"engage_cancel","description":"Cancel ONE queued \"Respond for me\" action before it happens (spec 50 row 5): the row goes to status cancelled and nothing is posted, liked or followed. Only a row that has not been handed to an executor can be cancelled - queued, or posting_soon (inside the 15-minute grace window that reposts, messages, original posts and replies to large accounts sit in). A row that is already releasing, done or ","write_action":true,"price_micros":0,"input_schema":null},{"name":"engage_community_recheck","description":"Forget what \"Respond for me\" believes about ONE community's automation rules and let it look again (spec 50 row 7e5): clears the cached rule for that lane+community and drops the `decision` on every signal there that was skipped BECAUSE of it, so the next triage run judges them from scratch. Use it when a subreddit / instance / group changed its rules, or when the cached \"this community bans autom","write_action":false,"price_micros":0,"input_schema":null},{"name":"engage_confirm","description":"Post a reply that \"Respond for me\" held for a look (spec 50 row 8e): a `confirm` ask carries the finished text plus one line saying why it was held (\"Checked before posting: this touches pricing\"). Calling this queues that text, optionally edited via `text`, and SKIPS the 15-minute grace window - grace exists to call back a row nobody read, and you just read this one. The wording still runs throug","write_action":true,"price_micros":0,"input_schema":null},{"name":"engage_confirm_handle","description":"Confirm (or deny) that the account a platform check SAW is this client's account, before \"Respond for me\" is allowed to act as it (spec 50 row 2e2, D13). ok:true copies the seen handle into posting.radar.engage.lanes[lane].handle and marks the platform ready; ok:false marks it wrong_account and it stays unusable until the operator switches the account in Chrome and re-checks. There is deliberately","write_action":false,"price_micros":0,"input_schema":null},{"name":"engage_dismiss","description":"Skip ONE ask from \"Respond for me\" (spec 50 row 8e2): the ask closes and the signal it belongs to records `skip` with the reason `owner`, so the feed row reads \"Skipped by you\" instead of inventing a machine reason for a human decision. Nothing is posted, and the thread is not re-decided by a later triage run - a person already decided it. Use it for a question you do not want to answer, a held re","write_action":false,"price_micros":0,"input_schema":null},{"name":"engage_pause","description":"Pause or resume ALL of \"Respond for me\" for this client (spec 50 row 12). This is a RESUMABLE hold, deliberately NOT the same thing as turning the mode off: the mode, the platform switches and every queued row keep their state, nothing is released while it is on, and resuming re-paces the backlog from where it stood. Use it when you want everything to stop right now and to be able to undo that in ","write_action":false,"price_micros":0,"input_schema":null},{"name":"engage_probe","description":"Check ONE platform now for \"Respond for me\" (spec 50 rows 2e / 2e2) and record what it found: { ok, lane, route, usable, reason, detail, lastProbeAt }. `route` is how this platform acts for this client - \"api\" (the lane engine holds a credential) or \"browser\" (it needs the operator's own logged-in Chrome). An API platform runs the SAME liveness probe health_recheck runs and sets reason ready (it a","write_action":false,"price_micros":0,"input_schema":null},{"name":"engage_queue_list","description":"Read the \"Respond for me\" (auto-engage, spec 50) action list for this client: { ok, mode, paused, actions:[{ id, signalKey, lane, kind, status, waitingOn, releaseAt, graceUntil, attempts, rung, result, askId, text, createdAt }] }. Each row is one thing the policy will do - or already did - to one Radar signal: a reply, a like/upvote, a follow, a repost, a message or an original post. `status` is q","write_action":false,"price_micros":0,"input_schema":null},{"name":"engage_undo","description":"Take back ONE action \"Respond for me\" already performed (spec 50 §7.9): delete the reply or original post it published, remove the like/upvote, unfollow, un-repost. Only a row whose status is `done` can be undone - a queued row is CANCELLED (engage_cancel), a dry_run row never touched a platform, and a failed row never landed. The reversal is attempted FIRST and the original row is marked `undone`","write_action":false,"price_micros":0,"input_schema":null},{"name":"fetch_insights","description":"Fetch fresh platform metrics for published posts (spawns the engines' read-only insights commands across the FREE measured lanes: IG/FB Graph insights with defensive metric fallback, YouTube videos.list statistics, LinkedIn share statistics, GBP performance, Pinterest pin analytics, Telegram subscriber counts, Ghost email opens/sends/clicks, Nostr reaction/zap counts, Reddit score/comments/upvote-","write_action":false,"price_micros":0,"input_schema":null},{"name":"generate_digest","description":"Render the performance digest (locale-aware markdown, rendered in the active client posting locale en or de-CH with locale-aware dates) from stored metrics + plan state: published posts of the last 7 days with per-platform metrics, all measured posts, queue/overdue/scheduler/account health, the next due posts. Honest about gaps (\"no metrics yet\"). Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"health_recheck","description":"Run a live liveness probe per platform and store the result in pendpost state (account_status.<platform>.live). Each probe is a single read-only call that proves the credential actually authenticates (LinkedIn token introspection, YouTube channels.list, Meta GET me) - it can never publish. The Meta probe is skipped while a 368 block is recorded. Returns { ok, health }. Use this to actively re-prob","write_action":true,"price_micros":0,"input_schema":null},{"name":"list_comments","description":"Read the inbound comments on one POSTED post, normalized across lanes (meta/youtube/linkedin/wordpress/reddit/tiktok/telegram/mastodon/nostr/discord): { ok, items:[{ kind, commentId, author, text, ts, postId, permalink?, parentId? }], platform, postId, needsScope?, scope? }. Optional platform picks the lane when a post hit several; otherwise the first comment-capable lane. Pull-on-demand, never pe","write_action":false,"price_micros":0,"input_schema":null},{"name":"list_inbound_events","description":"Read the normalized, idempotently-merged inbound-event feed (comment | mention | message | reaction): { ok, events:[{ eventId, type, platform, clientId, postId, externalPostId, author, text, reaction, parentId, permalink, ts }] }, newest first. Every pull merges the cloud's latest delta into a capped per-client store (deduped by `eventId`) and returns the WHOLE accumulated store, so the feed is st","write_action":false,"price_micros":0,"input_schema":null},{"name":"mark_posted","description":"Mark a planned post as posted because the owner published it natively OUTSIDE pendpost (e.g. in the Meta/LinkedIn app). Sets status:posted so it leaves the publish-due queue; NEVER triggers a real publish and never mints a platform id. Optionally record the externalUrl of the live post. On a MIXED multi-lane post pass `platform` to record ONE lane only - the post stays open (keeps owing its other ","write_action":false,"price_micros":0,"input_schema":null},{"name":"mastodon_update_profile","description":"Edit the connected Mastodon profile (display name, bio/note, website, avatar, header/banner) via accounts/update_credentials (scope write:accounts). Account-level, not a post. This makes a REAL, immediate change to the live account - only call it on the owner's explicit instruction. Requires confirm: true to apply. probe: true runs a read-only access-tier check and changes nothing. image/banner ar","write_action":true,"price_micros":0,"input_schema":null},{"name":"meta_lane_set","description":"Set the Meta (Facebook/Instagram) publishing lane: tune the anti-ban cadence cap and/or pause/resume the lane. Pass cadence:{maxPer24h,minGapMinutes} to set the cap (maxPer24h must be >=1 - the cap can never be disabled - minGapMinutes >=0, both integers); pass paused:true (with an optional reason) to STOP all Meta publishing or paused:false to resume. Cadence and pause/reason co-exist in one file","write_action":false,"price_micros":0,"input_schema":null},{"name":"moderate_comment","description":"Moderate one inbound comment on a posted post (the twin of list_comments): hide/unhide/delete/approve/hold/spam/remove via the lane's moderation REST, returning { ok, id, platform, postId, commentId, action }. Each lane supports a SUBSET (meta: hide/unhide/delete; youtube: hold/approve/spam/delete; wordpress: approve/hold/spam/delete; linkedin/telegram/discord: delete; reddit: remove/approve/spam ","write_action":false,"price_micros":0,"input_schema":null},{"name":"nostr_update_profile","description":"Edit the connected Nostr kind-0 profile metadata (name, about, picture, nip05, website) by GET-merging the current profile off a relay and publishing a freshly-signed kind-0 event (NIP-01) to every configured relay - ok when at least one relay accepts it. Account-level, not a post. This makes a REAL, immediate change to the live profile - only call it on the owner's explicit instruction. Requires ","write_action":true,"price_micros":0,"input_schema":null},{"name":"pendpost_health","description":"One-call readiness check (SS-10): { ready, blockers[], schedulerRunning, nextDue[] } - global blockers (manifest, credentials, Meta block, scheduler off) plus per-post blockers for the next N due posts. Use this for an overall go/no-go readiness roll-up; use health_recheck to actively re-probe a single lane's credential. Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"pendpost_record_block","description":"Record a Meta action block (error 368) so pendpost and the scheduler skip the Meta lane. A 368 carries no machine-readable clear time, so the block stays active until it is EXPLICITLY cleared: pass blockedUntil: null (source: \"owner\") to record \"block cleared\" once you have confirmed out of band that Meta lifted it. blockedUntil on a new block is only a recorded-at anchor, not an auto-expiry. Use ","write_action":false,"price_micros":0,"input_schema":null},{"name":"pinterest_board_create","description":"Create a Pinterest board: { ok, id, name }. privacy is one of PUBLIC|PROTECTED|SECRET (Pinterest defaults to PUBLIC when omitted). NOT idempotent - repeated calls each create a new board. A missing boards:write scope (a token minted before spec 29) returns not_configured with the scope to authorize - a reconnect, not App-Review. A name clash or other rejected field returns invalid_input. Twin of P","write_action":true,"price_micros":0,"input_schema":null},{"name":"pinterest_boards_list","description":"List this Pinterest account's boards: { ok, platform:\"pinterest\", boards:[{id,name,privacy,pinCount}], current:<PINTEREST_BOARD_ID|null> }. Shares the same read connect_discover uses for pinterest, plus privacy/pinCount. A read failure resolves ok:false with an error (not_configured | needs_scope | engine_failure) - a failed read is NEVER a false-empty { ok:true, boards:[] }. Read-only, open-world","write_action":false,"price_micros":0,"input_schema":null},{"name":"pinterest_list_board_sections","description":"List a Pinterest board's sections: { ok, platform:\"pinterest\", boardId, items:[{id,name}] }. boardId is optional (defaults to the connected PINTEREST_BOARD_ID). A missing board/credential, an ungranted scope (media:write predates a token minted before spec 17 does NOT block this read - boards:read already covers it), or a read failure resolves ok:false with an error (not_configured | needs_scope |","write_action":false,"price_micros":0,"input_schema":null},{"name":"plan_delete_post","description":"Delete a post row from its plan, in one motion: any natively scheduled platform object the row still owns (FB scheduled post, YouTube publishAt video, Mastodon queue entry, WordPress future post, Ghost scheduled post, Discord guild event) is cancelled platform-side FIRST, then the row is removed - no separate unschedule ceremony. A native cancel failure returns engine_failure with the row intact. ","write_action":true,"price_micros":0,"input_schema":null},{"name":"plan_get","description":"Get the full normalized posts of one campaign (captions, schedule, platforms, per-platform publish ids, media availability, approval, derived state). Optionally a single post via postId. Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"plan_list","description":"List all social campaigns (plan files) with per-state post counts, the next due post, schedulerRunning and manifestError. Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"platform_validate","description":"Per-platform readiness of one post: media present, caption length caps, credentials/auth, Meta action block, YouTube future-publishAt, approval state. Returns { platform: { ready, problems[] } }. Use this for full per-platform publish readiness; use validate_media for just the local media probe. Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"presubmit_check","description":"Check a post's reddit/tiktok platform-specific submission rules before publish: subreddit flair/title/restricted-type/submission-type rules (reddit, checked against THIS post's resolved redditSubreddit) and creator caption/privacy limits (tiktok, via creator_info). Returns { ok, postId, platforms: { <reddit|tiktok>: { ready, problems:[{code,text}], warnings:[{code,text}] } } } - only reddit/tiktok","write_action":false,"price_micros":0,"input_schema":null},{"name":"publish_due_run","description":"Run one publish-due sweep NOW (spawns the real engines for due, approved posts; optionally scoped to one campaign/post). This publishes REAL content - only call it on the owner's explicit instruction, and never while a Meta 368 block is active. Requires confirm: true.","write_action":true,"price_micros":0,"input_schema":null},{"name":"publish_preview","description":"Read-only publish preview / dry-run (C3): for each due post in the horizon, reports which posts would fire, on which lanes, in which mode (mock|live), and with what blockers - { ok, ready, schedulerRunning, posts:[{campaign, postId, scheduledAt, platforms:[{platform, lane, mode, ready, blockers[]}]}] }. facebook + instagram both resolve to the meta lane (mode is resolveMode of the LANE, matching t","write_action":true,"price_micros":0,"input_schema":null},{"name":"radar_agent_comparison","description":"Have the operator's own agent write the comparison page one Radar backlog entry is asking for. pendpost spawns it with the entry's buyer phrases as the brief and it calls radar_draft_comparison itself; the page lands as a DRAFT for a human to edit. Read the entries from radar_list geo.comparisonBacklog[]. Requires a connected agent AND a connected long-form lane (wordpress or ghost) - a comparison","write_action":false,"price_micros":0,"input_schema":null},{"name":"radar_agent_scan","description":"Run a REAL research job on the operator's own agent CLI: pendpost spawns it with the saved query as a brief and its own MCP config, and the agent researches and calls radar_ingest itself. Returns { ok, enabled, job } where job is { id, queryId, scope, providerId, startedAt, finishedAt, state: running|done|failed, accepted, dropped, deduped, exitCode, reason, tail }. The counts are pendpost's OWN t","write_action":true,"price_micros":0,"input_schema":null},{"name":"radar_agent_stop","description":"Stop the running Radar agent research job for this client. The child is killed, the job ends state:\"failed\" with reason:\"stopped\", and anything it ALREADY ingested stays in the feed (those were real findings; deleting them because the search was stopped would be its own kind of lie). Returns { ok, stopped, job }. jobId is optional - at most one job runs per client - but when given it must match th","write_action":false,"price_micros":0,"input_schema":null},{"name":"radar_draft_comparison","description":"Draft the comparison page one Radar backlog entry is asking for (e.g. \"pendpost vs Buffer\"), as a DRAFT post in the named campaign. Read the entries from radar_list geo.comparisonBacklog[] - each carries { key, title, buyerPhrases[], examples[] }: the phrases are what real buyers actually typed, so write the page that answers THOSE, not a feature grid. backlogKey must match a current entry (pendpo","write_action":false,"price_micros":0,"input_schema":null},{"name":"radar_followup_check","description":"Check now whether the original authors of the threads you replied into have replied BACK to your posted Radar replies: { ok, enabled, checked, replied, sources, agentFollowup? }. For every posted reply not yet answered, a READ-only re-read of your comment's thread on reddit/mastodon/bluesky; on a hit it stamps an \"author replied\" badge on that signal (and the daily digest counts it). x/youtube/nos","write_action":false,"price_micros":0,"input_schema":null},{"name":"radar_followup_report","description":"Report ONE follow-up check result for a target your follow-up brief listed: did the thread's ORIGINAL author answer OUR posted reply on this x/youtube/nostr thread? Call it exactly once per target from your brief. replied:false records \"checked, no answer yet\" (lastCheckedTs only). replied:true is an EVIDENCE-GATED claim and every field is verified server-side against what pendpost itself recorded","write_action":false,"price_micros":0,"input_schema":null},{"name":"radar_footprint_log","description":"Log one LLM-footprint result for the Radar (beta) GEO layer: you (the agent) run a buying question against your OWN model access, then report whether pendpost was mentioned. Appends { question, mentioned, competitorsMentioned, excerpt, assistant, ts } to the footprint trend (state.radar.geo.footprint) - pendpost NEVER calls a model itself (zero-dep), it only stores your reported result. Over time ","write_action":false,"price_micros":0,"input_schema":null},{"name":"radar_geo_reset","description":"Reset (clear) this client's Radar GEO / AI-visibility state: the agent-logged footprint results, the derived comparison-page backlog, and the dismissed-backlog ledger. Use this when a project's KI-Sichtbarkeit state was polluted with another brand's competitors or buying questions - a config edit cannot fix it, because this is Radar STATE, not config. OWNER-ONLY (it drops agent-logged history). Id","write_action":false,"price_micros":0,"input_schema":null},{"name":"radar_ingest","description":"Submit conversations you found (with your own web search / browse) as scored Radar (beta) signals - the credential-free scan path. First read the saved queries via config_get (posting.radar.queries); then, for the queryId you are scanning, search the named sources AND the open web for RECENT conversations where someone asks for, recommends, or compares tools like this product, and submit the candi","write_action":true,"price_micros":0,"input_schema":null},{"name":"radar_list","description":"Read the cached Radar (beta) signal feed + GEO summary: { ok, enabled, view, items:[Signal], lastScan, sources, geo:{ comparisonBacklog, footprint, footprintRate, shareOfVoice, buyingQuestions }, capabilities, nextScan, lastProduced }. nextScan says when the next UNATTENDED runs fire, derived from the same gate the scheduler ticks: { timezone, dailyAt, agent:{ armed, at, lastAt, budget, spent }, k","write_action":false,"price_micros":0,"input_schema":null},{"name":"radar_mark_copy_posted","description":"Record that a COPY-DRAFT Radar (beta) signal was posted BY HAND. The copy-draft lanes (hackernews, nostr, and non-Enterprise x) and reddit KARMA post-ideas have no reply API, so the operator copies the drafted text and posts it themselves - there is no minted id and no replied evidence to prove it went out. This stores a durable { postedUrl?, ts } marker on the signal (keyed by source+externalId, ","write_action":false,"price_micros":0,"input_schema":null},{"name":"radar_scan","description":"Run the project's saved Radar queries now and return the ranked, deduped signal feed. This is the DEMOTED plain keyword-match path (spec 41): radar_agent_scan (real agent research) is the PRIMARY scan - prefer it when a proven-live agent is configured; this one only string-matches the saved queries and never drafts. Returns: { ok, enabled, items:[{ source, externalId, url, author, text, matchedQue","write_action":true,"price_micros":0,"input_schema":null},{"name":"radar_triage","description":"Triage one cached Radar (beta) signal OR one GEO comparison-backlog entry. For a SIGNAL: dismiss / watch / clear - dismiss removes it from the feed and records it so a re-scan never re-surfaces it (US6); watch pins it to the top and exempts it from the retention prune (US7); clear undoes both. Identify the signal by source (any items[].source: reddit/hackernews/bluesky/mastodon plus the agent-inge","write_action":false,"price_micros":0,"input_schema":null},{"name":"radar_triage_bulk","description":"Dismiss a LIST of cached Radar (beta) signals in ONE call - e.g. to clear out a low-quality query's whole output at once instead of one radar_triage per row. Pass ids as an array of { source, externalId } (each source is any items[].source: reddit/hackernews/bluesky/mastodon plus the agent-ingested x/youtube/web; each externalId from radar_scan/radar_list items[]) and action \"dismiss\". Dismiss sem","write_action":false,"price_micros":0,"input_schema":null},{"name":"react_to_post","description":"React to one inbound comment or mention on a posted post (the reaction twin of list_comments): like/favourite/boost/emoji via the lane's reaction REST, returning { ok, id, platform, postId, commentId, reaction, removed }. Each lane supports a SUBSET (linkedin: like/praise/empathy/appreciation/interest/entertainment; mastodon: favourite/boost; nostr: like/emoji; telegram/discord: emoji; meta/youtub","write_action":true,"price_micros":0,"input_schema":null},{"name":"read_insights","description":"Read the STORED post metrics + history WITHOUT re-fetching (the twin of GET /api/insights). Returns { ok, lastFetch, items:[{ campaign, postId, platform, metrics, fetchedAt, history, postType, caption, mode }], metricLabels, mode, account, summary }. summary is the performance-memory ranking (R8): { hasEnough, measured, minMeasured, byLane, byType, byHour }, each a list of { key, avg, total, posts","write_action":false,"price_micros":0,"input_schema":null},{"name":"reddit_list_flairs","description":"List a subreddit's link-flair templates: { ok, platform:\"reddit\", subreddit, items:[{id,text,editable,cssClass}] }. subreddit is optional (defaults to the connected REDDIT_SUBREDDIT). A missing credential/subreddit, an ungranted flair scope, or a read failure resolves ok:false with an error (not_configured | needs_scope | engine_failure) - a failed read is NEVER a false-empty { ok:true, items:[] }","write_action":false,"price_micros":0,"input_schema":null},{"name":"reject_post","description":"Reject a post (approval: rejected) with an optional note explaining what to fix. Same actor rules as approve_post.","write_action":true,"price_micros":0,"input_schema":null},{"name":"rename_asset","description":"Rename one media file within data/media, renaming its paired .jpg cover sibling to match. sanitizeAssetName runs on BOTH names (rejects path segments / leading dots / bad charset / a disallowed extension); the extension may NOT change. Never overwrites: an existing toName is invalid_input. Confirm-gated + in-use-protected: renaming a file referenced by a plan post breaks that post's media referenc","write_action":false,"price_micros":0,"input_schema":null},{"name":"reply_to_comment","description":"Reply to one inbound comment on a posted post (the paired WRITE of list_comments): posts the reply through the lane's reply verb and returns { ok, id, platform, postId, commentId }. Operator-triggered - Reddit especially is human-gated (never auto-reply). This posts PUBLIC text to the live thread immediately (no approval fence), so it is confirm-gated for agents: actor 'owner' (the Studio Comments","write_action":false,"price_micros":0,"input_schema":null},{"name":"reply_to_inbound_event","description":"Answer one inbound X Activity event from the inbox (the paired WRITE of list_inbound_events): mention/reply -> a tweet reply via X's mention-exemption; message -> a direct message to the sender; reaction/follow -> not repliable (returns code not_repliable, no thread to answer). Finds the event by eventId in the local per-client inbox store and returns { ok, id, platform, eventId }. This posts PUBL","write_action":false,"price_micros":0,"input_schema":null},{"name":"reschedule","description":"Move a post to a new scheduledAt (ISO datetime). Waiting-due posts just change their due time; NATIVELY-scheduled posts have their platform object(s) deleted and re-queue for the new time (confirm: true required). Use this to move a post's time; use unschedule to park it entirely.","write_action":false,"price_micros":0,"input_schema":null},{"name":"resume_lane","description":"Resume a publishing lane that an account-level circuit breaker halted (the first case: X HTTP 402 - API credits depleted, which pauses the WHOLE x lane so retries do not burn quota). Clears the lane block, releases the posts that lane's credits failures parked, then re-fires those posts immediately as a credit recheck and reports the outcome: { cleared, released, published, stillDepleted, pending ","write_action":false,"price_micros":0,"input_schema":null},{"name":"reviewer_create","description":"Invite a reviewer to a client's review link: mints a 128-bit token and returns { ok, reviewer, token, actorString }. The token (the whole link identity) is returned EXACTLY ONCE and is NEVER retrievable again - only its 4-char tail is displayable afterwards; if it is lost, revoke and invite again. Owner-gated: requires actor:\"owner\" (minting a bearer capability is owner-only, the posting.autoAppro","write_action":true,"price_micros":0,"input_schema":null},{"name":"reviewer_list","description":"List a client's review-link reviewers (the client-review-link identities): [{ id, name, tokenTail, createdAt, createdBy, revokedAt, expiresAt, revoked, expired, active }]. Targets the per-call clientId (else the active client). Responses carry ONLY the 4-char token tail - the full link is shown exactly once at mint (reviewer_create) and is never retrievable again. Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"reviewer_revoke","description":"Revoke a client's reviewer by id: the token stops verifying immediately and the review link dies (the intended lifecycle end, matrix row 19). Owner-gated: requires actor:\"owner\". Idempotent - revoking an already-revoked reviewer is a no-op success. Returns { ok, reviewer } with revoked:true. To restore access, invite again (reviewer_create) - a revoked token can never be un-revoked. Never touches ","write_action":false,"price_micros":0,"input_schema":null},{"name":"scheduler_set","description":"Start or stop the in-process publish scheduler (60s tick over active campaigns; only approved + fully-scheduled posts publish). The setting persists across restarts.","write_action":false,"price_micros":0,"input_schema":null},{"name":"set_cover","description":"Set a cover override for one post. Pass exactly ONE source: frameSec (extract that second of the post's own video), filePath (a repo-local JPEG/PNG/WebP), base64 (image bytes, max 4 MB), or coverUrl (an absolute https image URL the CALLING APP hosts). coverUrl is the Instagram grid-safe path: IG applies it as cover_url on the reel container, so a calling-app-designed title card that centres its te","write_action":false,"price_micros":0,"input_schema":null},{"name":"telegram_update_profile","description":"Edit the MANAGED Telegram channel's title, description, and/or photo (setChatTitle / setChatDescription / setChatPhoto) - NOT the bot's own BotFather profile. Account-level, not a post. This makes a REAL, immediate change to the live channel - only call it on the owner's explicit instruction. Requires confirm: true to apply. probe: true runs a read-only access-tier check (confirms the bot administ","write_action":true,"price_micros":0,"input_schema":null},{"name":"token_refresh","description":"Refresh a platform token programmatically. linkedin and x are refreshable (each wraps its engine's refresh); Meta uses a long-lived page token and YouTube refreshes per call. On refresh-token expiry the hint carries the interactive re-auth command.","write_action":false,"price_micros":0,"input_schema":null},{"name":"unschedule","description":"Take a post off the schedule (executionMode: parked, so the scheduler ignores it). If the post is NATIVELY scheduled (FB scheduled post / YouTube publishAt video / Mastodon scheduled status / WordPress future post / Ghost scheduled post), every such platform object is DELETED via the engines - that needs confirm: true. Use this to park a post; use reschedule to move it to a new time, or reject_pos","write_action":false,"price_micros":0,"input_schema":null},{"name":"validate_media","description":"Probe one post's local media file (ffprobe): resolution/codec/faststart/duration spec checks for 9:16 story / 4:5 feed. Use this for just the local file probe; use platform_validate for full per-platform publish readiness, or publish_preview for a dry-run across due posts. Read-only.","write_action":false,"price_micros":0,"input_schema":null},{"name":"verify_post","description":"Read a handed-off post back from its platforms to confirm it is actually live (turns the guessed fired-assumed state into verified-live or verify-failed). Read-only against the platforms - spawns each engine's read-only verify subcommand and records the result in a non-destructive post.verify block (no publish, no minted id, no status change); this local annotation is why it is not flagged read-on","write_action":true,"price_micros":0,"input_schema":null},{"name":"x_update_profile","description":"Edit the connected X (Twitter) profile (name, bio <=160, url, location, profile image, 1500x500 banner) via the v1.1 account/* endpoints (OAuth 1.0a). Account-level, not a post. This makes a REAL, immediate change to the live account - only call it on the owner's explicit instruction. Requires confirm: true to apply. probe: true runs a read-only access-tier check and changes nothing. image/banner ","write_action":true,"price_micros":0,"input_schema":null},{"name":"youtube_playlist_add","description":"Add a published YouTube video to a playlist: { ok, id, playlistId, videoId, duplicate? }. Pass campaign+postId to resolve a scheduled post's published ytVideoId, or an ad-hoc videoId directly with no post context. Re-adding an already-present video reports duplicate:true rather than inserting a second item. A missing youtube/youtube.force-ssl write scope returns not_configured with the scope to au","write_action":false,"price_micros":0,"input_schema":null},{"name":"youtube_playlist_create","description":"Create a YouTube playlist: { ok, id, title }. privacy is one of public|unlisted|private, defaulting to \"private\". NOT idempotent - repeated calls each create a new playlist. A missing youtube/youtube.force-ssl write scope (a token minted with only youtube.upload) returns not_configured with the scope to authorize - a reconnect, not App-Review. Twin of POST /api/youtube/playlists.","write_action":true,"price_micros":0,"input_schema":null},{"name":"youtube_playlists_list","description":"List this YouTube channel's playlists: { ok, platform:\"youtube\", playlists:[{id,title,privacy,itemCount}], needsScope?, scope? }. Pull-on-demand, never persisted, no write side effect. Read-only, open-world (reaches YouTube). Twin of GET /api/youtube/playlists.","write_action":false,"price_micros":0,"input_schema":null},{"name":"youtube_update_profile","description":"Edit the YouTube channel's branding (description, keywords, country, defaultLanguage) and/or localizations via a GET-merge-PUT over channels?part=brandingSettings,localizations (scope youtube - readonly cannot write). Account-level, not a post; the channel DISPLAY NAME (snippet.title) is NOT writable via this API. This makes a REAL, immediate change to the live channel - only call it on the owner'","write_action":true,"price_micros":0,"input_schema":null}],"scan":{"score":83,"grade":"B","scanned_at":"2026-09-19T19:53:40.671Z","report":{"scannerVersion":"0.1.5","scannedAt":"2026-09-19T19:53:40.610Z","components":{"code":{"score":20,"max":25,"notes":["85 source files scanned"]},"reliability":{"score":-1,"max":20,"notes":["no gateway calls yet and no remote to probe"]},"poisoning":{"score":-1,"max":15,"notes":["tools not inspected (local package is not executed); not counted"]},"auth":{"score":12,"max":15,"notes":["local package, no credentials required"]},"maintenance":{"score":15,"max":15,"notes":["last push 0 days ago"]},"identity":{"score":7,"max":10,"notes":["registry namespace matches repository owner"]}},"findings":[{"id":"net.raw-ip","severity":"medium","component":"code","title":"Network call to a raw IP address","evidence":"app/dist/assets/main-BjKYdBTa.js: …?.reviewUrl?e.reviewUrl:e?.link?e.link:`http://127.0.0.1:8091/review/${e?.token||``}`}var PS=[`none`,`…"}],"inputs":{"packages":[{"registryType":"npm","identifier":"pendpost","version":"2.6.0","found":true,"license":"MIT","hasInstallScripts":false,"dependencyCount":0,"publishedAt":"2026-09-19T12:26:37.028Z","repositoryUrl":"git+https://github.com/pendpost/pendpost.git"}],"repo":{"found":true,"owner":"pendpost","repo":"pendpost","archived":false,"pushedAt":"2026-09-19T12:24:12Z","stars":9,"forks":3,"openIssues":1,"ownerType":"Organization","ownerAvatarUrl":"https://avatars.githubusercontent.com/u/293761785?v=4","ownerCreatedAt":"2026-06-15T06:19:42Z","license":"MIT"},"icon":{"url":"https://avatars.githubusercontent.com/u/293761785?v=4&s=128","source":"registry","width":128,"height":128},"presence":{"stars":9,"forks":3,"downloadsWeek":null,"license":"MIT","lastPushAt":"2026-09-19T12:24:12.000Z","score":28}}}},"grade_history":[],"reviews":[]}