cd /news/developer-tools/usp-write-once-in-markdown-post-ever… · home topics developer-tools article
[ARTICLE · art-31875] src=github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

USP – Write once in Markdown, post everywhere

USP is a new open-source tool that lets users write content once in Markdown and publish it to multiple social media platforms including X, LinkedIn, Reddit, Telegram, Bluesky, Mastodon, Discord, Aegea, and Threads. The tool supports both raw posting and AI-rewritten versions tailored to each platform, and can be used via terminal, pipe, or GitHub Action.

read7 min views1 publishedJun 17, 2026

Write once in Markdown. Post everywhere.

usp

publishes one Markdown file to X, LinkedIn, Reddit, Telegram, Bluesky, Mastodon, Discord, Aegea, and Threads — each target posted as-is or rewritten by an LLM to fit the platform.

One source, many platforms— images and threads preserved.** With or without AI**— raw text, or LLM-tailored per platform.** Previewthe generated text for every platform before anything goes live. Scriptable**— terminal, pipe, or GitHub Action.

Legend: ✅ supported, 🚧 WIP, ❌ not supported, — n/a.

Destination Text Images Thread API Browser Setup

Developer portalDeveloper appsOAuth appsTelegramBotFatherBlueskyApp passwordsMastodonNew applicationDiscordAegeaThreadsMeta appReddit posts via the OAuth submit endpoint (self-posts), so local images are linked in the body rather than uploaded. Browser posting (Playwright) currently backs X; everything else is native API.

curl -fsSL https://raw.githubusercontent.com/adamarutyunov/usp/main/install.sh | sh   # VERSION=v1.0.1 to pin

The installer clones the repo, builds, and links usp

globally, and pulls Playwright Chromium. Install Google Chrome for usp login

. For a manual setup, see From Source.

usp setup              # provider/model, accounts, targets, prompts, defaults
usp login x            # only for browser-backed platforms (X today); API platforms skip this
usp publish ./post.md

usp setup

writes credentials to ~/.config/usp/social-auth/

and routing to ~/.usp.yml

.

usp publish ./post.md          # publish
usp preview ./post.md          # generate + save per-platform text, no posting
usp publish --input "# Title\n\nBody"
cat post.md | usp publish --stdin

Without --target

, usp

opens an interactive tree to set each target to off, as-is (raw Markdown), or LLM (rewritten). The first publish offers to save that selection as the default. Pass --target platform/account/name

(repeatable) to skip the tree.

usp preview ./post.md

writes one Markdown file per target into a sibling post.usp-preview/

folder (e.g. post.usp-preview/x-main-default.md

), with posts separated by a line of dashes:

First tweet of the thread.
----------
Second tweet, with an image.

![alt](./chart.png)

Edit those files freely — rewrite text, move images, or add/remove dash lines to split or merge posts. The dash lines and post lengths are yours to manage; usp

does not re-split edited previews. On the next usp publish ./post.md

it finds the folder and offers to reuse your edited text or regenerate from source. Re-running usp preview

overwrites the folder (after a confirm).

Markdown images keep their position — text around an image splits into separate posts in a thread:

First post.

![alt](./chart.png)

Next post.

Merged in order, later wins:

~/.config/usp/config.yml          # global
~/.usp.yml (or ~/usp.config.yml)   # project: accounts, targets, profiles
~/.config/usp/social-auth/*.yml    # credentials (setup writes these)
--set key.path=value               # per-invocation override

Auto-discovery only looks in your home directory — never the current working directory. Pass --config <path>

to point at a file explicitly (resolved relative to cwd).

Platform— built-in; owns the per-platform prompt rules.** Account**— credentials for one identity (a login, bot, or webhook). Many per platform.** Target**— where an account posts, plus an optional prompt. Addressed asplatform/account/target

.

Reddit (subreddit

), Telegram (chatId

), and Discord (threadId

) route to multiple destinations per account. The rest post to the account's own feed, so their extra targets just carry different prompts.

llm:
  provider: anthropic
  model: claude-sonnet-4-6

accounts:
  x:
    main:
      targets:
        default: {}
        es: { prompt: { mode: replace, text: "Escribe un solo tuit en español." } }
  telegram:
    newsbot:
      targets:
        en: { chatId: "@news" }
        ru: { chatId: "@news_ru", prompt: { mode: append, text: "Write in Russian." } }

profiles:
  default: { targets: [x/main/default, telegram/newsbot/en] }

A profile is a named set of target ids; --profile

selects one, --target

overrides both.

Three layers, top to bottom: a fixed base you can only append to (globalPrompt

), per-platform rules (append/replace), and a per-target override (append/replace). A target replace

takes full control; otherwise the layers stack. Edit them in the usp setup

tree, or per run with --prompt

.

globalPrompt: Always write in British English.   # global config; appended to every prompt

prompts:
  x: { mode: append, text: Dry, factual tone. }   # per-platform

accounts:
  x:
    main:
      targets:
        es: { prompt: { mode: replace, text: "..." } }   # per-target
usp publish post.md --prompt 'x:append:End with a question.'   # per-run; bare platform:text = replace
postingDefaults:                # off | as-is | llm, per target
  x/main/default: llm
  telegram/newsbot/en: as-is

X, Telegram, Bluesky, Mastodon, Discord, and Aegea upload local image files directly. Threads and Reddit can't — they only accept a public URL — so local images are skipped there by default. Enable hosting (in usp setup

Media hosting, or uploadLocalMedia: true

) and usp

uploads each local image to a temporary anonymous host (litterbox, ~1h expiry, no account) and uses that URL, so images work on Threads and in Reddit posts. The image bytes briefly transit that third-party host.

uploadLocalMedia: true          # host local images for Threads / Reddit

Blank credentials fall back to env vars by convention; config values win. Accounts use PLATFORM_FIELD

(PLATFORM_ACCOUNT_FIELD

takes precedence for multi-account), the LLM uses PROVIDER_API_KEY

/ PROVIDER_AUTH_TOKEN

.

Platform Variables
LLM ANTHROPIC_API_KEY , OPENAI_API_KEY , GEMINI_API_KEY
X X_CONSUMER_KEY , X_CONSUMER_SECRET , X_ACCESS_TOKEN , X_ACCESS_TOKEN_SECRET
LINKEDIN_ACCESS_TOKEN , LINKEDIN_AUTHOR
REDDIT_CLIENT_ID , REDDIT_CLIENT_SECRET , REDDIT_REFRESH_TOKEN
Telegram TELEGRAM_BOT_TOKEN
Aegea AEGEA_BASE_URL , AEGEA_PASSWORD
Bluesky BLUESKY_IDENTIFIER , BLUESKY_APP_PASSWORD
Mastodon MASTODON_INSTANCE_URL , MASTODON_ACCESS_TOKEN
Discord DISCORD_WEBHOOK_URL
Threads THREADS_ACCESS_TOKEN , THREADS_USER_ID

Any field follows the rule — PLATFORM

  • the config key in UPPER_SNAKE_CASE

(e.g. DISCORD_THREAD_ID

).

OAuth 1.0a (consumerKey

/consumerSecret

/accessToken

/accessTokenSecret

); needed for media. POST /2/tweets

can return billing errors with valid credentials. Also supports browser posting (usp login x

).

w_member_social

, an access token, and a person URN (author: urn:li:person:...

), plus an API version

. Walkthrough.

OAuth self-posts. Local images are linked in the body, not uploaded. subreddit

is set per target.

Bot token on the account; chatId

(channel @handle

, group, or chat) per target.

Author-password flow over HTTP (baseUrl

, password

). Images uploaded and rendered in order.

App password + AT Protocol (identifier

, appPassword

, pdsUrl

). Multi-unit posts become a reply thread.

instanceUrl

  • access token with read:statuses

, write:statuses

, write:media

. visibility

is an account default.

Incoming webhook (one channel, no bot). One webhook = one account; threadId

per target. username

/avatarUrl

optional.

Meta Graph API with threads_basic

, threads_content_publish

. Only accepts public media URLs; local images need Media hosting enabled (see Local media on URL-only platforms). Short-lived tokens are auto-exchanged for long-lived ones at setup and refreshed before publish.

Shared by plan

/ preview

/ publish

/ browser:post

:

Option
-c, --config <path>
config file (auto-discovered as ~/.usp.yml /~/usp.config.yml )
-p, --profile <name>
profile to publish (default default )
-t, --target <id>
target id, repeatable; skips the picker
--set <key.path=value>
config override, repeatable
`--prompt <platform[:append replace]:text>`
prompt override, repeatable (bare = replace)
--input <markdown> / --stdin
inline / piped Markdown
usp init [-o <path>]                         # write a starter .usp.yml
usp setup [--platform <p> --account <name> -v key=value ...]   # wizard, or scripted
usp accounts                                 # print configured accounts
usp account:set <platform> <name> -v key=value ...
usp login [platform] [--browser chrome|chromium|msedge] [--controlled] [--headless] [--profile-dir <p>] [--url <u>]
usp plan    [markdown]                        # print the plan as JSON
usp preview [markdown]                        # generate + save text
usp publish [markdown]                        # generate + publish
usp browser:post [markdown]                   # experimental deterministic browser posting
Input Default
markdown
file to publish (required)
config
.usp.yml
config path
profile
default
profile to publish

Commit .usp.yml

without secrets; pass credentials via env:

(see Environment variables).

on:
  release: { types: [published] }
jobs:
  post:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          cat > post.md <<'MD'
          ${{ github.event.release.body }}
          ${{ github.event.release.html_url }}
          MD
      - uses: adamarutyunov/usp@v1
        with: { markdown: post.md, profile: release }
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          X_CONSUMER_KEY: ${{ secrets.X_CONSUMER_KEY }}
          X_CONSUMER_SECRET: ${{ secrets.X_CONSUMER_SECRET }}
          X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
          X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}
git clone https://github.com/adamarutyunov/usp && cd usp
pnpm install && pnpm build && pnpm link --global
pnpm exec playwright install chromium

MIT

── more in #developer-tools 4 stories · sorted by recency
── more on @usp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/usp-write-once-in-ma…] indexed:0 read:7min 2026-06-17 ·