@pear-protocol/agent-sdk
v2.0.0
Published
TypeScript client for the Pear **agent chat** API — the conversational assistant (pair-trade ideas, market data, and confirm-before-write trade execution).
Readme
@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,useLinkStatus,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
# only if you use the React hooks:
pnpm add reactzod@^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({
// Inject your env — the SDK NEVER reads it.
baseUrl: import.meta.env.VITE_AGENT_PEAR_API_URL, // Vite
// baseUrl: process.env.NEXT_PUBLIC_AGENT_PEAR_API_URL, // Next.js
getToken: () => auth.accessToken, // returns the freshest JWT; called per request
tokenVersion: "v2", // "v2" wallet JWT (default) | "v3"
});
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) {
// A money-moving trade was STAGED, not executed — render Confirm/Cancel.
await client.confirmTicket(ev.result.pendingAction.ticketId);
// ...or: await client.cancelTicket(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).
Pass images (base64, no data: prefix, capped at 4) to streamChat to
attach chart/PnL screenshots — the SSE stream is GET-only and can't carry a
body, so streamChat first uploads the images (client.uploadImages →
POST /chat/images → bucket keys) and rides those keys on the stream, so an
image turn shows live states like a text turn. An image can propose a trade but
never auto-executes; the confirm-before-write ticket still guards writes.
On reload, getMessages rows for such a turn carry an attachments array
(ChatAttachmentSchema) — { description, imageUrl?, tgFileId?, mime }.
imageUrl is a freshly-minted presigned GET; Telegram-originated rows carry
tgFileId instead (rendered natively, no presign).
Pass runtimeContext (to either streamChat or sendMessage) to carry
request-scoped context from the surface the user is on. Today it holds the
in-progress Basket Builder draft under basketContext (RuntimeContextSchema /
BasketContextSchema) so the agent reasons over the ACTUAL basket rather than an
invented one:
for await (const ev of client.streamChat({
sessionId: id,
message: "review my basket and swap the weakest leg",
surface: "pear_pro",
runtimeContext: {
basketContext: {
name: "AI vs Memes",
items: [
{ symbol: "NVDA", side: "BUY", weight: 0.5 },
{ symbol: "DOGE", side: "SELL", weight: 0.5 },
],
timeframe: "4Hrs",
platform: "hyperliquid",
},
},
})) { /* … */ }It is never persisted — injected per-turn for planner routing + basket analysis. On the SSE path it rides the query string (JSON); malformed/oversized payloads are dropped, not rejected.
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.
Voice (speech-to-text)
client.transcribe({ audioBase64, format }) transcribes a voice clip to text
via POST /chat/transcribe (multilingual, auto-detect). format is the
container — "ogg" | "webm" | "wav" | "mp3" | "m4a" | "flac" (default
"webm"). Send the returned text as a normal turn — a voice-originated trade
still stages a confirm-before-write ticket.
// Browser: MediaRecorder → base64 → transcribe → review → send
const b64 = await blobToBase64(recordedBlob); // strip the data: prefix
const { text } = await client.transcribe({ audioBase64: b64, format: "webm" });
// put `text` in your input for the user to confirm, then sendMessage(text)The React hook exposes the same as useAgentChat().transcribe({ audioBase64,
format }), returning the transcript string (kept separate from sendMessage
so a voice-originated trade isn't auto-submitted). See apps/sdk-example's
Composer for a full mic-button implementation.
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 });
// render `messages`; if `pendingAction`, show Confirm/Cancel buttons wired to
// confirmTicket(pendingAction.ticketId) / cancelTicket(pendingAction.ticketId)
}sendMessage(text, cta?, images?, runtimeContext?) takes an optional third arg —
base64 image strings (no data: prefix) — to attach a screenshot: sendMessage("what's
this?", undefined, [base64]). Image turns stream just like text (the hook
uploads them and streams the reply token-by-token); the sent image also echoes
on the user's own bubble. The optional fourth arg carries runtimeContext (e.g.
a Basket Builder draft) through to streamChat for that turn.
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).
Trading credential (link status + linking)
The agent executes trades with a V3 engine API key stored against the
caller. getLinkStatus() is the one read every surface should gate on — it
reports whether a CREDENTIAL is on file, not merely that a link row exists, and
it accepts either token scheme:
const status = await client.getLinkStatus(); // backend-verified — don't cache
// { linked, walletAddress?, accountLabel?, tradeAccountId? }linked: false with a walletAddress is a returning user whose credential
predates the V3 engine — they need to link again, not sign up again.
The SDK does not mint keys. Minting is session-only against the Pear gateway, so it belongs to the app that holds the user's session. Mint there, then hand the raw key over — it lives in one local variable for the duration of one POST, and never reaches storage:
// 1. mint on the user's OWN gateway session (same call apps/connect makes)
const res = await fetch(`${GATEWAY}/api-keys`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({ label: "AITA-WEB", scope: "read_write" }),
});
const { key } = await res.json();
// 2. hand it to the agent (v3 session token required; the agent refuses a key
// whose owner isn't the caller)
await client.linkApiKey({ apiKey: key });linkApiKey also takes tradeAccountId to pin a default execution target when
the key reaches several accounts; omit it and the agent asks at trade time
(multipleAccounts: true in the result).
Pass linkCode when the link should bind a Telegram identity — the short-lived
code from the ?code= param on the /linkwallet deeplink Telegram sends the
user to. Omit it for a web-only link.
React apps use useLinkStatus(client) from ./react —
{ status, linked, isWorking, error, linkApiKey, refetch } (linked is null
while the initial fetch is in flight — render loading, not a gate).
Surfaces that cannot wallet-sign (e.g. a Base mini app) still read the same status: show "trading is off", and send the user to the connect app to link there rather than attempting the mint.
client.linkWallet() / client.unlinkWallet() remain for the v2 wallet-token
routes (/auth/link-wallet/self) — those write the link ROW, not the
credential, so they answer a different question than useLinkStatus and are
not a substitute for it. There is no React hook for them: the one that existed
(useWalletLink) reported linked from the link ROW, which contradicts the
credential-based meaning useLinkStatus gives that word.
X (Twitter) connection
Linking an X account unlocks the agent's X arc server-side: share-to-X win cards, flex-on-profit pings, a nightly interest digest of the user's own tweets, and tweet-mention trade intake. Requires a linked wallet. The OAuth dance runs in the browser — fetch the authorize URL and redirect:
const status = await client.getXLinkStatus(); // { connected, handle?, stale?, protected? }
const { url } = await client.getXConnectUrl(); // then: window.location.href = url
await client.disconnectX(); // idempotentstale: true means X revoked access — offer a re-connect. React apps can use
useXLink(client) from ./react —
{ status, isWorking, error, connect, disconnect, refresh } (status is
null while the initial fetch is in flight; connect() resolves { url }
for the redirect). See apps/sdk-example's TradeSettings for a working
connect/disconnect row.
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"); // { needsOnboarding, nextQuestion, isLast, answered, total, onboardingState, profile }
while (state.needsOnboarding && state.nextQuestion) {
const q = state.nextQuestion;
// closed questions carry q.options: { value, label, description? } — render
// label + description, submit value. Free-text note uses the typed string.
const value = q.freeform ? userTypedText : userPickedOption.value;
state = await client.submitOnboardingAnswer("pear_v3", { questionId: q.id, value });
}
// or bail: await client.skipOnboarding("pear_v3"); // smart-skip (parity with TG)
// start over: await client.resetOnboarding(); // clears picks — NOT for edits
// surface-static question set: await client.getOnboardingQuestions("pear_v3");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. UsesubmitOnboardingAnswerto edit;resetOnboardingis "start over" only.
const [state, questions] = await Promise.all([
client.getOnboardingState("pear_v3"),
client.getOnboardingQuestions("pear_v3"),
]);
// Render each question PRE-FILLED: e.g. select state.profile.tradingStyle.
// On a change, submit just that field (non-destructive merge):
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 />;
// First run — sequential wizard off currentQuestion + progress.
if (ob.needsOnboarding) return <OnboardingWizard {...ob} />;
// Edit — pre-fill from ob.profile, submit changed fields (no reset):
// ob.questions.map(q => …current value = ob.profile[…]…
// onChange={(value) => ob.submitAnswer(q.id, value)} )
return <Chat client={client} />;
}submitAnswer is overloaded: submitAnswer(value) answers the next first-run
question; submitAnswer(questionId, value) edits a specific field.
Memory (background recall agent)
The agent auto-distills a short fact-list summary per user from chat history (e.g. "Speaks Dutch", "Avoids >5x leverage"). Read it or wipe it:
const { memory, updatedAt } = await client.getMemory(); // both null until distilled
await client.clearMemory(); // { cleared: true } — also purges the underlying notesDeclared trade mix (onboarding cold-start prior)
The deterministic trade profile (DTP) falls back to this onboarding-declared
directional/pair/basket split while observed closed-trade history is thin
(<20 trades). Ask-once: asked is true once the caller has answered OR
skipped, and stays true — re-submitting still updates the answer, but a
polite client shouldn't re-prompt once asked is true.
const { declaredMix, asked } = await client.getDeclaredMix();
// declaredMix is null until answered (never asked, or asked-and-skipped)
await client.setDeclaredMix("pairs"); // "directional" | "pairs" | "baskets" | "mix" | "skip"React: useDeclaredMix(client) from ./react — mirrors useXLink (status,
isWorking, error, setStyle, refresh).
Journal (auto-journaled closed trades)
When a position closes, the background journal agent writes a narrated entry grounded in the computed trade facts (legs, PnL, ratios, …). Read the caller's entries newest-first, cursor-paginated:
const { entries, nextCursor } = await client.getJournal(); // default limit 20, max 50
// entries[].payload carries the raw facts the narrative was grounded in (legs, pnlAmount, pnlPercentage, …)
const nextPage = nextCursor ? await client.getJournal({ cursor: nextCursor }) : null;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.
confirmTicket() resolves a ConfirmResult — { ok, status, result?,
positionsSnapshot?, images? }. status is the ticket's settled state
(TicketStatusSchema); a confirm most often lands on "EXECUTED" or
"FAILED", but can also come back "EXECUTING" when the money path is
unresolved (a racing replay, an expired plan token, a binding mismatch) — that
is NOT a failure, it means re-read positions before assuming anything. result
is the classified venue verdict (ConfirmVerdictResultSchema): for a settled
outcome, switch on result.venueStatus (only "filled"/"absent" mean the
trade completed; "running"/"unreported"/"partial"/"unfilled" mean
submitted-but-unsettled; "failed"/"cancelled" mean refused) or just read
the collapsed result.ok. For an unresolved protocol response, result.state
is "execution_in_progress" | "missing" | "binding_mismatch" | "error" — never
auto-retry on "missing" or "binding_mismatch".
