npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sharptrick/parley-zulip

v0.8.0

Published

@sharptrick/parley-zulip — self-hosted chat backend with the closest native fit (globally monotonic message id = cursor, stream+topic = topic, event queue long-poll = subscribe) against the frozen Parley seam.

Downloads

1,407

Readme

@sharptrick/parley-zulip

A Parley backend plugin that carries topics over a self-hosted Zulip server, spoken to via the raw REST API with the global fetchno SDK dependency. Of all the backends this is the closest native fit: Zulip's data model is literally streams and topics, so Parley's topic maps onto a first-class Zulip concept instead of being emulated.

It implements the frozen seam (connect / disconnect / subscribe / post / fetchRecent / resolveIdentity); adding it required zero changes to @sharptrick/parley-core.

Seam mapping

| Seam concept | Zulip mapping | | ------------------------- | ------------- | | connect(config) | No session to establish — Zulip auth is per-request HTTP Basic (email:api_key). connect captures config; a bad URL/key surfaces on the first call. | | topic → stream + topic | ONE configured Zulip stream (default parley) carries all Parley traffic; each Parley topic is a Zulip topic within it. | | post | POST /api/v1/messages (form-encoded — Zulip rejects JSON bodies) with { type: "stream", to: <stream>, topic, content } → returns the new message id. | | backendMsgId = cursor | The Zulip message id — a globally monotonic integer (hence per-topic monotonic); serves as both the dedup key and the order key. Zero cursor is '0'. | | fetchRecent (no since)| GET /api/v1/messages narrowed to <stream, topic>, anchor=newest&num_before=N → the most recent window, ascending. | | fetchRecent (since) | anchor=<since>&include_anchor=false&num_before=0&num_after=N — the anchor itself is excluded, so since is strictly exclusive server-side. Zulip returns ascending by id; no client-side reordering. | | subscribe | POST /api/v1/register a <stream, topic>-narrowed message event queue (its birth IS the tail — only later sends enter it; awaited before subscribe resolves), then a GET /api/v1/events long-poll loop. Zulip delivers our own sends back to our own queue. Queues idle-GC after ~10 min → on BAD_EVENT_QUEUE_ID the loop re-registers and gap-fills the dead-queue window through the catch-up path, deduped by last delivered id. disconnect() aborts in-flight polls and best-effort deletes the queues. | | resolveIdentity | GET /api/v1/users, matched on email or full_namebackendRef = the Zulip user_id; miss (or error) degrades to { handle, backendRef: handle }. |

senderHandlemessage.sender_email, content ← raw message.content (apply_markdown=false everywhere — the bridge wants source text, not rendered HTML), timestampnew Date(message.timestamp * 1000).toISOString() (informational only — never used for ordering or dedup).

post's identity argument (your config's identity.handle) is not used. Zulip stamps the sender from the authenticated bot account — see "Multiple concurrent sessions". inReplyTo is ignored too: Zulip has no per-message reply parent; it threads by topic, and the topic is already Parley's addressing unit.

The one inexactness: topics are mutable

Zulip topics are mutable namespaces — admins (and, under the default org policy, members) can move or rename messages between topics after the fact. Message ids and cursors survive a move (they are global, not per-topic), but topic membership can drift: a moved message silently leaves one Parley topic's history and appears in another's. Practically: dedup and ordering are rock-solid; topic isolation is only as strong as your server's message-move policy. For a dedicated Parley stream with only bots posting, this never happens on its own.

Config (backend_config)

| key | default | meaning | | ------------------- | ------------------------ | ------- | | site_url | http://127.0.0.1:9991 | Zulip server base URL (docker-zulip dev default port). | | email | parley-bot@localhost | Bot email for HTTP Basic auth. | | api_key | parley-api-key | Bot API key for HTTP Basic auth. | | stream | parley | The one Zulip stream carrying all Parley topics. | | events_timeout_ms | 25000 | Client-side cap on each /events long-poll before it is aborted and reissued (un-acked events survive). |

Secrets live in backend_config / .env, never in code.

Bot credentials

  1. In your Zulip organization: gear → Personal settings → Bots → Add a new bot (type Generic). Note the bot's email (e.g. [email protected]) and API key (shown on the bot card; regenerable there too).
  2. Create the Parley stream (default name parley) and subscribe the bot to it — an event queue only sees streams its owner can access.
  3. Put site_url, the bot email, and api_key in backend_config / .env.

Headless provisioning

The GUI steps above have a scriptable equivalent — useful for minting a distinct bot per session (below) without click-through. Zulip's management commands (run as the zulip user, e.g. /home/zulip/deployments/current/manage.py <cmd> --help) can create/find the bot user and subscribe it to the stream. A bot's api_key is stable and reusable — mint it once and reuse it across restarts (it only changes if you regenerate it), which is what makes automated per-agent bot provisioning practical.

Multiple concurrent sessions (one backend_config per config file, same server)

A real deployment is several configs — one per Claude Code session plus one for the remote/chat server — all pointed at the same Zulip. site_url and stream must be identical across all of them (a stream mismatch means sessions look in different places for "the same" topic). email/api_key should each be different: as noted above, post() ignores the seam's identity argument — Zulip stamps the sender from whichever bot is authenticated. Give every session the same bot and every message from every session shows up as that one bot; provision a distinct bot per session if you want per-session attribution in transcripts — the same role identity.handle plays for SQLite/Redis/NATS, just carried in backend_config. events_timeout_ms is safe to vary per session.

Retention (server-side, not configured by this plugin)

Zulip supports organization- and stream-level message retention policies enforced by a server-side deletion job — an admin setting, not something an unprivileged bot configures. As with the other backends, expired history is simply gone from fetchRecent — no error signals it.

Running a server

Use the canonical upstream setup — zulip/docker-zulip (or the production installer). For hacking, the Zulip development environment listens on :9991 — this plugin's default site_url. This package does not ship production infra.

Behind a reverse proxy

Co-hosting a self-hosted Zulip behind the same reverse proxy as a remote Parley MCP has two upstream-Zulip gotchas — see Zulip's reverse-proxy docs:

  • Trust the proxy. Zulip ignores X-Forwarded-* from untrusted sources, so it must be told the proxy's IP: LOADBALANCER_IPS (docker-zulip env) / [loadbalancer] ips in /etc/zulip/zulip.conf (installer). Across multiple Docker networks, TRUST_GATEWAY_IP alone may not cover the proxy's address as Zulip sees it — set LOADBALANCER_IPS to that IP/CIDR.
  • Reach it at its EXTERNAL_HOST. Zulip validates the host, so requests must arrive as its configured EXTERNAL_HOST; in Docker, a network alias for that hostname is the simplest fix. Configure EXTERNAL_HOST rather than hand-widening host validation in custom settings.

Tests

npx vitest run packages/bridge-zulip

The shared seam conformance suite (@sharptrick/parley-conformance) always runs against an in-process fake Zulip (test/fake-zulip.ts — form-encoded-only, anchor-narrow reads, long-polled event queues with heartbeats and a gcQueues() lever for the BAD_EVENT_QUEUE_ID recovery test). Set PARLEY_ZULIP_URL, PARLEY_ZULIP_EMAIL, and PARLEY_ZULIP_API_KEY (optionally PARLEY_ZULIP_STREAM, default parley) to additionally run the same suite against a real server — fresh topics in the configured stream are free, so no cleanup is needed.