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

@peeve/sdk

v3.0.1

Published

The Peeve SDK — one vanilla-JS package that works with any frontend (React, Vue, Angular, Svelte, plain JS) and a /server entry for the backend. Zero third-party deps.

Readme

@peeve/sdk

The official Peeve SDK — add the in-product AI cursor to any app. One vanilla-JS package that works the same in React, Vue, Angular, Svelte, or plain HTML, with a @peeve/sdk/server entry for backends. Zero third-party dependencies.

npm install @peeve/sdk

Quick start (any frontend)

Boot the cursor once, with your publishable workspace key (pk_* — safe in the browser). Then optionally tell it who the signed-in user is.

import { Peeve } from "@peeve/sdk";

Peeve.init({ publishableKey: "pk_live_…" });

Peeve.identify({
  userId: "user_123",
  email: "[email protected]",
  name: "Sam Lee",
  company: "Acme",
  phone: "+1 555 010 1234",
  plan: "Pro", // billing plan (display name) — surfaced in hand-offs
  createdAt: "2024-01-15T09:00:00Z", // account-creation time (ISO 8601 or epoch ms)
});
// Anything else about this user your app knows — not limited to the fields above.
// Stored against the user and surfaced to a teammate on hand-off + to the agent.
Peeve.setContext({ seats: 12, mrr: 4800, role: "admin", trialEndsAt: "2026-09-01" });

// on logout
Peeve.reset();

That's it — the cursor loads and runs on your site. The core is framework-agnostic; call Peeve.init() in whatever "on mount" hook your framework uses.

React / Next.js

"use client";
import { useEffect } from "react";
import { Peeve } from "@peeve/sdk";

export function PeeveInit() {
  useEffect(() => {
    Peeve.init({ publishableKey: process.env.NEXT_PUBLIC_PEEVE_PUBLISHABLE_KEY! });
  }, []);
  return null;
}
// render <PeeveInit /> once inside <body>.

Vue

import { onMounted } from "vue";
import { Peeve } from "@peeve/sdk";

onMounted(() => Peeve.init({ publishableKey: import.meta.env.VITE_PEEVE_PUBLISHABLE_KEY }));

Angular

import { Component, OnInit } from "@angular/core";
import { Peeve } from "@peeve/sdk";

@Component({ /* … */ })
export class AppComponent implements OnInit {
  ngOnInit() { Peeve.init({ publishableKey: environment.peeveKey }); }
}

Svelte / vanilla JS

import { Peeve } from "@peeve/sdk";
Peeve.init({ publishableKey: "pk_live_…" });

API — Peeve

| Method | Description | | --- | --- | | Peeve.init({ publishableKey, name? }) | Loads the cursor with your publishable key. Idempotent; no-op on the server (SSR-safe). name overrides the cursor's display name. | | Peeve.identify(userId, traits?) or Peeve.identify({ userId, email, name, company, phone, plan, createdAt }) | Unlocks the cursor. Until a page calls this, Peeve answers questions and hands off to a human — it never clicks, types or navigates, because there is no signed-in account to act in. Calling it also powers customer-context personalisation and lead capture. Required: userId, name, email, createdAt (a missing one is warned in dev); company, phone, plan are optional. plan is the user's billing plan (display name, e.g. "Pro") surfaced in hand-offs. createdAt (ISO 8601 or epoch ms) stops a returning customer being miscounted as a visitor→user conversion. Calls made before the widget finishes loading are queued and flushed. | | Peeve.setContext(ctx) | Attach any business context to the user as open key/value — not limited to the modelled identify fields (e.g. { seats: 12, mrr: 4800, role: "admin", lifecycle: "trial" }). Stored against the user and merged across calls (last value per key wins). Call it whenever the facts change: like identify, calls made before the widget finishes loading are queued and flushed, and calls made after it has loaded reach the running widget. See identify vs setContext. | | Peeve.reset() | Clear the identity + context (call on logout). |


identify vs setContext

Both attach information to the current user — the difference is what they're for:

  • identify is what tells Peeve a real person is here, and it is what the cursor's SHOW and DO modes require — an anonymous visitor gets ANSWER and ESCALATE only. It also sets the modelled identity fields Peeve understands first-class: email, name, company, phone, plan, createdAt. These are treated as the person's identity (PII is encrypted at rest) and drive continuity, lead capture, and the hand-off requester.
  • setContext attaches anything else your app knows about the user as an open key/value bag — seats, mrr, role, lifecycle, a workspace id, a feature flag, whatever. You're not limited to the fields we model, so you never have to cram custom data into company or wait for us to add a field.

Why it's useful — the context travels with the user:

  • On hand-off, your attributes show up in the teammate's Slack message as a Details section, so they see the full account picture (plan, seats, MRR, role…) without leaving Slack or looking the user up.
  • The agent gets them as reference context, so it can personalise answers ("On your Team plan with 12 seats, …") instead of asking the user to restate what your app already knows.
  • In the dashboard, they appear on the contact's profile.
// Identity — who they are (first-class fields, PII encrypted):
Peeve.identify({ userId: "user_123", email: "[email protected]", name: "Sam Lee", plan: "Pro" });

// Everything else your product knows — free-form, stored against the user:
Peeve.setContext({
  seats: 12,
  mrr: 4800,
  role: "admin",
  lifecycle: "trial",
  workspaceId: "ws_88f2",
});

Guidance: put identity/PII in identify (it's encrypted and modelled); put business context in setContext. It's non-secret, plaintext context — don't pass passwords, tokens, or anything you wouldn't want a teammate to see in a hand-off.


Passive signals: billing & churn intent (HTML data attributes)

Add a few data-peeve-* attributes to the markup you already have — your cancel button and your pricing cards — and the cursor reads them straight from the DOM. No extra JS, no events to wire up. These signals fire passively: they're captured on a plain pricing-page view or a click toward cancellation, even if the visitor never opens the assistant. That's the point — you see at-risk users before they churn, with the exact plan ($ and interval) attached to every signal, so you can tell apart "reached out before canceling" from "canceled cold."

You don't call anything — just annotate the element. After Peeve.init(), the cursor scans the page for these attributes.

| Attribute | On | Captures | | --- | --- | --- | | data-peeve-cancel | a cancel / downgrade control (button, link) | Presence alone marks the element as a cancel/downgrade control. Emits a passive churn cancel_intent signal — fires even when the assistant is never triggered. No value needed. | | data-peeve-plan="<name>" | a pricing plan card / row | Plan display name, e.g. "Growth". | | data-peeve-amount="<amount>" | a pricing plan card / row | Price, e.g. "99" or "$99.00". | | data-peeve-currency="<ISO 4217>" | a pricing plan card / row | Optional. ISO 4217 currency code for the amount, e.g. USD / EUR / GBP. Peeve infers it from the amount's currency symbol when present, else defaults to USD. Only rides with an amount. | | data-peeve-interval="<monthly\|quarterly\|yearly>" | a pricing plan card / row | Billing cycle. Use monthly, quarterly, or yearly (write annual as yearly). |

Together, data-peeve-plan / data-peeve-amount / data-peeve-currency / data-peeve-interval describe which plan a signal is about, so a pricing-page view or a cancel_intent is correlated to real revenue.

data-peeve-* is open — annotate anything, not just billing

The five attributes above are the ones Peeve gives special meaning to. But the sweep is generic: any data-peeve-<name> you invent is collected from the page and sent as context, so you can mark up whatever your app has and the agent sees it without you writing a line of JS.

<div data-peeve-workspace-tier="enterprise"
     data-peeve-onboarding-step="3-of-5"
     data-peeve-feature-flag="new-editor"
     data-peeve-invoice-status="past_due">

Those arrive as workspaceTier, onboardingStep, featureFlag, invoiceStatus — the data-peeve- prefix is dropped and the rest camelCased. Useful when the fact lives in the DOM already and you would rather not thread it through setContext.

The limits, so nothing surprises you:

  • 20 keys per page, 120 characters per value, and at most 4000 elements scanned. Past any of those, the rest is ignored — silently, by design.
  • Empty values are skipped, so a blank attribute is the same as no attribute.
  • data-peeve-theme is reserved (Peeve's own theming) and never collected, and the four structured billing attrs are excluded here because they are sent separately as plan data.
  • The sweep is fail-silent: a hostile DOM cannot break the widget through it.

It is page text, so treat it as public. These values ride to the server with the signal and are readable by anything on the page. Put IDs, statuses and tiers here — never tokens, secrets, or anything you would not show a teammate on a hand-off. For private-but-not-secret business context prefer Peeve.setContext, and for identity Peeve.identify.

Values are normalized before they're stored

Peeve normalizes every data-peeve-* value before storing it — it never keeps the host page's raw string — so signals stay consistent across apps and pages:

  • amount → a number, with currency symbol and thousands separators stripped ("$1,299.00" → 1299).
  • currency → an uppercased ISO 4217 code.
  • interval → the canonical monthly | quarterly | yearly.
  • plan → trimmed.

So the captured meta on each signal is { plan, amount: number, currency, interval }.

Annotate a cancel button

Presence of data-peeve-cancel is all it takes — the attribute needs no value:

<button data-peeve-cancel>
  Cancel subscription
</button>

Pair it with the plan attributes so the churn signal knows what's at stake:

<button
  data-peeve-cancel
  data-peeve-plan="Growth"
  data-peeve-amount="99"
  data-peeve-interval="monthly"
>
  Cancel subscription
</button>

Annotate a pricing plan card

<div
  class="plan-card"
  data-peeve-plan="Growth"
  data-peeve-amount="$99.00"
  data-peeve-currency="USD"
  data-peeve-interval="yearly"
>
  <h3>Growth</h3>
  <p class="price">$99<span>/yr</span></p>
  <button>Upgrade to Growth</button>
</div>

Now a visitor simply viewing this card emits a passive pricing-page signal tagged with the plan, amount, and interval — no assistant interaction required — which is exactly what lets a team spot upgrade interest and at-risk accounts early.


Advanced: PeeveClient

A low-level typed fetch wrapper if you want to call the planner directly instead of loading the cursor.

import { PeeveClient } from "@peeve/sdk";

const peeve = new PeeveClient({ publishableKey: "pk_live_…" });
const plan = await peeve.ask("where are my invoices?", { url: location.href });
console.log(plan.path, plan.steps);

ask(goal, opts) runs one planner turn against Peeve, authenticated with your publishable key. The request/response types (ActRequest / ActResponse) mirror the platform contract.

identify(id, traits?) attaches an identity to every subsequent ask: id becomes externalUserId, and the email / name / company / phone / plan / createdAt traits become the contact — every field PeeveContact declares. createdAt accepts ISO 8601 or epoch ms; pass it, because it is what stops a returning customer being counted as a new visitor→user conversion. Traits that are empty strings are treated as "nothing supplied", so no contact is sent.

peeve.identify("user_123", { email: "[email protected]", plan: "Pro", createdAt: 1700000000000 });

PeeveClient runs in the browser on a publishable key, so it carries no token and no stripeCustomerId — both are server-only (see PeeveServer.identify).

No default timeout here, unlike PeeveServer — that asymmetry is deliberate. In a page you already own cancellation through signal, and a hard deadline baked into the SDK would cut off a legitimately slow planner turn on a bad mobile connection, which is the moment a user most needs it to land. Pass your own when you want one:

await peeve.ask("where are my invoices?", { signal: AbortSignal.timeout(20_000) });

The 4 MiB response cap does apply in the browser: that one is about memory, not patience, and an oversized body throws PeeveResponseTooLargeError here too. See Timeouts & response limits.


Backend: @peeve/sdk/server

Server-only helpers. This entry is isolated so its secret-key code never ends up in a browser bundle — nothing reachable from @peeve/sdk can import it.

🔑 Key kinds — get this right or you leak an sk_ key

| Where | Key | What uses it | | --- | --- | --- | | Browser (any client bundle) | publishable, pk_* | Peeve.init, Peeve.identify, Peeve.setContext, PeeveClient | | Your backend only | secret, sk_* | PeeveServer.answer, .identify, .setContext, .killSwitch |

Every method on PeeveServer is secret-key, server-only. Never import @peeve/sdk/server from client code, and never expose an sk_* key to a browser — not in a NEXT_PUBLIC_* / VITE_* env var, not in a bundled config, not in an API response. A publishable key is safe in a page because it is origin-locked and can only ever drive the widget; a secret key is full workspace authority. If one has ever touched a client bundle, rotate it in Settings → API keys.

The key you hand the constructor is not readable back off the instance (since 1.0.0). It is a true private field, so it is absent from JSON.stringify(peeve), from util.inspect, and from Object.keys — which means logging the client itself cannot leak it. That matters because serialising whole objects is exactly what a structured logger does: logger.info({ peeve }) under pino or winston, a Sentry extra, or a request-context dump on an error all used to write sk_live_… into your logs and to your log vendor. If you were on 0.7.1 or earlier, assume the key reached your logs and rotate it.

The server SDK mirrors the browser — identify + setContext from your backend — and adds answer, which the browser has no equivalent of. Your secret key (sk_*) is given once to the constructor. identify returns a per-user handle, so you name the user once.

import { PeeveServer, killSwitch } from "@peeve/sdk/server";

// SECRET key (sk_*) — server-side only. Never ship this to a browser.
const peeve = new PeeveServer({ secretKey: process.env.PEEVE_SECRET_KEY! });

// ask a question, get a grounded answer (see `answer` below — handle BOTH branches)
const res = await peeve.answer({ message: "do you ship to Norway?" });

// identify returns a per-user handle. Only userId + token are REQUIRED; every
// other field is optional, but pass what you have — each one removes a question
// the agent would otherwise have to ask a customer about your own product.
const user = await peeve.identify({
  // ── required ────────────────────────────────────────────────────────────────
  userId: "user_123",                       // REQUIRED · your own id for this user
  token: process.env.SAMS_API_TOKEN!,       // REQUIRED · their token for YOUR api — server-side ONLY, never the browser

  // ── optional: modelled identity (PII, encrypted at rest) ────────────────────
  email: "[email protected]",                    // optional · also how Stripe/CRM lookups match, absent a stripeCustomerId
  name: "Sam Lee",                          // optional
  company: "Acme Inc",                      // optional
  phone: "+15551234567",                    // optional
  plan: "Pro",                              // optional · display name, e.g. "Pro" — not an entitlement
  createdAt: "2024-01-15T09:00:00Z",        // optional · ISO 8601 or epoch ms — stops a returning customer counting as a new conversion

  // ── optional: connector identifiers ─────────────────────────────────────────
  stripeCustomerId: "cus_QeXaMpLe123",      // optional · "cus_…" on YOUR connected Stripe — without it Peeve matches by email and takes the FIRST hit
});

// attach business context — attributes passed DIRECTLY, no id to repeat.
// Free-form: any key your team would want to see on a hand-off. Merged
// last-write-wins, so send the whole picture or just the field that changed.
await user.setContext({
  lifecycle: "trial",            // "trial" | "active" | "churned" | whatever you call it
  trialEndsAt: "2026-09-01",     // the agent can answer "how long do I have left?"
  seats: 12,                     // numbers stay numbers — no need to stringify
  seatsUsed: 11,                 // "you're 1 seat from your limit" without asking
  mrr: 4800,
  plan: "Pro",                   // fine to repeat here; identify's `plan` is the modelled one
  role: "admin",                 // so the agent doesn't walk a viewer to an owner-only screen
  accountId: "acct_8812",        // your own id, for a teammate to look up in your admin
  region: "eu-west",
  openTickets: 2,
});

// setContext-only (no identify) — grab a handle by id:
await peeve.user("user_123").setContext({
  lifecycle: "trial",
  trialEndsAt: "2026-09-01",
  seats: 12,
  plan: "Pro",
  role: "admin",
});

// call this workspace's live MCP server over JSON-RPC 2.0
await peeve.mcp(workspaceId, { method: "tools/list" });

// trip the kill switch (privileged)
await killSwitch(process.env.PEEVE_SECRET_KEY!, true);

answer — ask a question from your backend

peeve.answer({ message, conversationId?, userId?, email?, name? }) sends the question to Peeve with your secret key and returns a grounded reply — the same headless answerer that already serves inbound email, WhatsApp, Messenger and Instagram. Use it from a support-inbox worker, your own chat product, a Slack bot, or your own agent.

It answers from the workspace's Brain. It does not click, type, navigate, or drive a UI — there is no browser server-side, so there is nothing to drive. Anything that would need a live page comes back as escalate, honestly, rather than as a plan nobody can execute. If you want the cursor to do things inside your app, that is the widget's job, in the browser.

The result is a discriminated union — handle both branches. escalate is not an error: it means the agent will not guess, so a human is needed, and Peeve has already opened the hand-off in your console.

const res = await peeve.answer({
  message: "Do you ship to Norway, and how long does it take?",
  conversationId: thread.peeveConversationId, // omit on the first turn
  userId: "user_123",                         // same id as identify — one contact
  email: "[email protected]",
});

if (res.path === "answer") {
  await reply(res.say);                       // grounded reply for the customer
} else {
  // A real outcome, not a failure. A teammate already has it.
  await reply("Let me bring in a teammate — they'll follow up shortly.");
  log.info("peeve handed off", { reason: res.reason });
}

// Persist the handle — or the next turn starts a conversation with no memory.
thread.peeveConversationId = res.conversationId;
  • Multi-turn is the conversationId round trip, and nothing else. Pass the same handle back and every call continues one conversation — one transcript, one hand-off, one billing window — and the agent sees the history. Omit it and one is minted for you; it comes back on every response, so persist it.
  • Pass the identity you already hold (userId, email, name) so the agent doesn't ask a customer for details your product just handed it. userId is the same id as identify and the browser Peeve.identify, so all three converge on one contact. PII is encrypted at rest.
  • It shows up in your console like any other channel — the question, the reply, and the hand-off on escalate all land in Sessions / Hand-offs, not in a silent side channel. It bills one conversation credit per active day, the same as the widget.
  • Requires a Peeve deployment that supports headless answers (shipped alongside this release). Against an older one the call throws with status: 404.

identify / setContext — server-to-server

  • peeve.identify({ userId, token, email?, name?, plan?, company?, phone?, createdAt?, stripeCustomerId? }) upserts a user by userId and returns a PeeveUser handle. userId and token are required — handing over the token (their API credential for your service) is the whole reason to identify server-side. It's stored encrypted at rest, keyed on userId, decrypted only server-side when Peeve calls your API, never logged, never echoed. The other modelled fields are optional. A server-first identify stores a complete contact that a later browser identify (same userId) merges into.
  • user.setContext(attributes) (on the handle) merges non-secret business context (seats, plan, role…) — attributes passed directly. peeve.user(userId) returns a handle for setContext-only flows.
const user = await peeve.identify({ userId: "user_123", token: process.env.SAMS_API_TOKEN! });
await user.setContext({ seats: 12 });

stripeCustomerId — pass it if you use the Stripe connector

await peeve.identify({
  userId: user.id,
  token: userApiToken,
  email: user.email,
  stripeCustomerId: user.stripeCustomerId, // "cus_…" on YOUR connected Stripe account
});

Pass it and the agent acts on exactly that Stripe customer. Leave it out and Peeve falls back to finding the customer by email — a lookup that takes the first match — so a user with more than one Stripe customer under the same address (two checkouts, an import, a migrated account) can have a refund or a coupon applied to the wrong one, with nothing to signal that a choice was made. A user with no email gets no billing context at all.

Server-side only. There is no browser equivalent, deliberately: a value asserted by the page is a claim, while this call is already authenticated by your secret key.

It is not a secret. It names a customer inside your own connected account, so it cannot address anything outside it — no need to treat it like a credential.

Trimmed before sending, and omitted entirely when blank, so a stray space fails here rather than surfacing later as "no such customer" on a refund.

A user's API token must NEVER be passed in the browser. In the DOM it is reachable by any script on the page (including a prompt-injection payload), and possession of it is full-account authority. The browser Peeve.identify has no token field — the only supported path is PeeveServer.identify, from your backend.

Timeouts & response limits

New in 1.0.0. Every PeeveServer call now runs under a time budget and refuses an oversized response body. Before 1.0.0 there was neither — a stalled connection hung your request handler until something upstream killed it, holding a worker the whole time, and res.json() buffered whatever arrived.

| Call | Budget | Override | | --- | --- | --- | | identify · user().setContext · mcp · killSwitch | 15s (PEEVE_DEFAULT_TIMEOUT_MS) | timeoutMs on the constructor or the call | | answer | 60s (PEEVE_ANSWER_TIMEOUT_MS) | same | | PeeveClient (browser) | none, deliberately | your own signal | | Response body, every call | 4 MiB (MAX_RESPONSE_BYTES) | not configurable |

// per client — replaces BOTH defaults, including `answer`'s
const peeve = new PeeveServer({ secretKey: process.env.PEEVE_SECRET_KEY!, timeoutMs: 8_000 });

// per call — wins over the client's
await peeve.answer({ message: "…", timeoutMs: 30_000 });
await peeve.mcp(workspaceId, { method: "tools/list" }, { timeoutMs: 5_000 });
await peeve.killSwitch(true, { timeoutMs: 3_000 });
await peeve.user("user_123").setContext({ seats: 12 }, { timeoutMs: 5_000 });
await killSwitch(process.env.PEEVE_SECRET_KEY!, true, { timeoutMs: 3_000 });

// opt out entirely (waits forever, as 0.7.1 did) — strongly discouraged on a server
const unbounded = new PeeveServer({ secretKey: "…", timeoutMs: 0 });

Why answer is 60s and everything else is 15s. answer is the only model-backed method — it retrieves from your Brain and generates a reply, which legitimately runs into the tens of seconds on a long question. The other calls are small metadata round trips that a healthy API answers in well under a second, so 15s there is already many times the real p99: long enough to ride out a cold start or a retried TLS handshake, short enough that a stall fails inside one HTTP request rather than outliving it. killSwitch is the reason it isn't longer — its most important caller is an unattended watchdog, and one that blocks forever on the call meant to stop the agent has failed at its only job. Holding answer to the same number would have turned working integrations into timeouts, so it gets its own.

The budget covers the body read, not just the response headers. This is the half that actually protects you: a server that answers instantly and then trickles the body forever hangs a caller exactly as effectively as one that never answers at all, and only a clock spanning both catches it.

PeeveTimeoutError vs. your own AbortError

A timeout throws PeeveTimeoutError. Aborting your own signal still throws a plain AbortError. The two are kept distinct on purpose, because they mean opposite things — "I cancelled this" versus "Peeve did not answer" — and only the second is worth retrying:

import { PeeveServer, PeeveTimeoutError } from "@peeve/sdk/server";

try {
  const res = await peeve.answer({ message: "…", signal: req.signal });
  // …
} catch (err) {
  if (err instanceof PeeveTimeoutError) {
    // Peeve did not answer in time — ours to retry or degrade.
    log.warn("peeve timed out", { timeoutMs: err.timeoutMs });
    await reply("Sorry — I couldn't reach support just now. Try again in a moment?");
  } else if (err.name === "AbortError") {
    // The customer navigated away / the request was cancelled. Nothing to say.
    return;
  } else {
    throw err; // a real API error — `status` and `body` are attached
  }
}

PeeveTimeoutError carries timeoutMs and names the host and path it gave up on. It never contains your key.

Oversized responses fail — they are not truncated

A body over 4 MiB throws PeeveResponseTooLargeError (with limitBytes) instead of being read. Peeve never returns a body that large — the biggest real payload is an MCP tools/list for a workspace with every connector enabled, a few hundred KB of JSON schema at the extreme — so the cap sits roughly an order of magnitude above the worst legitimate case and cannot be tripped by normal use. What it stops is an unbounded read turning one API call into an out-of-memory on your backend.

It fails rather than truncating because a clipped JSON body is the dangerous outcome, not the large one: truncation can still parse into an object that looks real and is quietly missing fields, and you would act on it. An exception you can catch is strictly better than a plausible lie. The body streams against the cap and stops at the first chunk that crosses it, so the oversized payload is never fully in memory; an over-cap content-length is refused before a single byte is read. This one is a constant, not an option — a knob here would get raised the first time someone hit it, which is exactly when it is doing its job.


Upgrading to 1.0.0

Most apps upgrade with no code change. Four things behave differently, and all four are silent until they bite — so it's worth a read before you bump.

| What changed | Who it affects | What to do | | --- | --- | --- | | Server calls now time out (15s; answer 60s). They previously hung forever. | Backends that relied on a long call, or that wrapped these in their own retry/timeout | Raise timeoutMs, or set timeoutMs: 0 to keep the old unbounded behaviour | | A stalled call throws PeeveTimeoutError instead of hanging | Anything with a catch around a PeeveServer call | Handle it, or let it surface — it is an Error with timeoutMs | | Responses over 4 MiB throw PeeveResponseTooLargeError | Nobody in practice — Peeve doesn't return bodies that large | Nothing | | secretKey is no longer readable off the instance | Code doing peeve.secretKey, or logging that relied on seeing it | Keep the key in your own config/env and read it from there |

Check for peeve.secretKey. It used to be an ordinary property; it is now a true private field and reads back as undefined. TypeScript flagged it before, but plain JS did not, so a grep -rn 'secretKey' your-src/ is worth the ten seconds. And if you ran 0.7.1 or earlier, assume the key was written to your logs by any structured logger that serialised the client — rotate it in Settings → API keys.

Check anything relying on bundler-only resolution. Through 0.7.1 the build emitted extensionless import specifiers, so @peeve/sdk and @peeve/sdk/server resolved only inside a bundler that guesses extensions — under plain Node ESM both entries failed with ERR_MODULE_NOT_FOUND. 1.0.0 resolves properly, which means @peeve/sdk/server now genuinely works in a plain Node process (a worker, a script, a test run) where it previously could not load at all. If you worked around that — vendoring the file, aliasing it in a bundler config, pinning a resolver hack, or reaching into node_modules/@peeve/sdk/dist/... directly — remove the workaround. Deep paths like that were never supported and are blocked by the exports map.

Also in 1.0.0: ActRequest.secretKey is gone from the types. It described putting a secret key in the body of a browser request to a publishable-key-only endpoint, so no caller could ever have used it successfully; the SDK now also strips any secretKey or publishableKey found in ask's context bag before sending.


Notes

  • Keys. Use a publishable key (pk_*) in the browser (Peeve.init, Peeve.identify, Peeve.setContext, PeeveClient); a secret key (sk_*) only on your backend — every PeeveServer method (answer, identify, setContext, killSwitch) is secret-key and server-only. Never import @peeve/sdk/server from client code, and never put sk_* in a NEXT_PUBLIC_* / VITE_* variable. See the key-kinds table under Backend: @peeve/sdk/server.
  • Tokens are server-only. A user's API credential is passed to Peeve exclusively via PeeveServer.identify (server-to-server, secret key). It never travels through the browser.
  • Server-side is answers, not actions. PeeveServer.answer returns an answer grounded in your Brain; it cannot click, navigate, or drive a UI, because there is no browser. Driving the UI is the widget's job — there is no server-side act().
  • Server calls time out; browser calls don't. Every PeeveServer method has a 15s budget (answer: 60s), overridable per client or per call with timeoutMs, and an expiry throws PeeveTimeoutError rather than the AbortError your own signal produces. PeeveClient has no default deadline — in a page you own cancellation. Responses over 4 MiB throw PeeveResponseTooLargeError on both. See Timeouts & response limits.
  • Zero dependencies. Platform fetch + (server) Node builtins only.

License

MIT © Peeve