@pear-protocol/agent-sdk
TypeScript client for the Pear agent chat API — the conversational assistant
(pair-trade ideas, market data, and confirm-before-write trade execution).
@pear-protocol/agent-sdk — framework-agnostic core (AgentChatClient).
@pear-protocol/agent-sdk/react — optional React hooks (useAgentChat, useAgentSessions, useWalletLink, useOnboarding).
Published to public npm (same registry as the org's other @pear-protocol
packages — no .npmrc or token needed). The wire types are a single source of
truth shared with the backend, so they can't silently drift.
Install
pnpm add @pear-protocol/agent-sdk zod
pnpm add react
zod@^4.1.0 is a required peer dependency (the SDK ships zod schemas at
runtime). react@>=18 is required only for the /react hooks.
Quickstart (core)
import { AgentChatClient } from "@pear-protocol/agent-sdk";
const client = new AgentChatClient({
baseUrl: import.meta.env.VITE_AGENT_PEAR_API_URL,
getToken: () => auth.accessToken,
tokenVersion: "v2",
});
const { id } = await client.createSession();
for await (const ev of client.streamChat({ sessionId: id, message: "Long ETH short BTC?" })) {
if (ev.type === "token") process.stdout.write(ev.delta);
if (ev.type === "done" && ev.result.pendingAction) {
await client.confirmTicket(ev.result.pendingAction.ticketId);
}
}
streamChat yields token | thinking | status | sources events, then a
synthetic { type: "done", result }. Token text is delta-only — accumulate
ev.delta. The SSE wire has no terminal done frame; the SDK assembles one. If
the backend emits a mid-stream error, streamChat throws an AgentChatError.
Aborting the stream (the hook's stop(), or your own AbortSignal) also stops
generation server-side — the backend detects the disconnect and winds the
worker job down at the next safe boundary (never mid-trade-write).
There's also a sync client.sendMessage({ sessionId, message }) returning a
ChatResult, and sessions/history methods (listSessions,
listSessionSummaries, createSession, getSession, deleteSession,
getMessages).
Both streamChat and sendMessage require a surface
(pear_v3 | pear_pro | base_mini | web) declaring which Pear client you
are — the API rejects requests without a valid one. The agent applies
per-surface universe and execution policy — e.g. base_mini surfaces only SYMM
assets and is analysis-only. (useAgentChat defaults to pear_v3; set it via
the returned setSurface.) The Telegram surfaces are resolved server-side and
are not client-declarable.
React
import { useAgentChat } from "@pear-protocol/agent-sdk/react";
function Chat({ client }) {
const {
messages, sendMessage, isStreaming, status, stop,
pendingAction, confirmTicket, cancelTicket, mode, setMode, surface, setSurface,
} = useAgentChat({ client });
}
useAgentChat uses plain React state (no forced data lib). useAgentSessions(client)
is a minimal session-list hook; apps using TanStack Query can skip it and call the
client directly.
Auth
getToken() returns whatever bearer token the agent API accepts (today a v2
HL-issued JWT). The SDK sends Authorization: Bearer <token> +
X-Auth-Token-Version. It does NOT own sign-in — wire it to your wallet/auth
flow. v3 callers are identity-only (no wallet → trade tickets disabled).
Errors
confirmTicket / cancelTicket throw typed errors: TicketExpiredError (410),
ForbiddenError (403, not your ticket), ConflictError (409, already handled),
AuthError (401). All extend AgentChatError (carries .status).
Wallet linking (trade authority)
The agent can only execute trades for a linked wallet. Linking is
self-service — the caller's JWT is the authorization, and the Pear API key is
minted server-side (it never exists in the browser):
const { linked } = await client.getLinkStatus();
if (!linked) await client.linkWallet();
await client.unlinkWallet();
React apps can use useWalletLink(client) from ./react —
{ linked, isWorking, error, link, unlink, refresh } (linked is null
while the initial status fetch is in flight).
Onboarding (deterministic, FE-controlled)
A first-time user can be onboarded with a short, deterministic wizard you render
yourself — the agent exposes the flow over REST (no in-band chat short-circuit).
Every endpoint returns the same state shape so you drive the loop off one type:
let state = await client.getOnboardingState("pear_v3");
while (state.needsOnboarding && state.nextQuestion) {
const q = state.nextQuestion;
const value = q.freeform ? userTypedText : userPickedOption.value;
state = await client.submitOnboardingAnswer("pear_v3", { questionId: q.id, value });
}
Editing preferences (pre-fill + non-destructive)
getOnboardingState returns profile — the caller's CURRENT picks
(experience/tradingStyle/riskAppetite/favoriteSectors/avoidAssets/note,
all optional; {} for a new user). Render an edit form from the full question set
profile, then submit only the changed field — the server MERGES, so editing
one field never wipes the others. Use submitOnboardingAnswer to edit; resetOnboarding
is "start over" only.
const [state, questions] = await Promise.all([
client.getOnboardingState("pear_v3"),
client.getOnboardingQuestions("pear_v3"),
]);
await client.submitOnboardingAnswer("pear_v3", { questionId: "risk_appetite", value: "aggressive" });
React apps can use useOnboarding({ client, surface }) from ./react —
{ needsOnboarding, onboardingState, currentQuestion, questions, profile, isLast, progress, submitAnswer, skip, reset, loading, error, refresh }.
Gate your wizard on needsOnboarding once loading is false; for editing, render
questions pre-filled from profile and call submitAnswer(questionId, value):
function App({ client }) {
const ob = useOnboarding({ client, surface: "pear_v3" });
if (ob.loading) return <Spinner />;
if (ob.needsOnboarding) return <OnboardingWizard {...ob} />;
return <Chat client={client} />;
}
submitAnswer is overloaded: submitAnswer(value) answers the next first-run
question; submitAnswer(questionId, value) edits a specific field.
Trade tickets (confirm-before-write)
A turn that would move money returns a pendingAction
({ ticketId, action, consequence, options: ["confirm","cancel"] }) instead of
executing. Render it, then call confirmTicket(ticketId) (executes) or
cancelTicket(ticketId) (discards). setTradeConfirmations(false) opts a user out
of the confirm step entirely.