# Paragraph CLI

> Use the Paragraph CLI and MCP server to manage posts, drafted content (X posts, LinkedIn posts, newsletters, X Articles), publications, subscribers, and coins on paragraph.com. Trigger when the user asks to publish, create, update, or manage newsletter content on Paragraph via CLI or MCP.

- Skill: `paragraph-xyz/paragraph-cli` (Agent Skill)
- Install (CLI): `npx skillmds@latest add paragraph-xyz/paragraph-cli`
- Raw SKILL.md: https://api.skillmd.com/api/skills/paragraph-xyz/paragraph-cli/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: MIT
- Author: paragraph-xyz (https://skillmd.com/u/paragraph-xyz)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/paragraph-xyz/paragraph-cli

---


# Paragraph CLI

CLI for [Paragraph](https://paragraph.com) — 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](https://modelcontextprotocol.io), 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.

```bash
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+):

```bash
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](https://paragraph.com/docs/development/mcp).

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:

```bash
npm install -g @paragraph-com/cli
```

Authenticate — login persists the key to `~/.paragraph/config.json`:

```bash
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:

```bash
PARAGRAPH_API_KEY=<api-key> paragraph post list --json
```

Verify: `paragraph whoami --json`

## Working agreement

- **Always use `--json`** for parseable output. Data goes to stdout, status/errors to stderr.
- **Always use `--yes`** on `delete` to skip confirmation prompts.
- **Use `--dry-run`** before `delete`, `publish`, and `archive` to preview what will happen.
- **Use flags, not just positional args.** Every identifier accepts `--id` so 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 `--limit` and `--cursor`.** The JSON response includes `pagination.cursor` and `pagination.hasMore`.
- **Do not use interactive login.** Use `--token` or `--with-token` for non-interactive auth.
- **Check auth before running commands.** Run `paragraph whoami --json` to verify credentials are valid.
- **Do not publish without explicit user approval.** Publishing sends content live and optionally emails subscribers.
- **Default to draft.** `post create` creates drafts. Only call `post publish` when the user asks.
- **`content` commands 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 send` delivers real email and can't be undone. Draft the subject and body first; use `--dry-run` to preview filtering before a real send. On a `403`, 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

```bash
# 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.

```bash
# 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.

```bash
# 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

```bash
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

```bash
paragraph search post --query "ethereum" --json
paragraph search blog --query "web3" --json
```

### Subscribers

```bash
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

```bash
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

```bash
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.

```bash
# 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 query` or `paragraph subscriber list`.
- **Self-notifications to the writer** — "email me when I hit 1,000 subscribers", "weekly analytics digest". Use `paragraph whoami` to 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 as `suppressed`.)
- **Re-engagement of inactive subscribers** — "email everyone who hasn't opened in 90 days." Identify the segment via `paragraph analytics query` against `subscriber_engagement_scores` or `newsletter_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-email` only 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.

```bash
# 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

```bash
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):
```json
{
  "data": [{ "id": "...", "title": "..." }],
  "pagination": { "cursor": "abc123", "hasMore": true }
}
```

Single item:
```json
{ "id": "...", "title": "...", "markdown": "..." }
```

Mutation:
```json
{ "id": "...", "status": "published" }
```

Error (on stderr):
```json
{ "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
```bash
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
```bash
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
```bash
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`:

```bash
# 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

```bash
npm install -g @paragraph-com/cli
# Verify
npx paragraph --version
```

