ophis. Open Ophis

Docs · API reference · v1

Intent Parser API.

A single endpoint that turns natural-language swap descriptions into structured order intents. Free, rate-limited at 30 requests/min per IP, no auth, no key required. Build agents, Telegram/Discord bots, browser extensions, or your own UIs on top of Ophis's intent layer.

← Back to docs Source on GitHub

Endpoint

POST https://ophis.fi/api/intent

Runs on Cloudflare Pages Functions at the edge. Upstream model is LibertAI Qwen 3.5 122B. Response is structured JSON — no streaming, no SSE, single request/response with a 5-second upstream timeout.

Headers

HeaderRequiredNotes
content-typeYesapplication/json
originBrowser onlyIf present, must match the allow-list (see Origins). Server-side callers (no Origin header) are allowed.

Request

{
  "text": "swap 100 USDC for ETH on Base"
}
FieldTypeConstraints
textstringRequired. Non-empty. Max 280 chars.

Response — success

{
  "ok": true,
  "data": {
    "intent": "swap",
    "entities": [
      { "type": "amount",    "value": "100",      "raw": "100",     "start": 5,  "end": 8 },
      { "type": "sellToken", "value": "USDC",     "raw": "USDC",    "start": 9,  "end": 13 },
      { "type": "buyToken",  "value": "ETH",      "raw": "ETH",     "start": 18, "end": 21 },
      { "type": "chain",     "value": "base",     "raw": "Base",    "start": 25, "end": 29 }
    ]
  }
}

The model is allowed to emit any DEX-traded symbol it recognises; entities whose value isn't in the server-side allow-list (236 tokens / 14 chains) are silently filtered out before the response. The top-level shape is the contract — extra entities never appear.

ParsedIntent

FieldTypeNotes
intent"swap" | "unknown""unknown" means the input wasn't recognised as a swap. Always returns 200, not an error.
entitiesEntity[]Possibly empty. Order matches input text-position.

Entity

FieldTypeNotes
type"sellToken" | "buyToken" | "amount" | "chain"Entity role.
valuestringCanonical form. Uppercase symbol for tokens (USDC, WETH, PEPE), lowercase slug for chains (ethereum, base, arbitrum), decimal numeric string for amounts.
rawstringExact substring from the input that matched.
startint0-indexed character offset in the input. Inclusive.
endint0-indexed character offset. Exclusive. text.slice(start, end) === raw.

Response — error

{
  "ok": false,
  "error": {
    "code": "RATE_LIMITED",
    "message": "too many requests"
  }
}

All error responses share this shape. HTTP status reflects the error class (400/403/429/500/502/504). The message is human-readable and may change between deploys — clients should branch on code, not on text.

Error codes

CodeHTTPMeaning
BAD_INPUT400Missing text, empty text, text over 280 chars, or invalid JSON body.
FORBIDDEN403Browser Origin header present but not on the allow-list.
RATE_LIMITED42930/min per IP exceeded. Response includes a Retry-After header in seconds.
UPSTREAM500 / 502LibertAI returned non-2xx, or the function isn't configured (LIBERTAI_API_KEY missing).
TIMEOUT504LibertAI didn't respond within 5 seconds.
INVALID_JSON502LibertAI returned content that didn't parse as the expected schema. Rare; usually transient.

Rate limits

30 requests / minute / IP. Sliding window, KV-backed (distributed across all Cloudflare edge isolates). Counts every request, including ones that return FORBIDDEN or BAD_INPUT.

When you hit the limit, you get a 429 with a Retry-After header. Back off and retry after that many seconds. If you need higher throughput for a legitimate integration, reach out — the function is open-source and we'll happily document a self-hosting path.

Implementation note: cf-connecting-ip is the rate-limit key. Multiple users behind a single egress IP (corporate NAT, mobile carriers, etc.) share the bucket. Edge cases produce predictable but unwelcome 429s — server-side proxies are the workaround.

Origin allow-list

Browser callers (those that set an Origin header) must originate from:

Server-side callers (curl, Node fetch, Python requests, anything that doesn't set Origin) are not blocked. This is not a security boundary — Origin is spoofable by anything that isn't a browser — just a raised bar for casual scraping from third-party web UIs.

Supported values

The server filters the model's output against a hard allow-list. Entities with value outside the list are dropped before the response leaves the edge.

Chains (14)

ethereum · optimism · base · arbitrum · polygon · avalanche · gnosis · linea · bnb · megaeth · scroll · blast · mantle · zksync

Tokens (~236)

Includes stablecoins, ETH-pegs + LSTs + LRTs, BTC-pegs, native L1/L2 symbols, DeFi blue-chips, AI / DePIN / RWA tokens, memes, and gaming. Full list lives in the source at functions/api/intent.ts under TOKEN_VALUES. New additions land via PR when sustained user demand justifies them.

Examples

curl

curl -sS https://ophis.fi/api/intent \
  -H 'content-type: application/json' \
  -d '{"text":"swap 100 USDC for ETH on Base"}' | jq

fetch (JavaScript / TypeScript)

interface ParsedIntent {
  intent: 'swap' | 'unknown';
  entities: Array<{
    type: 'sellToken' | 'buyToken' | 'amount' | 'chain';
    value: string;
    raw: string;
    start: number;
    end: number;
  }>;
}

type IntentResponse =
  | { ok: true; data: ParsedIntent }
  | { ok: false; error: { code: string; message: string } };

async function parseIntent(text: string): Promise<ParsedIntent> {
  const res = await fetch('https://ophis.fi/api/intent', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ text }),
  });
  const body = (await res.json()) as IntentResponse;
  if (!body.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
  return body.data;
}

// Usage
const parsed = await parseIntent('swap 100 USDC for ETH on Base');
console.log(parsed.entities);

Python

import requests

resp = requests.post(
    'https://ophis.fi/api/intent',
    json={'text': 'swap 100 USDC for ETH on Base'},
    timeout=10,
)
body = resp.json()
if not body['ok']:
    raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
for entity in body['data']['entities']:
    print(entity['type'], '=', entity['value'])

Error: rate limit

HTTP/1.1 429 Too Many Requests
content-type: application/json
retry-after: 42

{"ok":false,"error":{"code":"RATE_LIMITED","message":"too many requests"}}

Edge case: non-swap input

POST /api/intent
{"text":"hello world"}

200 OK
{"ok":true,"data":{"intent":"unknown","entities":[]}}

Non-swap input returns 200 with intent: "unknown" and an empty entities array. Not an error.

From parsed intent to an executable swap

The endpoint returns an intent; it does not place a trade. To turn a parsed intent into an actual on-chain swap you have two paths:

  1. Hand off to Ophis.fi's UI — construct the deep-link https://ophis.fi/#/<chainId>/swap/<sellToken>/<buyToken> and open it in the user's browser. The cowswap form pre-populates and the user signs from their own wallet. Chain ID mapping: chainMap.ts.
  2. Build the order yourself — use the underlying protocol's SDK with the entity values resolved to ERC-20 addresses (token-list of your choice). Sign with the user's wallet, post to api.cow.fi/<chain>/v1/orders. Set appData.metadata.appCode = "ophis" if you want the order to be attributed to Ophis for analytics and partner-fee accounting; the recipient Safe is documented in our public spec.

Stability & versioning

The shape of ParsedIntent, Entity, the error-code enum, and the rate-limit policy are stable for v1. We will not remove fields or rename error codes without first publishing a deprecation note here and at least 30 days of overlap. Additive changes (new entity types, new error codes, additional response fields) are possible without notice — code defensively.

The underlying model is currently qwen3.5-122b-a10b via LibertAI; this is an implementation detail and may rotate. Latency, timeout, and rate-limit guarantees are part of the v1 contract; the specific model is not.

Contact

Found a parsing bug, want a token added, hitting an unhelpful 429? Open an issue at github.com/ophis-fi/ophis/issues.