Experimental Bluesky client for agents
TypeScript 100%

README.md

Perch #

A local Bluesky reader and explicit social publisher with a place to return to. Perch keeps named reading positions, immutable snapshots, typed short references, and navigation history in SQLite. The same snapshot produces a readable text view, exact JSON, inert HTML, and PNG panels. Publishing uses separately prepared, account-bound plans and durable receipts; it never happens as a side effect of reading. Perch also supports explicit follow, unfollow, and block operations. It does not poll or run a daemon.

Run #

Requires Bun and Playwright Chromium:

bun install
bunx playwright install chromium
bun run perch --help
bun run perch open 'at://did:plc:nbfjoeficjzf3pejpontvril/app.bsky.feed.post/3murj5kkhd22w'
bun run perch render --out .perch/burger

The final command reports the HTML, text, JSON, overview image, and reading-panel paths. Read panels in numbered order. Cards retain their width in deep reply branches. Original images are contained rather than cropped; unusually tall cards can have their own taller panel. The HTML has native disclosure controls for closer inspection; the text and JSON retain the detailed evidence.

Public reading is the default. Standard bsky.app/profile/<actor>/post/<rkey> URLs and AT URIs are accepted; handles are resolved to DIDs. The four real conversations used for visual acceptance are listed in docs/examples.json.

The home/following timeline is explicitly authenticated:

bun run perch following --auth                 # newest page, default limit 20
bun run perch following --auth --limit 40      # explicit range: 1..100
bun run perch more --auth                      # next opaque-cursor page
bun run perch refresh --auth                   # a new newest page

Each page is a separate immutable view rather than an ever-growing feed. more retains the page limit unless overridden and refuses a missing, changed-account, changed-service, or unchanged continuation cursor without moving the session. refresh never reuses the page cursor. The feed preserves source order and repeated occurrences of the same post; it does not claim to reproduce a phone client ranking or the whole network.

Read, inspect, move, return #

References printed by a view look like v1-p1, v1-a1, or v1-m1; following-feed occurrences use v1-e1. Use the refs in your actual output, not a ref from an unrelated database. Opening an e ref reads that occurrence's canonical thread and retains a breadcrumb to the exact source page, occurrence, and feed reason. Opening its separate p ref remains the ordinary canonical-post operation.

bun run perch show
bun run perch peek v1-a1
bun run perch open v1-a1
bun run perch back
bun run perch refresh
bun run perch fork scout
bun run perch open v1-p1 --session scout
bun run perch sessions
  • peek returns an exact typed target without fetching or moving.
  • open fetches post/profile targets, author-feed targets and feed occurrences. Link, tag, and media refs open a local target-inspection view, not an article, search, or video player. Its exact destination can be passed to a separate browser/read tool.
  • back restores the exact previous snapshot and its original refs without fetching it again.
  • refresh fetches the current canonical post or profile into a new view; it does not re-resolve a possibly reassigned original handle. On a following feed it requires --auth and fetches a new newest page.
  • fork copies the current position and history. Subsequent movement is independent. The source session does not change.
  • show, peek, render, and sessions do not move the reading position or mark anything externally seen.

View IDs are never reused within a database. Old references continue resolving to their original targets. Concurrent navigation of the same session rejects a stale revision instead of silently replacing another operation; independent sessions can be used by different processes. A failed fetch leaves the previous position intact.

Profiles and recent posts #

bun run perch profile liv.mlf.one
bun run perch author-feed liv.mlf.one --limit 10
bun run perch more
bun run perch refresh

A profile includes a vN-r1 recent-posts reference; open that actual ref to enter a separate author-feed view, and back to return. Author feeds are public by default, accept explicit --auth, and keep their canonical author DID and source bound to pagination. Their compact cards use the same occurrence navigation as the following feed.

Profiles preserve supplied native pronouns, bio, banner, website, counts, relationships, and structured label provenance. Native pronouns are not inferred from biographies or third-party records. A self-issued label is distinguished from another issuer's label; negations and supplied expiry remain inspectable. Historical snapshots with bare label strings retain unknown provenance rather than acquiring invented issuers.

A bounded provider registry also discovers recognized Tangled, GreenGale, and Standard Site collections on the account's PDS, with record previews for sh.tangled.actor.profile/self and app.greengale.publication/self. Each enrichment keeps its own service, DID, fetch time and available record URI/CID; it never replaces the primary profile's identity fields. Missing collections, unavailable requests, and explicitly empty values stay distinct. Empty optional fields and absent providers do not fill the main portrait with placeholder cards. This is a bounded inventory and preview, not a complete repository crawler or a claim about every app using that DID.

Discovered DID/PDS reads are unauthenticated, HTTPS-only, bounded, redirect-refusing and restricted to vetted public addresses pinned for the connection. DID document and repository identities must match the requested DID. The explicitly configured primary authenticated PDS is a separate authority.

Prepare, publish, inspect, reconcile #

Write a JSON draft containing an explicitly ordered array. For example, edit this before preparing it:

{
  "posts": [
    {"text": "A useful protocol detail: https://atproto.com/specs/record-key"},
    {"text": "A deliberate continuation, with its own stopping point."}
  ]
}
bun run perch prepare draft.json --auth
bun run perch receipt '<publication-id>'
bun run perch publish '<publication-id>' --auth
bun run perch reconcile '<publication-id>' --auth

Use the ID emitted by prepare, and the same --db for every step. publish is a real public action, not a preview. Preparation freezes the authored text, detected UTF-8 facets, resolved strong references, native TID record keys, timestamps, and copied attachment bytes in SQLite. Editing the input file afterward cannot change that plan. The plan is bound to both the authenticated DID and PDS service. Reading positions are not moved by publishing.

Each post supports:

  • text: checked across the entire array before uploads or posting, against both 300 graphemes and 3,000 UTF-8 bytes. Plain links, mentions and tags receive native facets where detected/resolved.
  • quote: a post URL, canonical AT URI or stored post reference, resolved to the actual URI/CID.
  • images: up to four { "path": "photo.png", "alt": "An actual description of the image" } objects. Relative paths resolve from the draft file's directory; absolute paths are also accepted. A draft is not a filesystem sandbox: review its selected files before publishing. PNG, JPEG, GIF and WebP signatures are recognized; each image is limited to 1,000,000 bytes for compatibility with the installed lexicons.
  • external: { "uri": "...", "title": "...", "description": "...", "thumb": { "path": "preview.png", "alt": "..." } }, with optional thumb. Metadata is supplied by the author, not fetched from arbitrary pages. External-card thumbnails have no separate published alt-text field in the native embed; use the card title and description for its text. Images and an external card are mutually exclusive; either can accompany a quote through recordWithMedia.
  • langs: optional language tags.

A top-level replyTo selects the first post's parent. Replies to replies preserve the parent's recorded root strong reference. Subsequent parts reply to the preceding confirmed part with the same root. Perch does not split prose automatically or add synthetic numbering fields; native OP-thread numbering is an AppView/client feature.

Receipts preserve the confirmed prefix and distinguish unresolved dispatches from success. A timeout is not permission to post again. reconcile reads the exact planned record key and checks the dispatched content; it does not publish. If that exact record is absent and you deliberately choose to try again, reconcile '<publication-id>' --auth --retry-absent enables a later explicit publish using the same frozen key/content and create-only semantics. It does not overwrite mismatched content or erase the previous attempt. Concurrent publishing claims are rejected. Remote rejection or availability can still interrupt a valid local plan; inspect the receipt rather than preparing and publishing a duplicate replacement.

Follow, unfollow, block #

bun run perch follow '<handle-or-did-or-profile-ref>' --auth
bun run perch unfollow '<handle-or-did-or-profile-ref>' --auth
bun run perch block '<handle-or-did-or-profile-ref>' --auth

These commands perform real authenticated social actions. Targets resolve to canonical DIDs and results identify the affected record. Follow/block creation uses stable collection-compatible keys for concurrent convergence. Unfollow verifies that the record belongs to the authenticated repository and targets the requested actor, then binds deletion to that record's CID; a stale replacement is an error, not a successful unfollow. Arbitrary deletion URIs are not accepted as a substitute for an actor. Blocks are public repository records. These verbs do not imply an unblock, like/repost, DM or generic profile-editing interface.

Source, limits, and exact evidence #

bun run perch open '<post-url-or-at-uri>' --depth 3 --parents 40 --max-nodes 60
bun run perch show --json
bun run perch render --out .perch/reading --json

The standard thread API is bounded, not a guarantee of the complete conversation. Perch keeps source sibling order and distinguishes local node omission from unknown remote coverage. It preserves available parent/root identities and explicit blocked/not-found/unsupported states without inventing why a post is unavailable. Missing counts are not zero. Quotes are not reply ancestors. Each snapshot carries one reference time, separate creation/indexing times, the service used, and its viewing mode.

Source text and UTF-8 facet offsets are preserved; facet destinations come from the record, not regexes over displayed text. Images and alt text retain their owning post. Video is reported as available with a poster where supplied; capture does not play it. Native profile pronouns and supplied label provenance are supported; querying arbitrary additional labelers or pronoun registries is not. Custom feeds, search, notifications, arbitrary article bodies, video/gallery publication, DMs, and like/repost/unblock commands remain outside this delivery.

PNG/HTML is a reading surface, not a replacement for the exact record. HTML escapes source content and disables scripts. Capture only retrieves direct HTTPS image responses from cdn.bsky.app; other origins, credentials in image URLs, and redirects are blocked, with missing-media cues. Public source content remains untrusted even when rendered into pixels.

Local state and explicit authentication #

State defaults to ~/.local/share/perch/reader.sqlite. Select another database with PERCH_DB or --db <path>, and another position with --session <name> (default main). Use separate output directories for unrelated databases: their independently allocated view IDs are not globally unique. Generated state and output belong outside version control.

bun run perch open '<post-url-or-at-uri>' --auth
bun run perch open '<post-url-or-at-uri>' --auth --auth-config /path/to/omp-config.json --pds '<pds-origin>'

--auth reads ~/liv/.omp/mcp.json at execution, specifically mcpServers.atproto.env.BSKY_IDENTIFIER and BSKY_APP_PASSWORD, and uses https://pds.mlf.one unless overridden. This is an explicit local integration, not a bundled credential. Configuration and passwords are never copied into snapshots or render output. Authenticated source views name the viewer DID; relationship indicators describe that account, not necessarily the viewer in someone else's screenshot. Login sessions are not cached yet. Local commands never log in.

If Chromium is installed elsewhere, PERCH_BROWSER selects its executable. bunx playwright install chromium provides the supported project default; platform-specific system libraries may also be needed.

Development #

bun test
bun run typecheck

Tests exercise normalization, immutable state and navigation, CLI errors/local behavior, escaping, readable deep branches, and real browser capture. Controlled SDK/PDS tests cover publication preflight, attachment freezing, facets/embeds, reply chains, uncertain-write reconciliation, concurrent claims, and owned graph mutations without live social writes. Browser tests require the installed Chromium. docs/PLAN.md records the full first-delivery contract and explicit later work. The reader core is separate from its CLI; choosing an OMP plugin or MCP interface does not require rebuilding the reading model.

Verified social delivery — 2026-09-05 #

The integrated candidate passed 62 tests / 770 assertions and bun run typecheck. Actual public/authenticated CLI reads exercised profile → author-feed → more/back/refresh, preserved older references and reading positions, and matched Tangled and GreenGale enrichment URI/CID data against their public PDS records. Native browser captures of the profile and author-feed surfaces were visually reviewed; both delivery slices received independent read-only review, with concrete findings repaired or evidence-backed rebutted.

The sole live write was Liv's three-part hello, published through Perch. Publication pub-0820ad71edb449119bf6e17bf2427bfc in the default store completed with all three posts confirmed. Independent PDS reads matched every URI, CID and record structurally, including the exact root/parent chain and mention facet. The pre-existing reading sessions remained unchanged; the public thread was then opened separately as session hello and rendered from the AppView with all three posts present. Avatar and biography were reviewed and intentionally left unchanged.

Image/quote/external-card composition, upload failure, uncertain responses, late commits, concurrent claims and follow/unfollow/block behavior were exercised through controlled SDK/PDS tests—not test mutations against other people's accounts. Controlled discovery tests cover identity, origin, private/special-use address, redirect, response-size and DNS-deadline boundaries. This evidence does not claim exhaustive network coverage or that every social operation was performed live.