Imported from paragraph-xyz/skill (
paragraph-cli/SKILL.md). Install upstream withnpx skills add paragraph-xyz/skill --skill paragraph-cli. Copyright stays with the author (MIT).
Paragraph CLI
CLI for Paragraph — a web3 publishing and newsletter platform. Use it to manage posts, drafted content, publications, subscribers, and coins.
For direct HTTP or SDK access without installing anything, see the paragraph-api skill instead.
MCP Server (recommended for MCP-compatible clients)
If your client supports MCP, use the Paragraph MCP server instead of the CLI for a more integrated experience.
Remote server (recommended)
Use the hosted server at mcp.paragraph.com — no installation or API key management required. Authenticates via your Paragraph account in the browser.
claude mcp add paragraph --transport http https://mcp.paragraph.com/mcp
For other clients, add https://mcp.paragraph.com/mcp as a server URL in your MCP configuration.
Local server
If you prefer to run the server locally (requires Node.js 18+):
claude mcp add paragraph -- npx @paragraph-com/mcp
Local mode requires an API key via PARAGRAPH_API_KEY env var or paragraph login from the CLI.
The MCP server exposes 29 tools (posts, content, publications, subscribers, coins, search, feed, users, me, analytics, emails) and shares authentication with the CLI. See full docs.
Notable tools added recently: create-content / list-content / get-content / update-content / archive-content / restore-content (drafted X posts, LinkedIn posts, newsletters, and X Articles — these draft only, they never send), send-custom-email (markdown email blast to a recipient list — requires publication approval), update-publication (settings, featured post, pinned posts, email-notification toggles), remove-subscriber (hard delete by email or wallet), update-post accepts publishedAt for backdating, and update-post accepts imageUrl (set/replace the cover image) and clearImage (remove the existing cover).
CLI Setup
Install globally:
npm install -g @paragraph-com/cli
Authenticate — login persists the key to ~/.paragraph/config.json:
paragraph login --token <api-key>
echo "$PARAGRAPH_API_KEY" | paragraph login --with-token
Or skip login and pass the key per-command via env var:
PARAGRAPH_API_KEY=<api-key> paragraph post list --json
Verify: paragraph whoami --json
Working agreement
- Always use
--jsonfor parseable output. Data goes to stdout, status/errors to stderr. - Always use
--yesondeleteto skip confirmation prompts. - Use
--dry-runbeforedelete,publish, andarchiveto preview what will happen. - Use flags, not just positional args. Every identifier accepts
--idso you can chain commands. - Pipe content via stdin when creating or updating posts from files:
cat draft.md | paragraph post create --title "My Post" - Paginate with
--limitand--cursor. The JSON response includespagination.cursorandpagination.hasMore. - Do not use interactive login. Use
--tokenor--with-tokenfor non-interactive auth. - Check auth before running commands. Run
paragraph whoami --jsonto verify credentials are valid. - Do not publish without explicit user approval. Publishing sends content live and optionally emails subscribers.
- Default to draft.
post createcreates drafts. Only callpost publishwhen the user asks. contentcommands never send. They only write to the publication's library; the writer sends the piece from the Paragraph app. Don't tell the user something went out.- Do not send custom emails without explicit user approval.
paragraph email senddelivers real email and can't be undone. Draft the subject and body first; use--dry-runto preview filtering before a real send. On a403, surface "this publication isn't approved for custom email yet" and stop — do not retry. - Respect rate limits. If you get
RATE_LIMITED, wait and retry. Avoid tight loops between paginated requests.
Commands
Posts
# Create a draft
paragraph post create --title "My Post" --file ./post.md --tags "web3,defi" --json
paragraph post create --title "My Post" --text "# Hello World" --subtitle "A subtitle" --json
cat content.md | paragraph post create --title "My Post" --json
# List
paragraph post list --json
paragraph post list --status draft --limit 20 --json
paragraph post list --status scheduled --json
paragraph post list --publication <slug-or-id> --json
# Get (by ID, URL, or @pub/slug)
paragraph post get --id <post-id> --json
paragraph post get @my-blog/post-slug --json
# Extract a single field
paragraph post get --id <post-id> --field markdown > post.md
paragraph post get --id <post-id> --field title
# Update
paragraph post update --id <id-or-slug> --title "New Title" --json
paragraph post update --id <id-or-slug> --text "Updated content" --subtitle "New subtitle" --json
paragraph post update --id <id-or-slug> --file ./updated.md --tags "new,tags" --json
# Backdate (publishedAt sticks across re-publishes — useful for imported content)
paragraph post update --id <id-or-slug> --published-at "2024-01-01T00:00:00Z" --json
# Set/replace cover image (URL is fetched + re-hosted on Paragraph's CDN)
paragraph post update --id <id-or-slug> --image-url https://example.com/cover.jpg --json
# Remove the existing cover image
paragraph post update --id <id-or-slug> --clear-image --json
# Publish
paragraph post publish --id <id-or-slug> --json
paragraph post publish --id <id-or-slug> --newsletter --json
# Revert to draft
paragraph post draft --id <id-or-slug> --json
# Archive
paragraph post archive --id <id-or-slug> --json
# Schedule a post for future publication
paragraph post schedule --id <id-or-slug> --at "2026-05-01T09:00:00Z" --json
paragraph post schedule --id <id-or-slug> --at "2026-05-01T09:00:00Z" --newsletter --json
# Cancel a scheduled publication
paragraph post unschedule --id <id-or-slug> --json
# Create and schedule in one step
paragraph post create --title "Launch Day" --file ./post.md --schedule-at "2026-05-01T09:00:00Z" --newsletter --json
# Preview destructive actions
paragraph post delete --id <id-or-slug> --dry-run --json
paragraph post publish --id <id-or-slug> --dry-run --json
# Delete
paragraph post delete --id <id-or-slug> --yes --json
# Send test newsletter email
paragraph post test-email --id <post-id> --json
# Browse
paragraph post feed --limit 10 --json
paragraph post by-tag --tag web3 --limit 20 --json
Content
Drafted short-form content: X posts and threads, LinkedIn posts, one-off emails, and X Articles. These commands only draft — nothing is posted, emailed, or scheduled. Long-form posts are paragraph post.
--kind is tweet, linkedin, newsletter, or x_article, and the body flags follow the kind: --tweet (repeatable, one per tweet in a thread) for tweet, --subject and --preheader for newsletter, --headline and --canonical-url for x_article. The long text comes from --text, --file, or stdin. Drafts are text-only; media has to be added in the app.
# Create
paragraph content create --kind tweet --title "Launch note" --text "We shipped it." --json
paragraph content create --kind tweet --title "Thread" --tweet "First." --tweet "Second." --json
paragraph content create --kind linkedin --title "Launch note" --file ./post.md --json
paragraph content create --kind newsletter --title "October update" --subject "What we shipped" --file ./body.md --json
paragraph content create --kind x_article --title "Editor rewrite" --headline "Why we rebuilt the editor" --file ./article.md --json
cat post.md | paragraph content create --kind linkedin --title "Launch note" --json
# List (--status defaults to all, which excludes archived pieces)
paragraph content list --json
paragraph content list --kind tweet --status draft --limit 50 --json
paragraph content list --status archived --json
# Get, with its body
paragraph content get <id> --json
paragraph content get <id> --field body
# Update — the body is replaced, not merged. Read it first.
paragraph content update <id> --title "Launch note, second pass" --json
paragraph content update <id> --text "Rewritten, and shorter." --json
# Archive and restore
paragraph content archive <id> --dry-run --json
paragraph content archive <id> --json
paragraph content restore <id> --json
A piece with a queued send is locked: editing its body is refused (the schedule has to be cancelled in the app), but renaming always works. lockedReason in the JSON says why, or is null when the piece is editable.
Content groups
A group is one identity for a post and everything made out of it — the post, the thread drawn from it, the LinkedIn version, the newsletter. It's what the writer sees as a single stacked row under Content.
When repurposing a post, group what you draft with it, or the writer gets unrelated drafts that don't know about each other.
# Get or create a post's group. Safe to repeat: the same ID comes back.
paragraph content bucket create <post-id> --json
# Draft into it
BUCKET=$(paragraph content bucket create <post-id> --json | jq -r .bucketId)
paragraph content create --kind tweet --title "Thread" --tweet "First." --bucket "$BUCKET" --json
# Group a draft made before the group existed
paragraph content update <id> --bucket "$BUCKET" --json
# Read a group and everything already made from its post
paragraph content bucket get <bucket-id> --json
# Find a post's group without creating one (bucketId is null when there is none)
paragraph content bucket for-post <post-id> --json
# List every group, most recently active first
paragraph content bucket list --limit 50 --json
Read the group before drafting so you don't remake a version that already exists. Each member's kind says where to read it: post with paragraph post get, content with paragraph content get. A kind of other is a member Paragraph groups but the CLI can't fetch.
A piece already grouped with a different post is refused rather than moved — tell the user to ungroup it in the app. Taking a piece out of a group is done in the app.
Publications
paragraph publication get --id <slug-or-id-or-domain> --json
# Update settings (only provided fields change). The publication ID must
# match the publication that owns your API key — run `paragraph whoami` to
# look it up.
paragraph publication update <publication-id> --name "My Blog" --theme-color purple-600 --json
# Featured post: a post ID, or one of "latest" | "popular" | "disabled"
paragraph publication update <publication-id> --featured-post latest --json
# Pinned posts (replaces the existing list, max 50, IDs must belong to this publication)
paragraph publication update <publication-id> --pinned-post-ids id1,id2,id3 --json
# Owner email-notification toggles (key=value pairs).
# Allowed keys: newComment, newSubscriber, newPaidSubscriber, newContentCollected.
paragraph publication update <publication-id> --email-notifications newSubscriber=true,newComment=false --json
# Apply a full settings body from a JSON file. Explicit flags override file values.
paragraph publication update <publication-id> --from-json ./settings.json --json
Search
paragraph search post --query "ethereum" --json
paragraph search blog --query "web3" --json
Subscribers
paragraph subscriber list --limit 100 --json
paragraph subscriber count --publication <id> --json
paragraph subscriber add --email user@example.com --json
paragraph subscriber add --wallet 0x1234...abcd --json
paragraph subscriber import --csv ./subscribers.csv --json
# Remove (hard delete, irreversible — prompts for confirmation; use --yes to skip)
paragraph subscriber remove --email user@example.com --yes --json
paragraph subscriber remove --wallet 0x1234...abcd --yes --json
Coins
paragraph coin get --id <id-or-address> --json
paragraph coin popular --limit 10 --json
paragraph coin search --query "ethereum" --json
paragraph coin holders --id <id-or-address> --limit 50 --json
paragraph coin quote --id <id-or-address> --amount <wei> --json
Users
paragraph user get --id <user-id-or-wallet> --json
Analytics
Run read-only SQL against your publication's analytics schema. Scoped automatically to the publication that owns your API key — do not include blog filters in WHERE clauses.
# Discover tables and columns
paragraph analytics schema --json
paragraph analytics schema --table post_analytics_summary --json
# Run a query
paragraph analytics query --sql "SELECT title, total_views, open_rate FROM post_analytics_summary ORDER BY total_views DESC LIMIT 5" --json
# Read SQL from a file or stdin
paragraph analytics query --file ./query.sql --json
echo "SELECT active_subscriber_count FROM blog_subscriber_counts" | paragraph analytics query --json
Prefer the pre-aggregated views (post_analytics_summary, subscriber_engagement_scores, blog_subscriber_counts) over raw tables — they're sub-second and cover most reporting questions. SELECT/WITH only, no semicolons, max 10,000 rows, 30s statement timeout.
Custom emails
Send a one-off markdown email from your publication to a specific recipient list you supply. Each recipient gets it individually with a mandatory unsubscribe footer.
Use this for:
- Targeted segment sends — "email everyone who opened my last post", "follow-up to my 50 most engaged subscribers", "reach out to this curated list of 20 readers". Build the segment via
paragraph analytics queryorparagraph subscriber list. - Self-notifications to the writer — "email me when I hit 1,000 subscribers", "weekly analytics digest". Use
paragraph whoamito look up the writer's email if needed. - Outreach to non-subscriber addresses — a CSV of conference contacts, a press list, an intro to friends-of-friends. Recipients can come from anywhere; they don't have to be in
paragraph subscriber list. (Anyone who previously unsubscribed will still come back assuppressed.) - Re-engagement of inactive subscribers — "email everyone who hasn't opened in 90 days." Identify the segment via
paragraph analytics queryagainstsubscriber_engagement_scoresornewsletter_metrics. - Draft review to a few collaborators — "send this draft pitch to my 3 co-authors for feedback." Use this when you need to email people other than the publication owner;
paragraph post test-emailonly goes to the owner.
Do NOT use this for newsletter blasts. To email all subscribers with a post, use paragraph post publish --newsletter (or paragraph post create ... --newsletter). That's the newsletter pipeline; email send is for targeted lists you supply.
The publication must be approved by Paragraph for custom email; ineligible publications get a 403. Up to 10,000 recipients per call. Always confirm with the user before sending — emails go out for real and can't be undone.
# Send (prompts for confirmation; pass --yes to skip)
paragraph email send --subject "Hello" --body "# Hi" --to reader@example.com --yes --json
# Read body from a file
paragraph email send --subject "Update" --body-file ./body.md --to a@x.com --to b@x.com --yes --json
# Comma-separated recipients (--to is repeatable)
paragraph email send --subject "Update" --body-file ./body.md --to "a@x.com,b@x.com" --yes --json
# Pipe body via stdin
cat body.md | paragraph email send --subject "Update" --to reader@example.com --yes --json
# Dry run — preview the accepted/skipped split without sending
paragraph email send --subject "Update" --body "# Hi" --to a@x.com --dry-run --json
The JSON response includes accepted (queued for delivery) and skipped — each skipped recipient has a reason: invalid (malformed address), suppressed (previously unsubscribed), or scheduling_failed (queue failure — the only reason safe to retry).
Auth
paragraph login --token <api-key>
echo "<api-key>" | paragraph login --with-token
paragraph whoami --json
paragraph logout
JSON response shapes
Paginated list (note: the CLI wraps items under data; the REST API and SDK use items instead):
{
"data": [{ "id": "...", "title": "..." }],
"pagination": { "cursor": "abc123", "hasMore": true }
}
Single item:
{ "id": "...", "title": "...", "markdown": "..." }
Mutation:
{ "id": "...", "status": "published" }
Error (on stderr):
{ "error": "Not found.", "code": "NOT_FOUND", "status": 404 }
Error codes: UNAUTHORIZED, FORBIDDEN, NOT_FOUND, RATE_LIMITED, SERVER_ERROR, REQUEST_FAILED, CLIENT_ERROR, UNKNOWN.
Common patterns
Create and publish in one flow
ID=$(paragraph post create --title "My Post" --file ./post.md --json | jq -r '.id')
paragraph post publish --id "$ID" --newsletter --json
Export all posts as markdown
paragraph post list --limit 100 --json | jq -r '.data[].id' | while read id; do
SLUG=$(paragraph post get --id "$id" --json | jq -r '.slug')
paragraph post get --id "$id" --field markdown > "${SLUG}.md"
done
Paginate through all subscribers
CURSOR=""
while true; do
RESULT=$(paragraph subscriber list --limit 100 ${CURSOR:+--cursor "$CURSOR"} --json)
echo "$RESULT" | jq '.data[]'
CURSOR=$(echo "$RESULT" | jq -r '.pagination.cursor // empty')
HAS_MORE=$(echo "$RESULT" | jq '.pagination.hasMore')
[ "$HAS_MORE" = "true" ] || break
done
Environment variables
| Variable | Purpose |
|---|---|
| PARAGRAPH_API_KEY | API key (skip login) |
| PARAGRAPH_API_URL | Custom API base URL |
| PARAGRAPH_NON_INTERACTIVE | Set to 1 to force CLI mode |
| CI | Set to true to force CLI mode |
Troubleshooting
Authentication errors
If commands fail with UNAUTHORIZED:
# Check if logged in
paragraph whoami --json
# Re-authenticate
paragraph login --token <api-key>
The CLI auto-clears stored credentials on 401. Re-login if credentials were revoked.
Rate limiting
If you get RATE_LIMITED, wait and retry. The error includes a 429 status code. Avoid tight loops — add a delay between paginated requests.
Command hangs
If a command appears to hang, it may be waiting for stdin. Ensure you're passing content via --text, --file, or piping to stdin. The CLI times out after 30 seconds if stdin is piped but no data arrives.
CLI not found after install
npm install -g @paragraph-com/cli
# Verify
npx paragraph --version