---
slug: "pdsx"
source_type: "readme"
source_url: "https://cdn.jsdelivr.net/gh/zzstoatzz/pdsx@main/README.md"
repo: "https://github.com/zzstoatzz/pdsx"
source_file: "README.md"
branch: "main"
---
# pdsx

general-purpose cli for atproto record operations

📚 **[documentation](https://pdsx.zzstoatzz.io)**

## installation

```bash
uv add --prerelease=allow pdsx
# or run directly
uvx --prerelease=allow pdsx --help
# or from GitHub (latest)
uvx --prerelease=allow --from git+https://github.com/zzstoatzz/pdsx pdsx --help
```

> **`--prerelease=allow` is required.** pdsx depends on `fastmcp==4.0.0a1`, and
> resolvers skip pre-releases unless asked. Without the flag you don't get an
> error — you silently get **0.1.5**, the last release published before that
> pin, missing everything since.
>
> To make it stick in a project, put this in your `pyproject.toml`:
>
> ```toml
> [tool.uv]
> prerelease = "allow"
> ```
>
> This goes away when fastmcp 4.0 ships stable.

## quick start

**important**: flags like `-r`, `--handle`, `--password` go BEFORE the command (`ls`, `get`, etc.)

```bash
# read anyone's posts (no auth needed)
uvx pdsx -r zzstoatzz.io ls app.bsky.feed.post -o json | jq -r '.[].text'

# update your bio (requires auth)
export ATPROTO_HANDLE=your.handle ATPROTO_PASSWORD=your-app-password
uvx pdsx edit app.bsky.actor.profile/self description='new bio'
```

## features

- crud operations for atproto records (list, get, create, update, delete)
- **batch operations**: delete multiple records concurrently with progress tracking
- **blob upload**: upload images, videos, and other binary content
- **cursor pagination**: paginate through large collections
- **MCP server**: expose operations via [model context protocol](https://modelcontextprotocol.io) for AI agents
- optional auth: reads with `--repo` flag don't require authentication
- shorthand URIs: use `app.bsky.feed.post/abc123` when authenticated
- multiple output formats: compact (default), json, yaml, table
- unix-style aliases: `ls`, `cat`, `rm`, `edit`, `touch`/`add`
- jq-friendly json output
- python 3.10+, type-safe

## plugin (claude code / codex)

installs the hosted MCP server **and** the skills together — no local runtime. this
is the recommended way to use pdsx with an agent: the MCP server gives it the tools,
the skills teach it the parts that are easy to get wrong.

```bash
# claude code
/plugin marketplace add https://github.com/zzstoatzz/pdsx
/plugin install pdsx

# codex
codex plugin marketplace add https://github.com/zzstoatzz/pdsx
codex plugin add pdsx@pdsx
```

from a clone, for local development:

```bash
claude --plugin-dir .
```

### the skills

| skill | reach for it when |
|-------|-------------------|
| `/pdsx:reading-records` | inspecting what an account published, pulling records out of a PDS, fetching blob content, or exploring an unfamiliar lexicon |
| `/pdsx:writing-records` | creating, updating or deleting records, uploading blobs, running batch operations, or checking that a write did what you meant |
| `/pdsx:experimental-spaces` | working with permissioned data (`com.atproto.space.*`) — experimental, and served by almost no PDS |

they load on demand, so installing all three costs nothing until one is relevant.

### MCP tools or the CLI?

the MCP server covers ordinary record CRUD with structured output. reach for the
CLI when you need something it doesn't expose:

| you want | use |
|----------|-----|
| read / create / update / delete one record | MCP tools |
| arbitrary read-only XRPC | MCP `query` tool |
| batch operations over JSONL, with concurrency | CLI — `create`/`update`/`rm` reading stdin |
| blob upload | CLI — `upload-blob` |
| permissioned data | CLI — `pdsx spaces …`, no MCP equivalent |

### two things that surprise people

**reads always need a target.** `-r/--repo` is required even when you're
authenticated — credentials decide what you can see, never where the read goes.

```bash
pdsx -r you.bsky.social ls app.bsky.feed.post    # your own repo, explicitly
```

**`ls` returns one page.** default limit is 50 and it does not follow cursors, so
a collection with 200 records answers with 50 and a cursor on stderr. sort one
page and you'll draw confident, wrong conclusions about "the latest" of anything.

```bash
pdsx -r zzstoatzz.io ls app.bsky.feed.post -o json | jq '.[].uri'
# stderr: next page cursor: 3mrgybvq4w22y
```

### already using protopack?

[protopack](https://tangled.org/zzstoatzz.io/protopack) bundles the pdsx MCP
server alongside the Microcosm services. installing both is fine — the server
registers once, not twice, and you get protopack's skills plus these three.

that pairing is worth having: protopack's `constellation` skill answers "who
interacted with this record" through the global backlink index, which is far
better than paginating someone's posts to find replies.

## MCP server

pdsx includes an MCP server for AI agent integration (e.g., claude code, cursor).

```bash
# add to claude code (read-only)
claude mcp add-json pdsx '{"type": "http", "url": "https://pdsx-by-zzstoatzz.fastmcp.app/mcp"}'

# with authentication for writes
claude mcp add-json pdsx '{
  "type": "http",
  "url": "https://pdsx-by-zzstoatzz.fastmcp.app/mcp",
  "headers": {
    "x-atproto-handle": "your.handle",
    "x-atproto-password": "your-app-password"
  }
}'
```

get an app password at: https://bsky.app/settings/app-passwords

📚 **[full MCP documentation](https://pdsx.zzstoatzz.io/guides/mcp-server)** - local setup, custom PDS, available tools, filtering, and more

### running the MCP server locally

run the MCP server locally with `uvx`:

```bash
uvx --from 'pdsx[mcp]' pdsx-mcp
```

to add it to claude code as a local stdio server:

```bash
claude mcp add pdsx -- uvx --from 'pdsx[mcp]' pdsx-mcp
```

for authenticated writes, use the `-e` flag:

```bash
claude mcp add pdsx -e ATPROTO_HANDLE=your.handle -e ATPROTO_PASSWORD=your-app-password -- uvx --from 'pdsx[mcp]' pdsx-mcp
```

<details>
<summary>usage examples</summary>

### read operations (no auth with -r)

```bash
# list records from any repo (note: -r goes BEFORE ls)
pdsx -r zzstoatzz.io ls app.bsky.feed.post --limit 5 -o json

# read someone's bio
pdsx -r zzstoatzz.io ls app.bsky.actor.profile -o json | jq -r '.[0].description'
```

### pagination

```bash
# get first page (note: -r before ls, --cursor after)
pdsx -r zzstoatzz.io ls app.bsky.feed.post --limit 2

# output includes cursor if more pages exist, copy it for next command
# next page cursor: 3m5335qycpc2z

# get next page (use actual cursor from previous output)
pdsx -r zzstoatzz.io ls app.bsky.feed.post --limit 2 --cursor 3m5335qycpc2z
```

### post with image (end-to-end)

```bash
# download a test image
curl -sL https://picsum.photos/200/200 -o /tmp/test.jpg

# upload image and capture blob reference
pdsx upload-blob /tmp/test.jpg
# copy the blob reference from output, example:
# {"$type":"blob","ref":{"$link":"bafkreif..."},"mimeType":"image/jpeg","size":6344}

# create post with uploaded image (paste your actual blob reference)
pdsx create app.bsky.feed.post \
  text='Posted via pdsx!' \
  'embed={"$type":"app.bsky.embed.images","images":[{"alt":"test image","image":{"$type":"blob","ref":{"$link":"PASTE_YOUR_CID_HERE"},"mimeType":"image/jpeg","size":6344}}]}'
```

### write operations (auth required)

```bash
# update your bio
pdsx edit app.bsky.actor.profile/self description='Building with pdsx!'

# create a simple post
pdsx create app.bsky.feed.post text='Hello from pdsx!'

# delete a post (use the rkey from create output)
pdsx rm app.bsky.feed.post/PASTE_RKEY_HERE
```

### batch operations

```bash
# delete multiple records (runs concurrently)
pdsx rm app.bsky.feed.post/abc123 app.bsky.feed.post/def456 app.bsky.feed.post/ghi789

# delete from file via stdin (the Unix way)
cat uris.txt | pdsx rm

# control concurrency (default: 10)
pdsx rm uri1 uri2 uri3 --concurrency 20

# fail-fast mode (stop on first error)
pdsx rm uri1 uri2 uri3 --fail-fast
```

**note**: when authenticated, use shorthand URIs (`collection/rkey`) instead of full AT-URIs (`at://did:plc:.../collection/rkey`)

</details>

<details>
<summary>output formats</summary>

both `ls` and `cat`/`get` support four output formats:

### compact (default for ls)
```
app.bsky.feed.post (3 records)
3m4ryxwq5dt2i: {"created_at":"2025-11-04T07:25:17.061883+00:00","text":"..."}
```

### json
```bash
pdsx -r zzstoatzz.io ls app.bsky.feed.post -o json | jq '.[].text'
pdsx -r zzstoatzz.io cat app.bsky.feed.post/3m5335qycpc2z -o json
```

### yaml
```bash
pdsx -r zzstoatzz.io ls app.bsky.feed.post --limit 3 -o yaml
pdsx -r zzstoatzz.io cat app.bsky.actor.profile/self -o yaml
```

### table (default for cat/get)
```bash
pdsx -r zzstoatzz.io ls app.bsky.feed.post --limit 5 -o table
pdsx -r zzstoatzz.io cat app.bsky.actor.profile/self  # default
```

</details>

<details>
<summary>development</summary>

```bash
git clone https://github.com/zzstoatzz/pdsx
cd pdsx
uv sync
just hooks      # install pre-commit hooks — CI skips the plugin manifests
uv run pytest
uv run ty check
```

</details>

## license

mit
