Skip to main content

SDK

Status

Both packages are built and in use: @signo-fi/sdk (server, Node 20+, ESM) and @signo-fi/sdk-web (browser, React and framework-free). We deliver them as tarballs today; the npm publish is waiting on the package scope. Import paths, the exports map and the shapes below are the ones the tarballs already carry, so nothing changes on your side when they land on npm.

Getting the packages​

Until they are on npm, both packages are installed from tarballs. We build them from the repository and send you the two files:

npm install /path/to/signo-fi-sdk-0.1.0.tgz /path/to/signo-fi-sdk-web-0.1.0.tgz

They install as a pair. @signo-fi/sdk-web peer-depends on @signo-fi/sdk, so a tarball of one with a different version of the other will not resolve. React 18 or 19 is a peer dependency of the browser package's React entrypoints.

Nothing else changes when they are published: the import paths, the exports map and the shapes below are the ones the tarballs already carry, and npm install @signo-fi/sdk @signo-fi/sdk-web will replace the two paths above.

What the SDK is for​

Partners should not orchestrate intent, plan and simulate themselves, should never put a partner key in a browser bundle, and should not hard-code Signo's Telegram bot. The server package holds the key and the workflow; the browser package holds the UI and talks only to your backend.

browser (@signo-fi/sdk-web) → your backend (@signo-fi/sdk, holds sg_ key) → app.signo.fi/api/v1beta

@signo-fi/sdk (server)​

import { SignoClient } from "@signo-fi/sdk";

const sdk = new SignoClient({
apiKey: process.env.SIGNO_API_KEY!, // server only, never in a browser bundle
baseUrl: process.env.SIGNO_API_BASE, // optional, defaults to https://app.signo.fi/api/v1beta
timeoutMs: 300_000,
});

Public request and response fields stay snake_case, exactly as on the wire. Requests are never retried automatically.

SIGNO_API_KEY is the key you generate in the Signo Partner Portal under API Keys — the only place keys are issued. One key per partner during beta; rotate or revoke it there, and email the Signo integration team at contact@signo.fi if you need more.

MethodPurpose
chat.stream, chat.completeIntent only, as SSE or one JSON result
chat.streamTurn, chat.turnThe full turn: intent → plan → simulation, streamed or collected
plan.create, simulate.run, steps.reencodeDirect typed access to the endpoints
agents.create / list / get / update / delete / test / eventsAgent management and event polling
webhooks.create / list / delete / verifyEndpoint lifecycle and inbound HMAC verification (X-Signo-Signature)
telegram.linkStatusWhether a wallet has an active Telegram link
catalog.listYour partner-scoped in-page agent definitions
surfaces.resolve / activate / reviewAssemble an in-page agent, activate it (directly when the wallet is already linked, through Telegram otherwise), open a prepared result
usage.get, meta.getMetering and capabilities

A streamed turn, rendering progress as it arrives:

for await (const event of sdk.chat.streamTurn({
prompt: "Move idle USDC into this market",
wallets: [{ address, chain: "arbitrum", balances: { USDC: 100 } }],
page_context: { surface: "market-detail", market: subjectId, chain: "arbitrum" },
}, { signal: abortController.signal })) {
// event.type: intent | planning | plan | simulation | result
}

chat.turn() collects the same workflow into one result. For a server-held conversation, create one controller per conversation with createConversation, never a singleton shared across users. createChatHandler gives your backend the request handler the browser kit talks to.

@signo-fi/sdk-web (browser)​

Never sees a partner key. Three things it renders:

In-page agents. Resolve the surface on your backend with sdk.surfaces.resolve, hand the browser-safe response to SignoAgentTrigger (variant="icon" | "button" | "banner") and SignoAgentSheet. The sheet shows the agent's fields and actions, existing agent status, the Telegram connection state and any prepared result; its onActivate and onReview callbacks POST to your authenticated backend, which forwards to surfaces.activate / surfaces.review. On an activated outcome the sheet stays on the page and shows the saved configuration; it navigates only on requires_action. Pass a fresh surface object after a re-resolve and it supersedes the sheet's own result. See In-page agents below for the flow that is live today.

Drop-in chat. SignoChat streams responses, shows progress, handles clarification questions and renders plan and simulation results. contextKey names a placement your backend can resolve; identityKey is a local reset key, not authentication. Plan-only unless you supply signing callbacks.

Your own UI. useSignoChat (React) or createChat from @signo-fi/sdk-web/core expose messages, status, progress, send, answer, cancel, reset.

"use client";
import { SignoChat } from "@signo-fi/sdk-web/react";

export function MarketAssistant({ userId, walletAddress, marketId }) {
return <SignoChat
endpoint="/api/signo-chat"
contextKey={`market:${marketId}`}
identityKey={`${userId}:${walletAddress}`}
title="Market assistant"
suggestedPrompts={["Explain this market", "Build a deposit plan"]}
/>;
}

Signing progress, and who owns which part​

SignoChat's plan card renders two different things and keeps them apart on purpose:

  • The plan-time simulation, labelled as such. It ran against the planned amounts before anything was refreshed, so it is history, not the current state. When a group's result names the failing step, the card says which step failed and tells the others in that group they were blocked by it rather than that they each reverted.
  • The current attempt, from a fresh prepareStep for that one step. Its stages are the ones the SDK drives: refreshing the quote, simulating, waiting for your wallet.

Everything after that belongs to your host, because only the host can see it:

<SignoChat
prepareStep={prepareOnMyBackend}
onSignStep={signAndWaitForReceipt} // resolve ONLY after a verified receipt
stepStatus={step => hostState[step.order]} // awaiting-approval | submitted | confirmed | rejected | failed
onPrepareStage={(step, stage) => log(step.order, stage)}
/>

onSignStep resolving is what the card treats as confirmation, and it says "Confirmed by your app" rather than claiming Signo verified anything. If you report a state through stepStatus, that state replaces the card's own label for the step, so the two can never contradict each other. Never resolve onSignStep on a submitted hash: a hash is not a receipt.

Amounts are rendered with their token, from the step's typed amount and asset. amount_raw is never displayed, because a base-unit integer needs the token's decimals to mean anything, and the input amount is never shown as if it were the expected output.

When a re-encode is refused​

POST /steps/reencode answers 200 with ok: false when it cannot rebuild a step. Branch on unsupported_code, not the sentence:

CodeWhat it means
quote_faileda live re-quote or on-chain preview failed. Usually worth one retry.
target_changedthe protocol answered with a different contract than the user reviewed. Re-plan; do not sign.
protocol_unsupportedthat protocol has no rebuild path, so a generic swap would be wrong.
step_type_unsupportedno re-encode path for this step type.
step_incompletethe step is missing something needed, e.g. a source wallet.
unknown_targetan asset, chain or protocol we cannot encode for.
invalid_requestthe body was not a step we could read.

unsupported_reason stays alongside it as the human sentence, and remains the right thing to show a person. Whatever the code, never fall back to signing plan-time calldata.

A 429 carries Retry-After in seconds, computed from your key's real rate window. Honour it instead of inventing a backoff.

In-page agents (live today)​

Live now on the /v1beta API and the hosted MCP server (signo_agent_catalog, signo_surface_resolve, signo_surface_activate, signo_telegram_link_status). The React components above render it; until they ship, call the endpoints from your backend.

A page in your product that shows one thing a user might want watched (a market, a vault, a position) gets a Turn on control. The user taps it. If their wallet is already linked to Telegram, Signo arms the agent for that exact subject right there and confirms in your page. If not, Signo links the wallet through Telegram and arms it on completion. Your backend holds the key; the browser only ever sees browser-safe state and, when a hand-off is needed, a short-lived opaque activation URL.

GET /agent-catalog the in-page agent definitions configured for your tenant
POST /surfaces/resolve browser-safe state for one agent, wallet and subject
POST /surfaces/activate validate the submitted values; arm directly or mint a short-lived activation URL
POST /surfaces/review exchange a prepared result for a one-time review URL
GET /telegram/link-status whether a wallet already has an active Telegram link

All of these require the agents scope and a partner configuration on your tenant. Responses are private, no-store. link-status is not credit-metered.

1. Resolve the surface​

Send the user's wallet and a stable subject for the page. Signo returns the catalog definition, the Telegram connection state, any existing agent for that identity, and (if you ask for it) a prepared result. Agent keys come from GET /agent-catalog.

curl -s https://app.signo.fi/api/v1beta/surfaces/resolve \
-H "Authorization: Bearer $SIGNO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent": "<agent key from /agent-catalog>",
"wallet": { "address": "0xUserWallet", "chain": "ethereum" },
"subject": { "kind": "market", "id": "0xSubjectAddress", "chain": "ethereum" },
"display_context": { "title": "The name your page shows for this subject" }
}'

Subject rules: market, vault, position and asset need id and chain; wallet has no id. display_context is presentation only: it never fills a missing subject, authorizes anything, or changes which agent is matched.

Reading positions​

Before you build an id, read one. GET /positions returns the wallet's addressable positions with the canonical id already on each row:

const { positions, degraded, unavailable } = await sdk.positions.list({ wallet });

for (const position of positions) {
const subject = { kind: "position", id: position.id, chain: position.chain };
}

Each row carries owner, protocol, reference and kind split out of the id, plus value_usd and, where the protocol has them, apy_pct, days_to_expiry, and the Saffron fields claimable, started, accepted_hours_ago, fill_pct. Narrow the list with protocol, kind or chain. One wallet per call; requires the agents scope and is not credit-metered.

Addressable means the position has a composite id: Pendle and Saffron today. Holdings on other protocols are real, they just cannot be a position subject, so they are not listed here.

degraded: true means one of the protocol reads failed or timed out, and unavailable names it. While that is set, an empty positions array means "could not look", not "this wallet holds nothing", so re-read before acting on it. When every source fails the call is a 503 positions_unavailable rather than an empty list.

The MCP equivalent is signo_positions.

Position ids​

A position subject is addressed by a composite id, not a bare address:

<owner>|<protocol>|<reference>|<kind>
0x2be4d36fae51fd3b82e3bfcf635859b23ce99c82|pendle|0x3f5ea53d1160177445b1898afbb16da111182418|pt
SegmentWhat it is
ownerthe wallet holding the position; must equal the wallet.address you send on the same call
protocolpendle or saffron
referencethe protocol's own key: a Pendle market address, a Saffron vault address
kindpt, yt or lp for Pendle; fixed or variable for Saffron

EVM addresses in owner and reference are lower-cased, as is kind, so an id you build matches the one echoed back on resolve. A malformed id, an unsupported protocol or kind, or an owner that is not the request wallet is refused with 400 invalid_request on subject rather than arming an agent that can never match a position.

Build it with the SDK instead of assembling the string:

import { positionId, parsePositionId, positionBelongsTo } from "@signo-fi/sdk";

const id = positionId({ owner: wallet, protocol: "pendle", reference: market, kind: "pt" });
parsePositionId(id); // { owner, protocol, reference, kind } | null
positionBelongsTo(id, wallet); // the check the API makes for you

positionId throws on a protocol or kind the API cannot address, because an id nothing matches is worse than an error. Reach for it when you have to construct an id from page data you already hold; when you can read the wallet, positions.list hands you the id.

Agent identity is the tenant plus wallet, agent key and normalized subject. Field values and the chosen action are configuration, so a later activation updates that agent instead of creating a second one.

For a page control that only connects Telegram, resolve with { "intent": "link", "wallet": … }.

2. Activate​

When the user submits the sheet, your backend forwards the submitted values to POST /surfaces/activate. Read outcome on the response:

  • activated: the wallet already had a live Telegram link, so the agent was saved in this call. The response carries agent (public id w_condition_…, status, saved field_values, action_id, activated_at), change (created, updated, resumed or unchanged) and rendered (the condition in words). There is no URL; show the confirmation in your page.
  • requires_action: the response carries a short-lived opaque activation_url and expires_at. Open it as-is. It takes the user to Telegram, links only that wallet, arms the agent, and confirms in the chat. Link-only activation (intent: "link") is always this outcome.

Two example responses:

{ "outcome": "activated", "intent": "agent",
"agent": { "id": "w_condition_k17f2a9c", "status": "active",
"field_values": { "dir": "above", "threshold": 12 }, "action_id": "propose-entry",
"activated_at": 1788905000000 },
"change": "created", "rendered": "reUSD yields above 12%" }
{ "outcome": "requires_action", "intent": "agent",
"activation_url": "https://signo.fi/go?p=pd_opaque", "expires_at": 1788905600000 }

Repeat submits are harmless: the same configuration is a no-op that keeps the agent's evaluation state, a changed threshold or action updates the same agent, a paused one resumes, and a relabel refreshes the words only. Agent identity is tenant + wallet + agent + subject; display_context never changes it.

One agent, three views​

surfaces.resolve().existing.id, the agent.id in an activated response and agents.list/get().agent.id are the same public id (w_condition_…) for the same saved agent. Resolve shows it scoped to a page subject; the agents endpoints show it as one row among the tenant's agents. Reading both does not mean two agents exist; join by the id. Where the display text lives:

Fieldsurfaces/resolve existingsurfaces/activate (activated)agents list / get
public idyesyesyes
statusyesyesyes
field_values, action_idyesyesno (see rendered)
rendered (one-line condition)yesyesyes

What each creation path supports​

OperationSurface agent (created by surfaces/activate)General agent (created by POST /agents)
Change configurationsurfaces/activate again with new values (change: updated)PATCH /agents/{id} with a new request
Free-text PATCH requestrefused, 400 invalid_patch (the subject and action are bound)yes
Pause / resumePATCH /agents/{id} { "status": "paused" | "active" }, or surfaces/activate (change: resumed)PATCH /agents/{id}
DeleteDELETE /agents/{id}DELETE /agents/{id}
Test firePOST /agents/{id}/testPOST /agents/{id}/test
Prepared action on fireyes, when the catalog action is prepareyes, with autonomy: "prepare" and an action — see Prepare agents

What the sheet does and what your host supplies​

SignoAgentSheet covers configure, turn on, save changes and resume, and shows the saved state. It does not render pause, delete or test-fire controls; if your page needs them, call PATCH/DELETE/test from your backend with the id from existing. Do not key the sheet on existing.id: its form identity is wallet + agent + subject, and the saved agent appearing after the first creation must not remount it.

What a test fire proves​

POST /agents/{id}/test on a surface agent evaluates the condition once for evidence, writes a test notification whether or not the condition holds, runs the prepared action when the agent has one, and attempts delivery (Telegram, and webhook events if your tenant has an endpoint) before returning. So: a 202 proves the attempt was made; the message arriving on Telegram proves delivery; the prepared result in that message proves preparation; nothing about a test proves the threshold was actually crossed. The test message is labelled as a test.

Authorization is yours to enforce​

The sg_ key authorizes your backend to call the API for your tenant. It does not authorize any particular end user: with the key, resolve and activate work for any wallet address, and pause, delete and test work for any agent id your tenant created. Knowing an address or an id is not permission. Before you activate for a wallet, change its agent or delete it, authenticate that the user in your session controls that wallet (a signed message, or your own account link to it). Read-only monitoring of an address needs no ownership; mutations on a saved agent do.

Custom agents are separate from this flow: POST /agents with the same key creates a Notify agent (or, with autonomy: "prepare" and an action, a Prepare agent) from plain English or a typed request, including conditions that are not about your protocol (an ETH price, say). The partner scope gates prepared actions, not what a user may be told about.

3. Prepared results​

Resolve with "include": ["result"]. When result.review.available is true, exchange it:

curl -s https://app.signo.fi/api/v1beta/surfaces/review \
-H "Authorization: Bearer $SIGNO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "schema_version": "1", "agent": "<agent key>", "wallet": { … }, "subject": { … }, "result_id": "…" }'

{ review_url, expires_at } comes back. Review URLs are minted on click, are one-time and expire quickly. A result holds only versioned summary text, rationale, a generic plan preview and typed rate, term, amount or destination details; nothing is signed.

Choosing the button copy​

GET /telegram/link-status?address=&chain= → { address, chain, linked }. Show Turn on when linked is true and Turn on with Telegram when it is false. The response never includes a chat id.

For the simplest integration, no key at all: a public deep link that mints a one-time code and redirects to Telegram.

https://signo.fi/go?agent=<agent key>&wallet=<address>&chain=<slug>[&mode=<mode>][&partner=<your partner id>][&market=<id>]
  • agent is the catalog key, wallet the user's address (EVM 0x… or Solana base58, original case), chain a slug from GET /meta.
  • Cards with modes require mode (a mode-only card without one answers 400).
  • A card's numbers travel as query parameters named after its template keys, for example &threshold=6; a number the card does not declare is rejected.
  • The response is a 302 to t.me/<bot>?start=pd_…. The code is single-use and expires; a reused or expired link tells the user to go back and tap Turn on again. Nothing secret is in the URL.

Prefer the surface flow when you want the sheet, prepared results and agent status on your page; use the public link when a plain button is enough.

Until the packages ship​

Use the API directly: the integration guide covers intent → plan → simulate, the agents guide covers standing monitors and webhooks, and the section above covers the catalog, surfaces and the public Turn on link, all of which need nothing but an sg_ key on your backend.