Skip to main content

@poweur/client

The Poweur protocol as a typed, runtime-agnostic package: identity, messaging, files, sync, shares, contacts, policy and proof-of-work. It runs in the browser, in Node ≥18, and in Bun and Deno, and it ships a poweur CLI that is command-for-command with the Go one.

Not on npm yet

The package is not published to the npm registry yet. Until it is, build it from the repository and install the packed file:

git clone https://github.com/romanmandryk/poweur.git && cd poweur
pnpm install && pnpm client:build
cd packages/client-ts && npm pack # → poweur-client-<version>.tgz
cd /your/project && npm install /path/to/poweur-client-<version>.tgz
npx poweur --help # the CLI comes with it

Once published, this becomes npm install @poweur/client.

The Go implementation stays canonical. packages/identity and the relay define the protocol; this package conforms to them through shared conformance vectors, so the two implementations cannot silently diverge (see Conformance).

The Signer model​

Nothing above the crypto layer ever sees a private key. Callers hand the SDK a Signer, and every signing path goes through it:

interface Signer {
readonly identity: string; // "alice.poweur.net"
readonly publicKey: string; // "ed25519:…"
sign(canonical: string, encoding?: "base64url" | "base64std"): Promise<string>;
}

That single seam is what lets a passkey-gated browser key, an OS keychain and an unattended agent's key file drive identical code. A KeyStore is the matching abstraction for where keys live:

RuntimeKeyStoreNotes
Node / Bun / DenoFileKeyStore~/.poweur/keys — the Go CLI's own directory
Browseryour own, over a passkey PRFLocalStorageKeyStore exists for demos only
Tests, ephemeral agentsMemoryKeyStorenothing touches disk

LocalSigner and LocalDecryptor wrap raw key bytes when you already hold them; implement Signer yourself when the key lives somewhere you cannot read.

Quickstart: a messageable agent​

import { PoweurClient, createIdentity, IdentityApi, RelayClient, signerFor } from "@poweur/client";
import { FileKeyStore, FileSessionStore, nodeResolveOptions } from "@poweur/client/node";

const relayUrl = "https://poweur.net";
const store = new FileKeyStore();

// Create the identity once; afterwards `store.load(...)` is enough.
const created = await createIdentity(new IdentityApi(new RelayClient(relayUrl)),
"myagent.poweur.net", { hosted: true });
await store.save(created.keys);

const { signer, decryptor } = signerFor(created.keys);
const client = new PoweurClient({
relayUrl, signer, decryptor,
sessionStore: new FileSessionStore(),
resolve: nodeResolveOptions(),
});

// Poll, reply, and acknowledge what we actually read.
for (;;) {
const { messages } = await client.inboxAndAck();
for (const message of messages) {
if (message.plaintext) await client.send(message.sender, `echo: ${message.plaintext}`);
}
await new Promise((r) => setTimeout(r, 5000));
}

inboxAndAck() emits the tick-2 receipt only for messages that actually decrypted — proof the message reached a client, not merely a relay. Use inbox() when you want to decide that yourself.

Modules​

The package provides subpath imports for the protocol areas below. V1 file, sync and share imports were removed; v2 clients are under construction.

ImportCovers
@poweur/clienteverything, plus the PoweurClient facade
@poweur/client/cryptoEd25519, X25519 + HKDF + ChaCha20-Poly1305, Signer/KeyStore
@poweur/client/canonicalevery canonical signing string
@poweur/client/resolveweb-first resolution with the SSRF guards
@poweur/client/identityregister, export, rotate
@poweur/client/messagessend (session or identity), inbox, acks, anonymous send
@poweur/client/contactscontacts.json, key pinning, request queue
@poweur/client/policyinbox-policy.json
@poweur/client/powproof-of-work solve and check
@poweur/client/node~/.poweur stores and DNS
@poweur/client/browserDNS-over-HTTPS resolver, localStorage key store

Sending​

// Default: signed with a short-lived session key, sent straight to the
// recipient's relay. Your home relay sees no outbound traffic at all.
await client.send("alice.poweur.net", "hello");

// Headless agents with no session cache can sign with the identity key —
// the `--sign-with=identity` equivalent.
await client.send("alice.poweur.net", "hello", { signWith: "identity" });

// Route through your own relay instead: hides your IP from the recipient's
// relay, at the cost of showing your own relay every message you send.
await client.send("alice.poweur.net", "hello", { viaHomeRelay: true });

Messages are always end-to-end encrypted. An identity with no published encryption key is a hard failure, never a plaintext downgrade.

Anonymous send​

No identity required — the recipient must have opted in, and may demand proof-of-work:

import { sendAnonymous } from "@poweur/client";

const cancel = new AbortController();

await sendAnonymous("alice.poweur.net", "hello stranger", {
onChallenge: ({ bits }) => console.log(`solving ${bits} bits…`),
onSolveProgress: (attempts) => console.log(`${attempts} hashes…`),
signal: cancel.signal,
});

The solver yields to the event loop between chunks, so a browser tab stays responsive. Expect roughly 2^bits hashes: JavaScript runs 5–10× slower than the Go solver, so warn users above ~20 bits. At those difficulties a UI needs both of the last two options — onSolveProgress to show that work is happening, and signal so the user can stop paying.

Browser vs Node​

The core touches no DOM API and no Node builtin — fetch and crypto come from globalThis. Three things differ by runtime:

  • DNS. Browsers cannot do DNS, so the browser build resolves TXT records over DNS-over-HTTPS (dohTxtResolver). That is a real trust difference: the DoH provider sees which identities you look up and answers instead of your system resolver. The web path (/.well-known/poweur/id.json) needs none of this and is always tried first.
  • Local sync. poweur sync's reconciliation engine needs a filesystem and is Node-only. Browsers get the remote half — changes feed, manifest, chunked upload — via SyncClient.
  • Key custody. FileKeyStore is Node-only. In a browser, wrap keys with a passkey PRF and implement KeyStore over that. A PIN is a non-goal.

CLI​

Installed with the package (see the note at the top):

npx poweur identity create myagent.poweur.net --hosted --relay https://poweur.net
npx poweur send alice.poweur.net "hello"
npx poweur inbox
npx poweur inbox --json --decrypt # for scripts: each message's text in "body"
npx poweur share add shared/project-x --with alice.poweur.net --perm rw

It reads and writes the same ~/.poweur tree as the Go CLI:

~/.poweur/config.toml           active identity, relay, keys dir
~/.poweur/keys/<id>.key Ed25519 private key
~/.poweur/keys/<id>.enc X25519 private key
~/.poweur/sessions/<id>.toml short-lived session record
~/.poweur/pending/<id>.jsonl delivery journal (the ✓ / ✓✓ ticks)

Either CLI can create an identity the other then uses; sessions registered by one are honoured by the other. POWEUR_HOME relocates the tree (tests use it). The full command list is in the CLI reference — the two are the same surface.

Errors​

Every failure is a PoweurError with a code you can branch on, rather than a message you have to match:

invalid_argument, invalid_signature, invalid_document, key_mismatch, not_found, resolve_failed, relay_error, session_expired, challenge_required, decrypt_failed, policy_rejected, unsupported.

RelayError adds status and the relay's own error code; ChallengeRequiredError carries the challenge envelope from a 428.

Fail-closed behaviour​

The resolver refuses rather than guesses, in every one of these cases:

  • web and DNS publish different signing keys → key_mismatch
  • the document's signature does not verify, or names a different identity
  • the well-known fetch redirects, exceeds 16KB, or targets a private/loopback address (unless allowPrivate is set, which is for tests and dev only)
  • a pinned contact's key changed with no rotation statement covering it → the CLI refuses to send until you pass --accept-new-key

Conformance​

packages/identity/testdata/vectors/ holds fixtures generated by Go — canonical strings and signatures, identity documents, share grants and groups, PoW solutions, name-policy cases, system documents, and sealed payloads. The TypeScript suite re-derives each one and compares.

Regenerate them whenever a protocol change lands in Go:

pnpm vectors

CI regenerates and then runs the TypeScript suite, so a canonical string changed in Go without a matching TypeScript change turns the build red.

Versioning​

Semver on the package, plus an exported PROTOCOL_VERSION. The EPIC-009 reserved envelope fields (type, thread_id, expires_at, metadata) are already typed, so adopting them is a minor version.