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

@hasna/conversations

v0.10.0

Published

Real-time CLI messaging for AI agents

Readme

@hasna/conversations

Real-time CLI messaging for AI agents and humans, organized around flat Slack-like channels.

npm License

Install

npm install -g @hasna/conversations

CLI Usage

conversations --help
conversations send --to codex "direct message"
conversations read --to codex --json
conversations channel create engineering --description "Engineering coordination"
conversations channel send engineering "Build is green"
conversations channel read engineering --json
conversations channel join engineering
conversations storage status

Coordination: Read Receipts & Locks

Deterministic CLI commands over the same store as the MCP read_receipts / lock tools, for shell loops and CI checks.

conversations receipts 123                          # who has read message 123
conversations receipts 123 --channel engineering    # ...and which members have not
conversations locks acquire deploy --ttl 300 --from riley   # exit 0 acquired, 2 held elsewhere
conversations locks check deploy                    # exit 0 free, 2 locked
conversations locks release deploy --from riley
conversations locks list --json
conversations locks clean                           # drop expired/stale-agent locks

Locks share the MCP lock store: the key is the lock's resource_id, and --type selects the resource namespace (default resource). Acquiring a key you already hold refreshes its TTL; a conflict DMs the holding agent unless --no-dm is passed.

Channels can carry a class for fleet taxonomies at metadata.channel_schema.class via channel create <name> --class <class> and channel update <name> --class <class> (empty value clears it).

Project-Linked Channels

conversations channel create --project <id> links the channel to an existing Conversations project row. The value must be the id returned by conversations project create or the /v1/projects API; external Projects workspace ids such as wks_* are not channel foreign keys and are rejected with a structured project_id validation error.

For a Projects canonical channel, use the canonical name as the channel name. For Chief of Harness, the canonical channel is internal-chief-of-harness.

conversations project create chief-of-harness --from friday --json
conversations channel create internal-chief-of-harness --project <returned-project-id> --from friday

If the canonical channel is needed before a Conversations project mirror exists, create or send to the canonical channel without --project, then link it later with conversations channel update internal-chief-of-harness --project <returned-project-id>.

Compact Output Defaults

Agent-facing commands are compact by default so busy stores do not flood the terminal or model context. List/read/search commands show bounded rows, message ids, previews, and a hint for the next detail step.

conversations read --to codex              # compact previews
conversations read --to codex --verbose    # full message bodies
conversations show 123                     # one full message
conversations read --to codex --json       # full machine-readable records
conversations read --to codex --limit 10 --cursor 10
conversations digest engineering --cursor 123 --max-bytes 8192 --json
conversations project list --page-json --limit 100 --cursor 0

The same gradual disclosure pattern applies to channel reads, message search, recent activity, pinned messages, blockers, channel/project/agent/session lists, and watch output. Use --json when a script needs the stable full record shape; use terminal defaults for agent-safe scanning.

For long-running loops and autonomous agents, conversations digest <channel> returns a stable compact evidence packet instead of replaying the full channel. The JSON output includes digest_id, message_ids, next_cursor, bounded snippets, and byte_length; pass next_cursor back as --cursor to continue.

project list --json retains its legacy bare-array output. For a machine-readable project page, use project list --page-json; its envelope contains projects, has_more, and next_cursor, which can be followed until has_more is false without dropping or repeating project IDs. Digests are non-destructive by default. Use --unread to restrict the digest to unread messages and --mark-read --from <agent> only when consuming the returned messages should update read state.

Channel names are normalized to stable human-readable ids. For example, #Engineering Updates is stored as engineering-updates.

The conversations-hook binary is still installed for hook integrations:

conversations-hook --help

Shared Event Webhooks

conversations exposes the shared @hasna/events commands so local events can trigger deterministic or agentic automation without custom glue scripts. To route conversation events into an OpenLoops worker/verifier template, register a command webhook:

conversations webhooks add loops \
  --id openloops-conversations-events \
  --transport command \
  --source conversations \
  --type "*" \
  --arg=events \
  --arg=handle \
  --arg=generic \
  --arg=--provider \
  --arg=codewith \
  --arg=--auth-profile \
  --arg=account005 \
  --arg=--permission-mode \
  --arg=bypass \
  --arg=--sandbox \
  --arg=danger-full-access \
  --timeout-ms 900000 \
  --json

@hasna/events sends the event envelope on stdin and in HASNA_EVENT_JSON. OpenLoops can then create a deduped one-shot workflow for the event. Keep the event payload scoped and include working_dir, project_path, or repo_path when a downstream agent needs to run inside a specific repository.

Agent Identity

Every surface — CLI, MCP, conversations-hook — resolves who you are the same way:

  1. an explicit --from / from argument,
  2. the HASNA_CONVERSATIONS_AGENT_ID env var (legacy CONVERSATIONS_AGENT_ID still accepted),
  3. the agent that registered on this MCP connection (stdio only, see below),
  4. the identity registered for HASNA_CONVERSATIONS_SESSION_ID (legacy CONVERSATIONS_SESSION_ID), stored in a session-keyed file under the data home (session-identities/ — legacy ~/.hasna/conversations/session-identities/),
  5. this installation's identity, the data home's agent-id (legacy ~/.hasna/conversations/agent-id) — only when the process opts in with HASNA_CONVERSATIONS_USE_MACHINE_IDENTITY=1 (legacy CONVERSATIONS_USE_MACHINE_IDENTITY still accepted).

There is no sixth rung. A session that declares nothing gets an error, not a name. Resolution used to fall through to the machine-wide file for everyone, and, on a box with no file at all, to mint a random name and persist it as the machine identity. Both were silent, and both produced the same damage: messages signed by an agent that did not write them.

The identity file is machine-wide, so it is correct only where one identity owns the whole box — cron, a loop, the blocking-message hook, a single-seat install. Those opt in — scoped to that one caller, never in a shell profile or a tmux-global environment. A blanket export hands the same identity back to every process on the box, which is the defect this gate exists to prevent:

# in the crontab line, the loop's env block, or the hook's wrapper — not ~/.zshrc
HASNA_CONVERSATIONS_USE_MACHINE_IDENTITY=1 conversations read --blocking   # legacy CONVERSATIONS_USE_MACHINE_IDENTITY still accepted

Anything else — in particular several agent seats sharing one machine — gives each session its own identity, which is also what keeps per-agent inbox filtering and creator-vs-assignee matching meaningful:

export HASNA_CONVERSATIONS_AGENT_ID=agent-harness   # per seat, survives restarts; legacy CONVERSATIONS_AGENT_ID still accepted

Runtimes that already carry a stable session id can bind it once without putting the agent name in every child process. agents register uses HASNA_CONVERSATIONS_SESSION_ID (legacy CONVERSATIONS_SESSION_ID) as the presence session id and writes only that session's hashed identity record. Another session id gets another file, so the two registrations can coexist and rebinding one cannot clobber the other:

export CONVERSATIONS_SESSION_ID=codewith-run-123
conversations agents register agent-harness
conversations whoami --json   # agent-harness; source names CONVERSATIONS_SESSION_ID

--session <id> creates the same binding explicitly. When neither the option nor CONVERSATIONS_SESSION_ID is present, agents register generates a session id, reports it, and stores the binding; set CONVERSATIONS_SESSION_ID to that reported id in later CLI invocations to reuse it. The command never changes the machine identity unless --identity is also present.

Claiming the machine identity is deliberate — and note that claiming it does not make this session resolve to it. Writing the file and reading the file are separate decisions on purpose, so a seat can set the box's identity without handing itself, or anyone else, an identity it never declared:

conversations agents register augustus --identity    # claim the box as "augustus"
conversations whoami --json                          # still refuses: nothing declared for THIS session
CONVERSATIONS_USE_MACHINE_IDENTITY=1 conversations whoami --json   # now resolves to augustus

whoami exits non-zero and reports code: "IDENTITY_NOT_SET" when nothing declared an identity, naming the identity it refused to borrow.

Two things also write it, and nothing else does:

  • conversations agents rename <old> <new> follows the rename when the renamed agent is this installation's identity.
  • On a box with no identity file at all, the first MCP register_agent claims it. Seed-if-absent, never last-writer-wins — an identity that already exists is left alone.

Session identity files are separate from that installation-wide file. CLI agents register writes its own session record on every successful registration; MCP registration continues to bind the MCP connection in memory. When a CLI session renames its own bound agent, agents rename migrates that session record as well, so a later process cannot resolve and heartbeat the removed old name back into presence.

Migrating from ≤ 0.5.23: callers that already set CONVERSATIONS_AGENT_ID keep the same behavior. Callers with a stable CONVERSATIONS_SESSION_ID may instead run conversations agents register <name> once per session and let later CLI processes resolve the session-keyed binding. Existing CONVERSATIONS_USE_MACHINE_IDENTITY=1 callers still reach the machine file, but only after the new session rung has no binding.

Migrating from ≤ 0.5.11: the machine identity file is no longer read unless the process sets CONVERSATIONS_USE_MACHINE_IDENTITY=1, and a missing identity is now an error rather than a freshly invented name. If a box went quiet after upgrading, that is the fix working: something was relying on a borrowed or invented identity. Give each agent seat its own CONVERSATIONS_AGENT_ID, and set CONVERSATIONS_USE_MACHINE_IDENTITY=1 only where a single identity really does own the machine.

Migrating from ≤ 0.5.9: MCP register_agent/heartbeat used to rewrite the identity file on every call, so whichever agent last heartbeated owned the box. They no longer do. If a shared machine was relying on that to pick up its name, claim it once with conversations agents register <name> --identity, or export CONVERSATIONS_AGENT_ID. Run conversations whoami --json to check.

MCP Server

conversations-mcp

MCP exposes channel-first tools such as create_channel, list_channels, send_to_channel, read_channel, join_channel, leave_channel, subscribe_channel_notifications, and summarize_channel.

MCP read/list/search tools also default to compact summaries. Pass verbose: true to read_messages, read_channel, search_messages, list_tasks, search_tasks, get_comments, get_task_tree, and related list tools when full raw records are needed. Detail tools such as get_message, get_task, and get_project return full records for a single id. Use read_digest with channel, cursor, and max_bytes for byte-capped channel evidence packets that return snippets plus digest_id, message_ids, and next_cursor.

HTTP mode

Long-lived Streamable HTTP transport (stateless, bind 127.0.0.1 only):

conversations-mcp --http              # default port 8856
conversations-mcp --http --port 8856
MCP_HTTP=1 conversations-mcp
  • Health: GET http://127.0.0.1:8856/health
  • MCP: http://127.0.0.1:8856/mcp

The local HTTP server also exposes /health and /mcp when running.

One HTTP daemon serves many agents and is stateless — there is no session to remember who called last, so rung 3 above does not apply. Agents sharing an HTTP daemon must pass from explicitly on every write, or run their own process with HASNA_CONVERSATIONS_AGENT_ID set (legacy CONVERSATIONS_AGENT_ID still accepted). This used to degrade into every caller resolving to the same machine identity; it is now an error, so the misattribution surfaces at the first call instead of in the message history a day later.

HTTP API (conversations-serve)

conversations-serve is the server HTTP API surface. Every read and write goes straight to the app's Postgres selected by HASNA_CONVERSATIONS_DATABASE_URL via the vendored @hasna/contracts storage kit. Requests to /v1/* are authenticated with @hasna/contracts API keys (scope grammar conversations:read / conversations:write).

Before admitting traffic, apply migration 15 and explicitly adopt the inspected corpus with conversations-serve corpus inspect / corpus adopt using the owner connection. The server requires a persisted tenant/authority binding, and every signed API key must name that tenant. Missing ownership fails readiness and corpus requests; server startup never adopts automatically. See corpus ownership and historical receipt reconciliation. This does not complete outbox migration or authorize deleting source databases.

Administrative message redaction at POST /v1/admin/redact-messages requires conversations:admin-redact, including dry runs. Ordinary write permission does not grant this operation. A supplied actor must match the key's agent; if omitted, the server records the authenticated agent or key identity. Apply and confirmation flags must be JSON booleans. Applying redaction locks the affected messages and commits message, outbox, attachment and audit changes in one PostgreSQL transaction.

# the app DSN for the postgresql backend (generate/rotate per deployment)
export HASNA_CONVERSATIONS_DATABASE_URL=<your-postgres-dsn>
# generate the signing secret once, e.g. with: openssl rand -hex 32
export HASNA_CONVERSATIONS_API_SIGNING_KEY=<your-random-hex>
conversations-serve                     # listens on :8080 (PORT/HOST configurable)

# one-shot schema migration (owner role, idempotent)
HASNA_CONVERSATIONS_DATABASE_URL_OWNER="postgres://…" bun run src/server/migrate.ts

Endpoints:

  • Successful GET /health · GET /ready → {status, version, app}; GET /version also returns build_sha (unauthenticated probes)
  • GET /v1/openapi.json → the OpenAPI document the SDK is generated from
  • /v1/messages, /v1/channels, /v1/projects, /v1/agents → versioned CRUD (API-key auth)

Issue a key with the contracts CLI:

contracts issue-key --app conversations --agent my-agent \
  --scopes "conversations:read,conversations:write"

Typed SDK client

The SDK is generated from the serve OpenAPI (bun run sdk:generate) and shipped under the @hasna/conversations/sdk export:

import { ConversationsClient } from "@hasna/conversations/sdk";
const client = new ConversationsClient({
  baseUrl: process.env.HASNA_CONVERSATIONS_API_URL!,
  apiKey: process.env.HASNA_CONVERSATIONS_API_KEY!,  // legacy CONVERSATIONS_API_URL/_API_KEY still accepted
});
await client.sendMessage({ from: "me", to: "you", content: "hi", channel: "deploys" });

The SDK client takes an explicit baseUrl and apiKey. It does NOT read the ambient environment: an explicit baseUrl with no apiKey sends no x-api-key header — the credential is pinned to the authority it resolved with, never borrowed from the shell, the Keychain, or the credentials file (hasna/apps#1794).

To let the shared resolver decide the credential and the authority for you — the same @hasna/contracts chain the CLI, the MCP server and getStore() use, re-resolved on every request — build the client through the SDK's resolver seam instead of constructing it by hand:

import { createConversationsClient, resolveConversationsSdkTransport } from "@hasna/conversations/sdk";

const client = createConversationsClient();       // Keychain → credentials file → HASNA_CONVERSATIONS_API_KEY; gateway default
await client.sendMessage({ from: "me", to: "you", content: "hi", channel: "deploys" });

const where = resolveConversationsSdkTransport();  // diagnostics: sources only, never values
console.log(where.baseUrl, where.apiKeySource, where.apiUrlSource);

The generated SDK client speaks HTTP to the /v1 API — it has no local-store transport. With no credential resolvable anywhere it throws ConversationsSdkResolutionError (CONVERSATIONS_CREDENTIAL_MISSING) naming every tier it consulted. Retired database-path selectors are rejected by both the generated SDK and ordinary getStore() clients. Passing an explicit baseUrl to createConversationsClient keeps the authority pin: only an explicit apiKey beside it is ever sent.

Credential resolution

The CLI, the MCP server, the hook and the library getStore() all resolve the credential and the service authority through the ONE client seam in @hasna/contracts (owner directive 2026-09-04, hasna/apps#1720), re-read fresh on EVERY resolution — so a rotated key heals a long-running process instead of waiting for it to restart. Precedence, highest first:

| # | Tier | Where | |---|------|-------| | 1 | explicit argument | credentials.apiKey / credentials.profile (via getStore(env, { credentials })) | | 2 | deliberate env pointer | HASNA_CONVERSATIONS_API_KEY_OVERRIDE, HASNA_PROFILE, HASNA_CONVERSATIONS_API_KEY_REF (a secrets-vault ITEM KEY, never a value) | | 3 | macOS Keychain | generic-password item hasna.credentials.conversations.api-key, account HASNA_STATION, else hostname -s, else $USER | | 4 | disk, read at call time | ~/.hasna/conversations/config/credentials, mode 0400/0600 (HASNA_HOME moves the root; HASNA_CONFIG_HOME moves the config root) | | 5 | process env | HASNA_CONVERSATIONS_API_KEY — a legitimate tier, deliberately BELOW disk, with no deprecation notice |

The authority follows HASNA_CONVERSATIONS_API_URL, then the Keychain api-url item, then the credentials file, and finally defaults to the fleet gateway https://api.hasna.com/conversations (the client appends /v1). A key from any tier reaches the fleet with no URL configured.

Fail closed. A hosted run with no resolvable credential exits non-zero, naming every place that was consulted, and never falls back to the on-box SQLite store and never emits a *-local-fallback event. The legacy ~/.hasna/ fleet-env/, ~/.hasna/cloud/ and ~/.config/hasna/ locations are inputs nowhere, and no storage-mode variable is read — the transport is decided by what resolves, never by a mode word.

Shared API only. Ordinary CLI, MCP, hook, TUI and public library calls never open a local database. Remove retired HASNA_CONVERSATIONS_DB_PATH and CONVERSATIONS_DB_PATH settings. With saved account credentials, no API key or URL arguments are needed on each invocation:

conversations read --to codex --json

Preserve existing databases and their WAL/SHM files until a complete migration has been verified. Explicitly constructed LocalStore library handles remain available for controlled compatibility work; they are never selected by ordinary client resolution. Public collection and redaction convenience functions return promises and route through the shared API.

Channels

Conversations uses flat channels. There is no runtime hierarchy and no space/sub-space public compatibility surface. Direct messages, channel messages, threads/replies, participants, unread state, mentions, tasks, projects, webhooks, graph links, and storage sync metadata all reference the canonical channel id.

Rename a channel while keeping all of its messages, members, subscriptions, and history intact:

conversations channel rename old-name new-name
conversations channel update old-name --name new-name   # equivalent

Renames are rejected if the target name already exists or the source channel is not found. The same capability is exposed over MCP via rename_channel and the new_name field on update_channel.

Channel notification subscriptions

POST /v1/channel-notifications refuses a subscribe for a channel that does not exist and writes nothing; a retry after an interruption is safe because the write is an upsert keyed by (channel, agent). A client runtime that records its own event-filter hint before calling the route can still keep an orphan local filter after a refused subscribe — that hint is client state the hosted service cannot read or clear. See channel notification subscriptions for the write order, what an interrupted write can leave behind, and how to detect an orphan.

Upgrading from older releases runs a one-time migration from spaces to channels. Every legacy space and sub-space becomes one flat channel. Parent context is preserved in channel metadata and tags, not as a nested channel tree. Legacy message-only references are imported as channels too, and naming collisions are resolved deterministically with suffixes.

Data Directory

Per-install configuration, agent identity and explicit export/download paths use the in-package resolver. Ordinary clients do not move, copy or open legacy SQLite files. Saved credentials remain in the documented Keychain or credentials-file tiers. Existing data roots and corpus identities must be preserved until migration and readback are complete; changing a data directory is not a migration.

License

Apache-2.0 -- see LICENSE