Skip to main content

Agents

Private beta

Agents ship as part of the /v1beta API — same posture as the rest of it: keys come from the Signo Partner Portal (one per partner during beta, email the Signo integration team at contact@signo.fi for more), and the surface may evolve behind the Signo-Schema-Version header until GA.

Programmatic alerts: price crosses, yield/health-factor moves, portfolio swings, a recurring check-in, or a freeform plain-English condition — created over the API, delivered to a webhook endpoint you register (or pulled on demand). Agents default to Notify: an agent tells you something happened and never signs or executes anything on its own. A Prepare agent also stores an action and, when it fires, hands you an unsigned plan to review and sign — see Prepare agents. Automatic execution exists only through a signed Shield permission.

  • Base URL: https://app.signo.fi/api/v1beta (same as the rest of the API)
  • Auth: Authorization: Bearer sg_beta_..., scope agents
  • Metering: not charged against your credit balance — gated instead by a per-tenant active-agent quota (beta 50 / standard 500 / high 5000, by rate tier) and the usual per-key rate limit
  • Reference: every endpoint, schema, and error code is in the API Reference (generated from the OpenAPI spec) — this page is the narrative walkthrough

An agent triggers whether or not you've registered anything to receive it. There are two ways to see its triggers:

  • Recommended — register a webhook endpoint. POST /webhook-endpoints once, and every trigger is signed and pushed to it automatically. No polling.
  • Optional alternative — poll GET /agent-events. Skip registration entirely and read triggers on your own schedule instead.

An agent created with no endpoint registered still arms, evaluates, and triggers exactly the same — it just has nowhere to push to, so nothing shows up unless you poll. If you're building an always-on integration, register an endpoint first; use the pull rail for a quick test, a backfill, or a partner that genuinely prefers polling.

Quickstart​

Three calls: register where triggers get delivered, create an agent in plain English, then trigger a test trigger so you can see the whole loop without waiting for a real market move.

1. Register a webhook endpoint (recommended).

curl https://app.signo.fi/api/v1beta/webhook-endpoints \
-H "Authorization: Bearer sg_beta_..." \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/webhooks/flip"}'
{
"endpoint": { "id": "k9c7f2ab", "url": "https://example.com/webhooks/flip" },
"signing_secret": "whs_3f9a7b2e1c4d8f6a0b5e9c2d7a1f4b8e6c0d3a9f7b2e5c1d8a4f0b6e3c9d7a2f"
}

Store signing_secret now — it's returned exactly once and can't be fetched again (only its hash is kept server-side). You'll use it to verify inbound deliveries; see Verifying signatures below. Up to 5 endpoints per tenant.

Skipping this step is fine too — it just means step 3 below won't push anywhere until you poll GET /agent-events instead.

2. Create an agent.

curl https://app.signo.fi/api/v1beta/agents \
-H "Authorization: Bearer sg_beta_..." \
-H "Content-Type: application/json" \
-d '{"request":"notify me when ETH is above 5000","wallets":["0x8f2a...d5"]}'
{
"agent": {
"id": "w_condition_k17f2a9c",
"type": "condition",
"kind": "condition",
"status": "active",
"created_at": 1753142400000,
"wallets": ["0x8f2a...d5"],
"intent": "ETH is above 5000",
"rendered": "ETH price is above $5,000",
"cadence": "hourly"
},
"compile": { "rendered": "ETH price is above $5,000", "fire_headline": null, "cadence": "hourly" }
}

Same compile pipeline the Signo app and Telegram bot run on your text — if it's ambiguous you get a clarification instead of an agent; see The clarification round-trip.

intent is the trigger the compiler extracted, not a verbatim echo of what you sent — "notify me when ETH is above 5000" is stored as "ETH is above 5000". You already hold the original request; what you can't reconstruct is our interpretation, which is what intent and rendered give you.

Agent ids​

Every response that contains an agent — create, list, get, and the agent.id inside a trigger event — returns the same public id, shaped w_{type}_{id}. Store it and pass it back verbatim on /agents/{id}, /agents/{id}/test, PATCH and DELETE.

Two things worth knowing so you don't try to build one yourself:

  • The prefix is the storage type — alert, condition, or reminder — not the public kind. A kind: "price" agent has a w_condition_… id. There is no reliable way to derive the id from kind, which is exactly why events carry it for you.
  • A bare id without the prefix is not valid on path endpoints and returns 404 agent_not_found.

Which wallets can I watch?​

Any address you can write down. Notify and Prepare agents are read-only at fire time — they observe prices, yields, health factors and balances and emit an event (a Prepare agent's plan is unsigned until the owner signs it) — so there is no signature, no connected session, and no ownership check involved. A test wallet, a wallet your user gave you, or an address that has never touched Signo all work identically.

That also means the check is yours: the key authorizes your backend for your tenant, not any particular end user. Management operations apply to agents your key is authorized to manage, not every row originally created with that key. An agent with owner-only Execute authority is excluded; see Pausing, resuming, deleting. Authenticate that the user in your session controls the wallet before you create, change or delete an agent on their behalf.

wallets[0] is the agent's owner for bookkeeping; the array preserves the case you send (Solana is case-sensitive).

3. Trigger a test trigger to see a real delivery immediately, instead of waiting for ETH to actually cross $5,000. It returns 202 Accepted: the test event is written and a delivery attempt starts before the response returns, not on a later tick. The 202 proves the attempt; the message or webhook arriving proves delivery. A test never proves the condition was actually met (it fires whether or not it holds, and says so in the message):

curl -X POST https://app.signo.fi/api/v1beta/agents/w_condition_k17f2a9c/test \
-H "Authorization: Bearer sg_beta_..."

Within the next delivery tick (every ~2 minutes) your endpoint receives:

POST /webhooks/flip HTTP/1.1
Content-Type: application/json
X-Signo-Event-Id: evt_k7m2ab91
X-Signo-Signature: t=1753142400,v1=5f6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e

{
"id": "evt_k7m2ab91",
"type": "agent.test",
"created": "2026-07-30T18:41:10.000Z",
"agent": { "id": "w_condition_k17f2a9c", "kind": "condition" },
"trigger": { "headline": "🔔 ETH price is above $5,000", "kind": "condition", "condition": "above", "threshold": 0, "wallet": "0x8f2a...d5" }
}

Return any 2xx within 10 seconds. That's the full loop — a real trigger looks identical except type: "agent.triggered" and, for kinds other than condition, real trigger data (value_at_trigger / price_at_trigger / change_pct) alongside trigger.

The id in an event is the id you act on

agent.id is the same public w_{type}_{id} value create and list return, so you can pause or delete the agent straight from a trigger without a lookup table. Note the prefix is the storage type (alert | condition | reminder), not the kind — a kind: "price" agent is w_condition_…. Always pass the id back verbatim rather than rebuilding it from kind.

Verifying signatures​

Every delivery carries X-Signo-Signature: t=<unix_seconds>,v1=<hex hmac> — the Stripe signed-webhooks convention: hmac_sha256(signing_secret, "<t>.<raw_body>"). Verify against the raw, unparsed request body (not a re-serialized JSON.stringify of the parsed object — whitespace differences will break the signature), with a constant-time comparison, and reject stale timestamps. This mirrors the reference implementation Signo itself uses to sign (verifySignature in lib/webhooks/sign.ts):

const crypto = require("crypto");

function verifyFlipSignature(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => {
const i = kv.indexOf("=");
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}),
);
const { t, v1 } = parts;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;

const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");

const expectedBuf = Buffer.from(expected, "hex");
const gotBuf = Buffer.from(v1, "hex");
if (expectedBuf.length !== gotBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, gotBuf);
}

// Express example — must read the raw body before any JSON-parsing middleware
app.post("/webhooks/flip", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyFlipSignature(process.env.SIGNO_WEBHOOK_SECRET, req.header("X-Signo-Signature"), req.body.toString("utf8"));
if (!ok) return res.status(401).end();

const event = JSON.parse(req.body);
// ... handle event.type, dedup on event.id ...
res.status(200).end();
});

Delivery: retries, dead-lettering, dedup​

Delivery is at-least-once. Dedup on the payload's id (same value as the X-Signo-Event-Id header) — never assume a trigger arrives exactly once.

Acknowledge with any 2xx within 10 seconds. Anything else — non-2xx, a timeout, or a redirect (redirects are never followed) — is a failed attempt, retried with backoff:

AttemptWaits before next try
11 minute
25 minutes
330 minutes
42 hours
512 hours

After attempt 5 fails, that delivery is dead-lettered — no further retries for that specific trigger (it's still visible via GET /agent-events, the pull rail, described next). If an endpoint racks up 10 consecutive dead-lettered deliveries, it's auto-disabled (GET /webhook-endpoints shows status: "disabled") — fix your receiver and re-register, or flip it back on, to resume.

The pull rail​

No endpoint registered, or catching up after an outage? GET /agent-events returns the same triggers, cursor-paginated, newest-first:

curl "https://app.signo.fi/api/v1beta/agent-events?limit=50" \
-H "Authorization: Bearer sg_beta_..."
{
"events": [
{
"id": "evt_k7m2ab91",
"type": "agent.triggered",
"created_at": 1753142400000,
"agent": { "id": "w_condition_k17f2a9c", "kind": "condition" },
"trigger": { "headline": "ETH crossed above $5,000", "kind": "condition", "condition": "above", "threshold": 0, "wallet": "0x8f2a...d5" }
}
],
"next_cursor": null
}

The id is the same dedup key either rail hands you, and agent/trigger are built from the same event mapping the pushed webhook uses — same fields, same values, for the same trigger. Two differences to know about:

  • Timestamp. This rail returns created_at as an integer epoch-ms; the pushed webhook's equivalent field is created, an ISO-8601 string — same instant, just a different field name and format.
  • Key casing is the same on both rails. trigger is snake_case (value_at_trigger, price_at_trigger, change_pct) whether the event arrived by push or was pulled, so one parser handles both.

condition and threshold are present on every trigger (both rails) even for kinds where they're not meaningful — condition- and schedule-kind triggers always show the placeholder "above" / 0. Trust them for price/yield/health/portfolio kinds.

The clarification round-trip​

A plain-English request that's too broad to compile into one measurable check returns 200 instead of 201:

{
"status": "needs_clarification",
"question": "Which \"gas\" — Ethereum L1 gwei, or a specific chain?",
"options": [
{ "id": "opt_0", "label": "Ethereum L1 gas (gwei)" },
{ "id": "opt_1", "label": "Base gas (gwei)" }
]
}

Three things to know before you build against this:

  • It's a 200, and so is nothing else about it. A clarification and a successful create are both 2xx. Branch on the body, not on res.ok — check for status === "needs_clarification" before you go looking for agent.id. An agent that only checks the status class will silently treat a clarification as a created agent.
  • Prefer a persisted selection id when one is returned. Candidate-backed options use the returned id as selection_id, allowing the server to reuse the compiled choice without another interpretation pass. Positional fallbacks (opt_0, opt_1, …) have no persisted candidate; for those, send the visible label in the request text.
  • options can be empty, and is capped at 4. When it's empty you still get a question — fall back to a free-text input rather than rendering an empty picker.

Render the options, then re-POST with the picked label folded into the next request string:

{
"request": "notify me when gas is cheap\n\nClarification — Which \"gas\" — Ethereum L1 gwei, or a specific chain?: Base gas (gwei)",
"wallets": ["0x8f2a...d5"]
}

When the selected option has a persisted candidate id, include it too:

{
"request": "notify me when gas is cheap",
"selection_id": "sel_...",
"wallets": ["0x8f2a...d5"]
}

Same inline convention /intent's clarification flow uses — there's no server-side conversation state to carry, so the answer travels inside the next request string.

That string is re-compiled as plain language, not parsed, so the exact \n\nClarification — {question}: {label} template isn't magic — it's simply a shape that compiles reliably because it keeps the original request and the answer together. Any phrasing that states both will work; what fails is sending only the answer, since the original intent is gone.

The reply can itself come back as another needs_clarification if it's still ambiguous. Treat it as a loop with a cap — two or three rounds, then surface the question to a human rather than retrying forever.

Handle both outcomes for a vague request

Whether a very broad request comes back as a 200 clarification or a 422 unwatchable is not deterministic today — the same text can classify either way on different calls, because an LLM decides whether it's a watchable condition at all before the clarification step runs. Handle both: on 422, show the reason and let the user rephrase rather than assuming the clarification round-trip is broken.

PATCH /agents/{id} with a new request (recompiling a condition agent's text) follows the identical round-trip.

Display strings​

Every agent carries a rendered one-liner suitable for showing a user — "BTC price is above $65,350", "aave health factor drops below 1.5". For plain-English agents it's what the compiler produced from the request; for typed kinds Signo builds it from the fields you sent, so you don't have to invent a label per kind. Plain-English agents also carry the original intent.

Prefer rendered for display and fall back to your own string only if it's absent. It is returned by GET /agents, GET /agents/{id}, and for in-page agents by surfaces/resolve (existing.rendered) and an activated activation response, so one read is enough.

Treat it as display text, not data. Formatting is not guaranteed stable or consistent between kinds — typed agents are formatted by us ("ETH price is above $1,973.26") while plain-English ones are phrased by the compiler ("ETH price is above 5,000"), so currency symbols and rounding can differ. Never parse rendered to recover a threshold or an asset; every agent returns those as structured fields.

Typed kinds reference​

Skip the plain-English compile step by sending a typed body instead — { kind, wallets, ...fields }, 201 immediately, no LLM call. All fields are snake_case, same as the rest of /v1beta.

kindRequired fieldsNotes
priceasset_symbol, coingecko_id, condition (above|below), thresholdcoingecko_id must be the literal CoinGecko id ("ethereum", not "ETH") — look it up yourself, e.g. via CoinGecko's /coins/list. Optional pct_change/base_price to trigger on a percent move instead.
yieldpool_id, protocol, chain, asset, condition, thresholdOptional metric: apy (default) | supplyApy | borrowApy (these three values are the literal wire values — not converted).
healthprotocol (aave|spark), chain, thresholdFires when the health factor drops below threshold — a decimal like 1.5, never a percent (values ≥ 100 are rejected as a likely mistake). Optional target_wallet if the address to watch differs from the owner wallet in wallets[0].
portfoliothresholdPercent move, always the "above" direction (no down-only portfolio agent). Optional asset_symbol to scope to one holding instead of the whole portfolio; optional movement_window (24h default | 7d).
schedulecadence (daily|weekly|monthly), hour, minute, timezoneA recurring notify-only check-in, not a market condition. timezone is required and explicit here (contrast the plain-English schedule path, which anchors to UTC noon since there's no browser timezone to read from a raw API call). day_of_week required for weekly, day_of_month for monthly. Optional label for a display name.

condition (the plain-English kind) isn't a typed-create option — you only get one by sending request instead of kind.

Every agent's wallets array preserves the case you sent, for every kind (Solana addresses are case-sensitive).

When an agent actually triggers​

Worth understanding before you test, because it's the difference between "broken" and "working exactly as designed".

Evaluation runs on a schedule, not continuously. Typed price agents run hourly because their price snapshot is refreshed hourly. Notify/Prepare yield and health-factor agents normally run about every 5 minutes. Portfolio-movement agents run hourly. Shield Execute agents normally run hourly, with the continuation exceptions. Plain-English condition agents use their stored cadence floor, usually hourly or daily. An agent's last_checked_at advances on each pass whether or not it triggers.

After the first check, triggers are edge-based, not level-based. A typed price agent can fire on its first hourly check when the price is already on the trigger side. After that, it triggers when the condition becomes false and then true again.

Three consequences:

  • The first typed-price check can trigger. If the price is already on the requested side at the first hourly evaluation, the agent can fire then.
  • Movement between checks is invisible. If the price crosses your threshold and comes back between hourly price snapshots, both samples land on the same side and nothing triggers. Polling a price feed every 20 seconds will show crossings we never see.
  • Agents re-arm. They are not one-shot: each genuine crossing triggers again. A typed percent-price agent keeps the threshold supplied at creation; it re-arms when that condition becomes false and fires when it becomes true again. It does not ratchet the threshold after a firing.
Testing this deliberately

Use POST /agents/{id}/test to verify your integration. It's deterministic and immediate, and it exercises the identical delivery path a real trigger uses. For an in-page agent with a prepared action it also runs the preparation, so the test message carries a prepared result. It does not evaluate the condition as a gate: the test fires either way.

For a natural typed-price trigger, wait for the next hourly evaluation. It can fire on that first check when already true; later firings need a genuine false-to-true crossing between hourly snapshots. Do not wait on a market move to prove an integration: use the deterministic test endpoint above.

Health, yield and portfolio agents follow the same evaluate-then-compare rhythm on their own cadences.

Quotas​

  • Active agents per tenant: 50 (beta) / 500 (standard) / 5000 (high), by your key's rate tier. POST /agents past the cap returns 429 agent_quota_exceeded — pause or delete one first.
  • Webhook endpoints per tenant: 5. POST /webhook-endpoints past the cap returns 429 endpoint_quota_exceeded — remove one first (GET /webhook-endpoints shows what's registered).
  • Rate limit: the same per-key tier as the rest of /v1beta (GET /meta for the exact numbers) — a 429 rate_limited here is unrelated to the agent-count quota above; POST /agents can return either code at 429.
  • Credits: none of this — agents, events, and webhook-endpoint management are not metered against your credit balance at all.

Errors​

The codes you'll actually meet on these endpoints. error is the stable machine value — match on it, not on friendly_message, which is prose and may change.

StatuserrorMeansDo
401invalid_keyMissing or malformed bearer keyCheck the Authorization header
403scope_forbiddenKey isn't authorized for agentsAsk us to add the scope — not a code bug
404agent_not_foundNo agent with that id for your tenantCheck you sent the full w_{type}_{id}
400invalid_requestBody failed validationRead issues[] — it names the field
429agent_quota_exceededAt your tier's active-agent capPause or delete one
429rate_limitedPer-key request rateBack off and retry
402insufficient_creditsTenant is out of creditsPartner ops — don't surface to end users

Requests from a region where the service is unavailable return HTTP 451 at the edge. That response contains an error message but not the normal application error envelope. Handle the status before matching the table above.

404 agent_not_found is deliberately returned for an agent that exists but belongs to another tenant — we never distinguish "not yours" from "not there", since that would leak whether an id is real.

Prepare agents: a trigger that pre-plans a transaction​

Agents default to Notify: a fire tells you something happened, and what to do about it is on you. Set autonomy: "prepare" and the agent also stores an action. When it fires, Signo runs the action through the same planner the app uses and hands you an unsigned plan to review and sign. Nothing is ever signed or broadcast on your behalf — every execution still needs a wallet signature.

{
"kind": "price",
"wallets": ["0x1111111111111111111111111111111111111111"],
"asset_symbol": "ETH",
"coingecko_id": "ethereum",
"condition": "below",
"threshold": 3000,
"autonomy": "prepare",
"action": "Swap 10 USDC to ETH on Arbitrum",
"idempotency_key": "eth-entry-v1"
}

Plain-English requests can carry the action in request instead ("when ETH drops below 3000, swap 10 USDC to ETH on Arbitrum") — but Prepare is never inferred. Without autonomy: "prepare" a "when X, do Y" request is armed as notify-only and the action half is dropped, exactly as before. With it, the action is required: an explicit Prepare with no action returns 422 action_required, and an action on a Notify agent returns 422 action_requires_prepare. autonomy: "execute" is not accepted here (422 execute_requires_shield) — automatic execution needs a signed Shield permission, see Shield below.

What is stored is an instruction, not a quote. Balances, routes, amounts and any clarification are resolved when the agent fires, against the wallet's state at that moment. Naming a chain in the action does not add support for a protocol or an action the planner does not already have.

Fire, plan, deliver. A fire (real or via POST /agents/{id}/test) queues one planning job, which retries transient failures up to three times. The outcome is published as an agent event on both rails — webhook and GET /agent-events — with a preparation object carrying the outcome and, when a plan was built, a private review_url:

preparation.outcomeMeans
readyA plan was built. Open review_url to see refreshed quotes and sign.
clarificationThe planner needs one answer before it can plan (an amount, a venue).
informationalNothing to plan — the planner answered with information instead.
failedPlanning failed (including insufficient credits). Top up, then POST /agents/{id}/test to try again.
cancelledThe agent was paused, edited or deleted while the job was in flight.

The event's created_at is publication time (after planning); preparation.triggered_at keeps the original trigger time. Review URLs are bearer links: anyone holding one can open the review, so do not publish them. They expire after seven days, and the review page regenerates quotes before asking for a signature.

Editing. GET and list responses include autonomy and action. PATCH /agents/{id} accepts an autonomy / action change on any tenant-owned agent; omitting them on a trigger edit keeps the existing action. Set autonomy: "notify" to drop the action.

Cost. Creation and Notify fires are quota-gated, not metered. A ready outcome is charged the standard 5-credit partner planning rate, once per fire; clarifications, informational and failed outcomes and cancelled jobs are not charged.

Delivery. Partner-created agents deliver on webhooks or event polling. They need no Telegram account, unlike the keyless drafts below.

In-page agents created through surfaces/activate keep their own model: a catalog action with the prepare capability pre-plans on fire and hands back a reviewable result. See Prepared results.

Keyless drafts (no API key)​

An assistant with no key can draft a Notify or Prepare agent for any wallet and hand the owner a review link; the same call is POST /agent-drafts and the public MCP tool signo_draft_agent. That flow is documented for its own audience under For AI assistants. Two things matter from the partner side: a claimed draft belongs to the owner, not to a tenant, so it never appears in your GET /agents; and your keyed MCP client does not carry the public tools, so add the server a second time without the header if you want them.

Shield: permissions and protection​

Automatic execution — an agent that spends without a signature at fire time — is only available through Signo Shield, an on-chain permission the owner signs once. The API and MCP let a caller prepare a Shield proposal and, with a partner key, read its state; authorizing it is always the owner, on the review page, with their wallet.

Drafting a permission needs no API key. POST /shield/proposals with no Authorization header, or the public MCP tool signo_draft_permission, takes the permission shape below and returns a private review_url. It is rate limited like keyless drafts, the proposal expires after 72 hours, and it is stored under a public tenant that no key can read — the owner reads it on the review page and signs there. The three reads need a key.

New Execute setup is in early access. Before storing a proposal, the service checks the named wallet. A wallet without access gets 403 execute_team_only with the early-access message, and no proposal is stored. Direct the owner to support@signo.fi rather than retrying the same request. Existing live permissions continue under their signed terms.

  • POST /shield/proposals stores a proposal and returns proposal_id, a private review_url, a plain-English summary, the limits, the live fee_bps and expires_at (unix milliseconds). It never authorizes spending. Free. With no key it takes the permission shape only; with a partner key (agents scope) it takes either shape.
  • GET /shield/proposals/{id} reads the proposal. GET /shield/proposals/{id}/permission reads the permission once authorized (null before). GET /shield/proposals/{id}/runs lists what the permission has executed, most recent first (limit 1 to 100).
  • All reads are tenant-scoped: a proposal you did not create is 404.

The request is a permission (ShieldPermissionRequest, schema_version: 2): an explicit trigger (a supported plain-English condition, compiled like an in-app Execute agent with the part the Shield can read pinned on chain and the rest decided by scheduled checks; or a typed health_factor, token_balance, or price: the asset's current USD price from the Aave oracle on the permission's chain, Chainlink-backed). It does not make every Notify preset an Execute trigger: Execute is not available for the app's Portfolio move trigger, boxed Pendle PT loops are notify-only today, and a recurring schedule is not an Execute trigger. The request also includes an action (transfer, swap, vault, supply, repay_from_wallet or repay_from_collateral), a lifetime budget and a per-execution cap_per, both before Shield fees, and an expiry_days (1 to 90, default 30). The older protection short-form (a body with no schema_version) is retired; a body in that shape answers 400 with the mapping — its wallet and collateral actions are repay_from_wallet and repay_from_collateral here (the old names are still accepted as aliases), with a health_factor trigger. Today Shield runs on X Layer (chain_id: 196) and Arbitrum One (chain_id: 42161), for a single wallet. The action kinds: transfer sends the asset to one fixed recipient (never the owner wallet itself); swap goes through OKX DEX into token_out and takes exactly one of min_out or max_slippage_bps; vault deposits into an ERC-4626 vault; supply supplies into Aave; repay_from_wallet repays Aave debt from the wallet's own balance; repay_from_collateral repays Aave debt by selling collateral down to a target_health_factor.

The access, asset and budget checks run before anything is stored, and a refusal stores nothing:

  • Assets go by the symbol Signo uses for them on X Layer (XETH, XBTC, USDT0, WOKB, USDG, USDC, GHO, XSOL, XBETH, XOKSOL; USDT is accepted for USDT0, bare BTC and ETH resolve to XBTC and XETH, and a swap output of OKB becomes WOKB), by their ERC-20 address, or by any symbol on OKX's X Layer token list (about 660 tokens: AMDx, INTCx, IBMx, DMCX, …). One match resolves to that token; when several listed tokens share a symbol the request answers 422 needs_clarification and issues[] lists the candidates with their addresses and token pages, so pass the address you mean. The symbols are names for resolving addresses, not an allowlist: any ERC-20 on X Layer works for transfer and swap; Aave actions need an Aave reserve.
  • On Arbitrum the symbols Signo uses are WETH, USDC, USDT, WBTC, ARB and DAI; USD₮0 and USDT0 resolve to USDT, bare BTC resolves to WBTC, and a swap output of ETH becomes WETH. The fallback is LI.FI's Arbitrum token list. The rest works the same way.
  • Budget arithmetic. Both amounts are before fees. cap_per must be positive and no greater than budget (it may equal it). At 10 bps, cap_per: "100" and budget: "100" is valid: the registered lifetime cap and token approval cover 100.1. The fee buffer is rounded up to the token's smallest unit. A positive cap_per above budget returns 400 invalid_budget.

A native coin cannot be spent by Execute. Shield moves approved tokens. For a transfer, supply, repayment or swap input, wrap OKB to WOKB on X Layer or ETH to WETH on Arbitrum; an attempt to spend native OKB or ETH is refused at setup. A swap that buys the chain's own coin is different: Signo drafts WOKB or WETH as the output, and the review page names the wrapped token before the owner signs.

The owner wallet does not need to hold the budget when the proposal is made: every firing checks the owner's balance and allowance for that firing, and one the wallet cannot pay does not run. The permission registers on Shield v1.

What the owner signs is exactly the summary shown on the review page: the target and spender are pinned, every execution is capped, the lifetime budget is enforced on-chain, and a firing that would breach any of them is cancelled by the contract. fee_bps is stamped into the permission at signing; see Fees for the current rate. A proposal that cannot be compiled returns 422 with the compiler's reason.

Explain firing behavior before authorization. An already-true condition can fire at the first hourly check. Start after Pause re-arms it, so it can fire at the next check without first turning false. One successful execution completes a trigger by default. Afterward, an agent that stays active normally waits for a false reading and a new crossing. An "any X" condition or two to six top-level OR parts can instead fire for each new matching item, one run at a time, while the overall condition stays true. Waiting items are checked again after a four-minute cooldown; an item that stops matching can fire again when it returns. Repay from collateral continues toward its target only when the owner turns on Keep going until the target in the app before signing. The option is off by default, older permissions behave as off, and the public API and MCP request shape do not opt in. With it on, an Aave-limited partial firing can continue after that cooldown, within the signed limits, until its target, an owner-limit stop, ten firings or six hours. A check that could not act because of sizing, balance, approval, a venue stop or a service-wide execution pause can try again while the condition stays true. A sent firing that fails is retried once at the next hourly check. The signing service allows at most two execution attempts for one permission in a rolling hour. After each successful firing the owner receives Goal, Done, Remaining, Reasoning and Next, including the confirmed transaction, what limited a partial result and whether another execution is possible. See Execute checks.

Pausing, resuming, deleting​

PATCH /agents/{id} with { "status": "paused" } or { "status": "active" } works for supported Notify/Prepare agents your key is authorized to manage, including in-page agents. It does not grant control of an owner-only Execute agent, an Execute setup in progress, or an agent with a linked permission. A free-text request edit is refused for an in-page agent (400 invalid_patch); change those through surfaces/activate. DELETE /agents/{id} removes it and is idempotent — a repeated delete, or one aimed at an id you don't own, still returns 204. For owner-only Execute rows, pause and edits return not found; delete returns 204 without removing the agent. A successful delete response is not proof that an on-chain permission was revoked. The owner uses Signo to manage that authority. Both operations are described in full in the API Reference.

MCP​

Everything above is also available as MCP tools on Signo's hosted server at https://app.signo.fi/api/mcp (Streamable HTTP). The tool set depends on whether you send a key:

No Authorization header — three public tools. wallet_insights_report is the read-only wallet report, whose suggestedAgents feed straight into the drafting tool. signo_draft_agent is the MCP form of keyless drafts: { request, wallet?, autonomy? } in, a draft with a review_url out. signo_draft_permission is the MCP form of a keyless POST /shield/proposals in the permission shape: a stored proposal with a review_url out, nothing authorized until the owner signs. The Execute early-access check applies here too: a wallet without access gets 403 execute_team_only, and no proposal is stored. The public tools expose no account-management or private proposal reads and cannot spend; the wallet report reads public data. An assistant can add the server with no configuration at all and prepare an agent or a permission for its user to claim.

With a partner key — the full set. Same capabilities, same key, same quotas as REST:

ToolSame as
signo_agent_createPOST /agents (Notify or Prepare)
signo_agent_listGET /agents
signo_agent_getGET /agents/{id}
signo_agent_updatePATCH /agents/{id}
signo_agent_deleteDELETE /agents/{id}
signo_agent_testPOST /agents/{id}/test
signo_agent_eventsGET /agent-events
signo_agent_catalogGET /agent-catalog
signo_webhook_endpoint_createPOST /webhook-endpoints
signo_webhook_endpoint_listGET /webhook-endpoints
signo_webhook_endpoint_deleteDELETE /webhook-endpoints/{id}
signo_draft_permissionPOST /shield/proposals (also public)
signo_shield_proposalGET /shield/proposals/{id}
signo_shield_permissionGET /shield/proposals/{id}/permission
signo_shield_runsGET /shield/proposals/{id}/runs

The tools were called signo_agent_prepare and signo_draft_permission until 2026-09-22; those names still work, unlisted, until 2026-10-06.

A bad or expired key is refused with 401 — it never falls back to the public tool set, so a misconfigured client cannot quietly lose its capabilities. The reverse also holds: a valid key replaces the three public tools, they are not in a keyed client's tool list. A client that already has a key and also wants to draft agents for other people's wallets adds the server a second time, without the header:

claude mcp add --transport http signo-public https://app.signo.fi/api/mcp

An agent working through these tools gets the same recommendation as the REST quickstart above: call signo_webhook_endpoint_create first so triggers push to you, and fall back to signo_agent_events only if you'd rather poll. Add the server once and all of the tools above (plus signo_intent/signo_plan/signo_simulate/ signo_reencode/signo_usage) come with it:

claude mcp add --transport http signo https://app.signo.fi/api/mcp \
--header "Authorization: Bearer sg_beta_..."

See API (beta) for the Cursor config and more on the hosted MCP server.

Reference​