@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.
Maintainers
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/sdkQuick 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:
identifyis 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.setContextattaches 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 intocompanyor 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-themeis 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
PeeveServeris secret-key, server-only. Never import@peeve/sdk/serverfrom client code, and never expose ansk_*key to a browser — not in aNEXT_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), fromutil.inspect, and fromObject.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 Sentryextra, or a request-context dump on an error all used to writesk_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
conversationIdround 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.userIdis the same id asidentifyand the browserPeeve.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 byuserIdand returns aPeeveUserhandle.userIdandtokenare required — handing over thetoken(their API credential for your service) is the whole reason to identify server-side. It's stored encrypted at rest, keyed onuserId, 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 (sameuserId) 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.identifyhas no token field — the only supported path isPeeveServer.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 — everyPeeveServermethod (answer,identify,setContext,killSwitch) is secret-key and server-only. Never import@peeve/sdk/serverfrom client code, and never putsk_*in aNEXT_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.answerreturns 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-sideact(). - Server calls time out; browser calls don't. Every
PeeveServermethod has a 15s budget (answer: 60s), overridable per client or per call withtimeoutMs, and an expiry throwsPeeveTimeoutErrorrather than theAbortErroryour ownsignalproduces.PeeveClienthas no default deadline — in a page you own cancellation. Responses over 4 MiB throwPeeveResponseTooLargeErroron both. See Timeouts & response limits. - Zero dependencies. Platform
fetch+ (server) Node builtins only.
License
MIT © Peeve
