{"slug": "usp-write-once-in-markdown-post-everywhere", "title": "USP – Write once in Markdown, post everywhere", "summary": "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.", "body_md": "**Write once in Markdown. Post everywhere.**\n\n`usp`\n\npublishes 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.\n\n**One source, many platforms**— images and threads preserved.** With or without AI**— raw text, or LLM-tailored per platform.** Preview**the generated text for every platform before anything goes live.** Scriptable**— terminal, pipe, or GitHub Action.\n\nLegend: ✅ supported, 🚧 WIP, ❌ not supported, — n/a.\n\n| Destination | Text | Images | Thread | API | Browser | Setup |\n|---|---|---|---|---|---|---|\n|\n\n[Developer portal](https://developer.x.com/en/portal/dashboard)[Developer apps](https://www.linkedin.com/developers/apps)[OAuth apps](https://www.reddit.com/prefs/apps)[Telegram](https://telegram.org/)[BotFather](https://t.me/BotFather)[Bluesky](https://bsky.app/)[App passwords](https://bsky.app/settings/app-passwords)[Mastodon](https://mastodon.social/)[New application](https://mastodon.social/settings/applications/new)[Discord](https://discord.com/)[Aegea](https://blogengine.me/)[Threads](https://www.threads.net/)[Meta app](https://developers.facebook.com/)Reddit 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.\n\n```\ncurl -fsSL https://raw.githubusercontent.com/adamarutyunov/usp/main/install.sh | sh   # VERSION=v1.0.1 to pin\n```\n\nThe installer clones the repo, builds, and links `usp`\n\nglobally, and pulls Playwright Chromium. Install Google Chrome for `usp login`\n\n. For a manual setup, see [From Source](#from-source).\n\n```\nusp setup              # provider/model, accounts, targets, prompts, defaults\nusp login x            # only for browser-backed platforms (X today); API platforms skip this\nusp publish ./post.md\n```\n\n`usp setup`\n\nwrites credentials to `~/.config/usp/social-auth/`\n\nand routing to `~/.usp.yml`\n\n.\n\n```\nusp publish ./post.md          # publish\nusp preview ./post.md          # generate + save per-platform text, no posting\nusp publish --input \"# Title\\n\\nBody\"\ncat post.md | usp publish --stdin\n```\n\nWithout `--target`\n\n, `usp`\n\nopens 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`\n\n(repeatable) to skip the tree.\n\n`usp preview ./post.md`\n\nwrites one Markdown file per target into a sibling `post.usp-preview/`\n\nfolder (e.g. `post.usp-preview/x-main-default.md`\n\n), with posts separated by a line of dashes:\n\n```\nFirst tweet of the thread.\n----------\nSecond tweet, with an image.\n\n![alt](./chart.png)\n```\n\nEdit 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`\n\ndoes not re-split edited previews. On the next `usp publish ./post.md`\n\nit finds the folder and offers to **reuse** your edited text or **regenerate** from source. Re-running `usp preview`\n\noverwrites the folder (after a confirm).\n\nMarkdown images keep their position — text around an image splits into separate posts in a thread:\n\n```\nFirst post.\n\n![alt](./chart.png)\n\nNext post.\n```\n\nMerged in order, later wins:\n\n```\n~/.config/usp/config.yml          # global\n~/.usp.yml (or ~/usp.config.yml)   # project: accounts, targets, profiles\n~/.config/usp/social-auth/*.yml    # credentials (setup writes these)\n--set key.path=value               # per-invocation override\n```\n\nAuto-discovery only looks in your home directory — never the current working directory. Pass `--config <path>`\n\nto point at a file explicitly (resolved relative to cwd).\n\n**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 as`platform/account/target`\n\n.\n\nReddit (`subreddit`\n\n), Telegram (`chatId`\n\n), and Discord (`threadId`\n\n) route to multiple destinations per account. The rest post to the account's own feed, so their extra targets just carry different prompts.\n\n```\nllm:\n  provider: anthropic\n  model: claude-sonnet-4-6\n\naccounts:\n  x:\n    main:\n      targets:\n        default: {}\n        es: { prompt: { mode: replace, text: \"Escribe un solo tuit en español.\" } }\n  telegram:\n    newsbot:\n      targets:\n        en: { chatId: \"@news\" }\n        ru: { chatId: \"@news_ru\", prompt: { mode: append, text: \"Write in Russian.\" } }\n\nprofiles:\n  default: { targets: [x/main/default, telegram/newsbot/en] }\n```\n\nA profile is a named set of target ids; `--profile`\n\nselects one, `--target`\n\noverrides both.\n\nThree layers, top to bottom: a fixed **base** you can only append to (`globalPrompt`\n\n), **per-platform rules** (append/replace), and a **per-target override** (append/replace). A target `replace`\n\ntakes full control; otherwise the layers stack. Edit them in the `usp setup`\n\ntree, or per run with `--prompt`\n\n.\n\n```\nglobalPrompt: Always write in British English.   # global config; appended to every prompt\n\nprompts:\n  x: { mode: append, text: Dry, factual tone. }   # per-platform\n\naccounts:\n  x:\n    main:\n      targets:\n        es: { prompt: { mode: replace, text: \"...\" } }   # per-target\nusp publish post.md --prompt 'x:append:End with a question.'   # per-run; bare platform:text = replace\npostingDefaults:                # off | as-is | llm, per target\n  x/main/default: llm\n  telegram/newsbot/en: as-is\n```\n\nX, 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`\n\n→ **Media hosting**, or `uploadLocalMedia: true`\n\n) and `usp`\n\nuploads each local image to a temporary anonymous host ([litterbox](https://litterbox.catbox.moe/), ~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.\n\n```\nuploadLocalMedia: true          # host local images for Threads / Reddit\n```\n\nBlank credentials fall back to env vars by convention; config values win. Accounts use `PLATFORM_FIELD`\n\n(`PLATFORM_ACCOUNT_FIELD`\n\ntakes precedence for multi-account), the LLM uses `PROVIDER_API_KEY`\n\n/ `PROVIDER_AUTH_TOKEN`\n\n.\n\n| Platform | Variables |\n|---|---|\n| LLM | `ANTHROPIC_API_KEY` , `OPENAI_API_KEY` , `GEMINI_API_KEY` |\n| X | `X_CONSUMER_KEY` , `X_CONSUMER_SECRET` , `X_ACCESS_TOKEN` , `X_ACCESS_TOKEN_SECRET` |\n`LINKEDIN_ACCESS_TOKEN` , `LINKEDIN_AUTHOR` |\n|\n`REDDIT_CLIENT_ID` , `REDDIT_CLIENT_SECRET` , `REDDIT_REFRESH_TOKEN` |\n|\n| Telegram | `TELEGRAM_BOT_TOKEN` |\n| Aegea | `AEGEA_BASE_URL` , `AEGEA_PASSWORD` |\n| Bluesky | `BLUESKY_IDENTIFIER` , `BLUESKY_APP_PASSWORD` |\n| Mastodon | `MASTODON_INSTANCE_URL` , `MASTODON_ACCESS_TOKEN` |\n| Discord | `DISCORD_WEBHOOK_URL` |\n| Threads | `THREADS_ACCESS_TOKEN` , `THREADS_USER_ID` |\n\nAny field follows the rule — `PLATFORM`\n\n+ the config key in `UPPER_SNAKE_CASE`\n\n(e.g. `DISCORD_THREAD_ID`\n\n).\n\nOAuth 1.0a (`consumerKey`\n\n/`consumerSecret`\n\n/`accessToken`\n\n/`accessTokenSecret`\n\n); needed for media. `POST /2/tweets`\n\ncan return billing errors with valid credentials. Also supports browser posting (`usp login x`\n\n).\n\n`w_member_social`\n\n, an access token, and a person URN (`author: urn:li:person:...`\n\n), plus an API `version`\n\n. [Walkthrough](https://marcusnoble.co.uk/2025-02-02-posting-to-linkedin-via-the-api/).\n\nOAuth self-posts. Local images are linked in the body, not uploaded. `subreddit`\n\nis set per target.\n\nBot token on the account; `chatId`\n\n(channel `@handle`\n\n, group, or chat) per target.\n\nAuthor-password flow over HTTP (`baseUrl`\n\n, `password`\n\n). Images uploaded and rendered in order.\n\nApp password + AT Protocol (`identifier`\n\n, `appPassword`\n\n, `pdsUrl`\n\n). Multi-unit posts become a reply thread.\n\n`instanceUrl`\n\n+ access token with `read:statuses`\n\n, `write:statuses`\n\n, `write:media`\n\n. `visibility`\n\nis an account default.\n\nIncoming webhook (one channel, no bot). One webhook = one account; `threadId`\n\nper target. `username`\n\n/`avatarUrl`\n\noptional.\n\nMeta Graph API with `threads_basic`\n\n, `threads_content_publish`\n\n. Only accepts public media URLs; local images need **Media hosting** enabled (see [Local media on URL-only platforms](#local-media-on-url-only-platforms)). Short-lived tokens are auto-exchanged for long-lived ones at setup and refreshed before publish.\n\nShared by `plan`\n\n/ `preview`\n\n/ `publish`\n\n/ `browser:post`\n\n:\n\n| Option | |\n|---|---|\n`-c, --config <path>` |\nconfig file (auto-discovered as `~/.usp.yml` /`~/usp.config.yml` ) |\n`-p, --profile <name>` |\nprofile to publish (default `default` ) |\n`-t, --target <id>` |\ntarget id, repeatable; skips the picker |\n`--set <key.path=value>` |\nconfig override, repeatable |\n`--prompt <platform[:append|replace]:text>` |\nprompt override, repeatable (bare = replace) |\n`--input <markdown>` / `--stdin` |\ninline / piped Markdown |\n\n```\nusp init [-o <path>]                         # write a starter .usp.yml\nusp setup [--platform <p> --account <name> -v key=value ...]   # wizard, or scripted\nusp accounts                                 # print configured accounts\nusp account:set <platform> <name> -v key=value ...\nusp login [platform] [--browser chrome|chromium|msedge] [--controlled] [--headless] [--profile-dir <p>] [--url <u>]\nusp plan    [markdown]                        # print the plan as JSON\nusp preview [markdown]                        # generate + save text\nusp publish [markdown]                        # generate + publish\nusp browser:post [markdown]                   # experimental deterministic browser posting\n```\n\n| Input | Default | |\n|---|---|---|\n`markdown` |\n— | file to publish (required) |\n`config` |\n`.usp.yml` |\nconfig path |\n`profile` |\n`default` |\nprofile to publish |\n\nCommit `.usp.yml`\n\nwithout secrets; pass credentials via `env:`\n\n(see [Environment variables](#environment-variables)).\n\n```\non:\n  release: { types: [published] }\njobs:\n  post:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - run: |\n          cat > post.md <<'MD'\n          # ${{ github.event.repository.name }} ${{ github.event.release.tag_name }}\n          ${{ github.event.release.body }}\n          ${{ github.event.release.html_url }}\n          MD\n      - uses: adamarutyunov/usp@v1\n        with: { markdown: post.md, profile: release }\n        env:\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n          X_CONSUMER_KEY: ${{ secrets.X_CONSUMER_KEY }}\n          X_CONSUMER_SECRET: ${{ secrets.X_CONSUMER_SECRET }}\n          X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}\n          X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}\ngit clone https://github.com/adamarutyunov/usp && cd usp\npnpm install && pnpm build && pnpm link --global\npnpm exec playwright install chromium\n```\n\nMIT", "url": "https://wpnews.pro/news/usp-write-once-in-markdown-post-everywhere", "canonical_source": "https://github.com/adamarutyunov/usp", "published_at": "2026-06-17 23:35:02+00:00", "updated_at": "2026-06-17 23:52:44.977685+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "generative-ai"], "entities": ["USP", "Markdown", "X", "LinkedIn", "Reddit", "Telegram", "Bluesky", "Mastodon"], "alternates": {"html": "https://wpnews.pro/news/usp-write-once-in-markdown-post-everywhere", "markdown": "https://wpnews.pro/news/usp-write-once-in-markdown-post-everywhere.md", "text": "https://wpnews.pro/news/usp-write-once-in-markdown-post-everywhere.txt", "jsonld": "https://wpnews.pro/news/usp-write-once-in-markdown-post-everywhere.jsonld"}}