API Reference

The Fryri API lets your own apps and agents use your library: have full chat turns grounded in it, capture new things, search and read your documents as data, and run deep research. Every key is scoped to a single account.

The stable surface is grouped by job: Query (search, grep, graph, entities), Capture, Answers (chat and research), Your data (documents, facts, export, storage, calendar, conversations), Events out (webhooks), and Account (wallet, usage, keys). The same tools are also callable over an MCP server; beta endpoints live under /v1/evolving.

Quickstart

The minimal agent loop: create a key, fund the wallet, capture something, find it again, ask about it.

  1. In Settings, Data, API keys, create a key. You get a fryri_sk_... key. It's shown once, copy it then.
  2. Add credit. API calls are billed from a prepaid wallet, separate from any app subscription, and an unfunded wallet returns a 402 on every paid call. Top up in Settings, or call POST /v1/wallet/topup-link (amounts 5, 10, 20, 50, or 100 USD) and open the checkout URL it returns. See Billing & wallet.
    curl -X POST https://api.fryri.com/v1/wallet/topup-link \
      -H "Authorization: Bearer $FRYRI_KEY" \
      -H "Content-Type: application/json" \
      -d '{"amount_usd":10}'
    
    # -> { "url": "https://checkout.stripe.com/...", "amount_usd": 10 }
  3. Capture a note into your library. status is the typed receipt (created, deduped, or rejected), and id is the library file id the other verbs take.
    curl -X POST https://api.fryri.com/v1/capture \
      -H "Authorization: Bearer $FRYRI_KEY" \
      -H "Content-Type: application/json" \
      -d '{"text":"Car rego is due 30 September."}'
    
    # -> { "ok": true, "status": "created", "dedup": false,
    #      "detail": "Captured.", "id": 42, "file_id": 42 }
  4. Find it again. Search is meaning-based, so the query does not have to repeat the note's words.
    curl "https://api.fryri.com/v1/search?q=vehicle%20registration&limit=5" \
      -H "Authorization: Bearer $FRYRI_KEY"
    
    # -> { "query": "vehicle registration", "count": 1,
    #      "results": [
    #        { "id": 42, "kind": "file", "snippet": "Car rego is due 30 September.",
    #          "score": 0.91, "from_keyword": false, "content_date": "..." }
    #      ],
    #      "has_more": false,
    #      "next_cursor": null }
  5. Ask about it. reply is the answer; sources lists the library items that grounded it; run_id keys the per-run cost breakdown.
    curl -X POST https://api.fryri.com/v1/chat \
      -H "Authorization: Bearer $FRYRI_KEY" \
      -H "Content-Type: application/json" \
      -d '{"question":"When is my car rego due?"}'
    
    # -> {
    #   "reply": "Your car rego is due on 30 September.",
    #   "artifacts": [],
    #   "sources": [{ "kind": "file", "file_id": 42, "title": "note.txt", "snippet": "..." }],
    #   "tokens_used": 1234,
    #   "run_id": "1f8a..."
    # }

Authentication

Create a key in the developer console. The key is shown once, copy it then. Send it as a Bearer token on every request:

Authorization: Bearer fryri_sk_your_key_here

Base URL: https://api.fryri.com. A missing or revoked key returns 401. Requests are rate limited per key, scaled by your plan: free is 30 requests/min, and an account with a funded wallet gets at least 120/min. There are no rate-limit headers on normal responses; a 429 with a Retry-After header is the signal to back off.

Live keys need prepaid credit. API usage is billed from your credit wallet, not your app subscription. Before your first call, add credit in Settings (any plan can; on free, verify your email first). An unfunded wallet returns 402 insufficient_credits on every call except GET /v1/usage and the /v1/wallet endpoints, which stay reachable at a $0 balance so you can read the balance and fund the wallet. See Billing & wallet.

Safe retries. On the write calls (capture, capture/batch, research, research/entity) you can send an Idempotency-Key header. If a retry comes in with the same key and the same body within 24 hours, you get the original response back instead of a second write. The same key with a different body returns 409 idempotency_key_reused. On beta skill runs the header dedupes at the run level: a replay returns the same run.

Machine-readable spec. The full OpenAPI spec is at https://api.fryri.com/v1/openapi.json (browsable UI at https://api.fryri.com/v1/docs), so you can generate a client or import it into Postman.

SDKs

Single-file clients for Python and TypeScript, covering every endpoint on this page including streaming. Drop one into your project, no install step (pip and npm packages are coming).

fryri.py (needs requests) · fryri.ts (zero dependencies, Node 18+, browsers, Deno, Bun)

# Python
from fryri import Fryri
client = Fryri("fryri_sk_...")
print(client.chat("When is my car rego due?")["reply"])

for event in client.chat("Summarize my week.", stream=True):
    if event["type"] == "delta":
        print(event["content"], end="")
// TypeScript
import { Fryri } from "./fryri";
const client = new Fryri("fryri_sk_...");
const { reply } = await client.chat({ question: "When is my car rego due?" });

for await (const event of client.chatStream({ question: "Summarize my week." })) {
  if (event.type === "delta") process.stdout.write(event.content);
}

Both clients raise a typed error carrying the machine-readable code and the request_id from the error envelope. Prefer generating your own? The OpenAPI spec is at https://api.fryri.com/v1/openapi.json.

Your users

One key, a private library for each user of your app.

End users

Pass end_user_id on a data call and it acts on that end user's private library instead of your own: a body field on POST /v1/chat, POST /v1/capture and batch (once on the batch body, not per item), and the facts writes; a query param on search, grep, graph, entities, facts reads, documents and library export. The id is your own string (1 to 256 printable characters), stored opaque: never parsed or normalized, so "user_42" and " user_42 " are two different users. First use creates the library. The namespace belongs to your account, not the key: the same id used with any of your API keys reaches the same library. Omit the field and every call works on your own library exactly as before. All spend lands on your account, and usage rows carry the end_user_id so you can attribute cost per user: GET /v1/usage?group_by=end_user ranks your users by cost, and ?end_user_id= itemizes one user's spend. Webhook capture events carry it too.

# file something for one of your users
curl -X POST https://api.fryri.com/v1/capture \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text":"Lease renewal due March 1.","end_user_id":"user_42"}'

# search their library (never yours, never another user's)
curl "https://api.fryri.com/v1/search?q=lease+due&end_user_id=user_42" \
  -H "Authorization: Bearer $FRYRI_KEY"

GET /v1/end-users

List the end users under your account, oldest first. There is no create endpoint: an end user exists from the first call that carries its id. limit 1 to 200 (default 50); page with the opaque next_cursor.

curl "https://api.fryri.com/v1/end-users?limit=2" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "count": 2,
#      "end_users": [
#        { "end_user_id": "user_42", "created_at": "2026-07-12T09:14:03Z",
#          "last_used_at": "2026-07-12T10:02:41Z" },
#        { "end_user_id": "user_43", "created_at": "2026-07-12T09:20:11Z",
#          "last_used_at": null } ],
#      "has_more": false, "next_cursor": null }

DELETE /v1/end-users/{end_user_id}

Permanently erases that end user's entire library: documents, stored bytes, facts, conversations, search index, everything. Synchronous and irreversible; afterwards the id returns 404, and a later call with the same id starts a brand-new empty library. This is the call to wire to your own account-deletion flow.

curl -X DELETE "https://api.fryri.com/v1/end-users/user_42" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "deleted": true, "end_user_id": "user_42" }

Query

Find anything in a user's library and get it back ready to use.

GET /v1/grep

Exact-text (regex) search over your documents. A plain word works as a pattern too. Two scopes, one verb; both responses carry a mode field naming which shape you got. Free, never a model call.

One file (with file_id)

Pass file_id (from a capture response, a chat source card, or /v1/documents) to search inside that document: matching lines with line numbers and surrounding context. Params: case_insensitive (default true), context_lines (default 1, max 5), max_matches (default 50, max 250).

curl "https://api.fryri.com/v1/grep?file_id=42&pattern=deductible&context_lines=2" \
  -H "Authorization: Bearer $FRYRI_KEY"

# -> { "mode": "file", "file_id": 42, "filename": "insurance.pdf",
#      "total_lines": 340, "total_matches": 2,
#      "matches": [{ "line": 88, "text": "...deductible is $500...", "before": [...], "after": [...] }] }

The whole library (without file_id)

Omit file_id to search a page of your library in one call, newest files first. Params: max_files (default and max 20 per call), skip to page further, extension to filter to one file type. Only files with matches are listed; scanned_files says how many were checked, so you know how much of the page was covered.

curl "https://api.fryri.com/v1/grep?pattern=auto-renew&max_files=20" \
  -H "Authorization: Bearer $FRYRI_KEY"

# -> { "mode": "library", "pattern": "auto-renew",
#      "scanned_files": 18, "matched_files": 2, "total_matches": 3, "truncated": false,
#      "files": [ { "file_id": 42, "name": "lease.pdf", "total_matches": 2, "matches": [...] },
#                 { "file_id": 17, "name": "gym-contract.pdf", "total_matches": 1, "matches": [...] } ] }

GET /v1/graph

Trace how a person, place, or organisation connects across your documents: the connected entities (nodes), the documents that link them (edges and documents), and a known_person flag for anyone you've saved facts about. Params: name (the entity to start from) and hops (how far to walk, 1–3, default 2). Free, never a model call.

curl "https://api.fryri.com/v1/graph?name=Acme%20Corp&hops=2" \
  -H "Authorization: Bearer $FRYRI_KEY"

# -> { "seed": "Acme Corp", "seed_found": true, "hops_walked": 2,
#      "nodes": [ { "name": "Jane Doe", "kind": "person", "hop": 1, "known_person": true } ],
#      "edges": [ { "a": "acme corp", "b": "jane doe", "via_document_id": 501, "via_document": "contract.pdf" } ],
#      "documents": [ { "id": 501, "label": "contract.pdf", "hop": 1 } ] }

The ids compose: a node name is a ready /v1/search query or /v1/grep pattern, and a documents[].id (or an edge's via_document_id) is a ready /v1/documents/{id} read, so you can walk search → graph → read without translating ids.

GET /v1/entities

The people and organisations that matter most to a user, ranked by salience so the most important come first. Each row carries how often it shows up (doc_count), how many open obligations it has (event_count), whether something is coming up (has_upcoming) and whether it matches a saved fact (is_known); minor one-off names are summarised as a quiet_count rather than listed. One param: limit (default 40, 1 to 200). Free, never a model call.

curl "https://api.fryri.com/v1/entities?limit=10" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "count": 2, "total": 38, "quiet_count": 21,
#      "entities": [
#        { "name": "Acme Corp", "kind": "org", "doc_count": 6, "event_count": 2,
#          "is_known": true, "has_upcoming": true, "salience": 11 },
#        { "name": "Jane Doe", "kind": "person", "doc_count": 4, "event_count": 0,
#          "is_known": true, "has_upcoming": false, "salience": 5 } ] }

Pairs with GET /v1/graph: a row's name is exactly what GET /v1/graph?name= takes, so "who matters most" to "how they connect" is two calls.

Capture

Send text or files and get a typed receipt back. Sending the same thing twice is safe.

POST /v1/capture

Add something to your library. Send plain text (up to 200,000 characters), or content_base64 with a filename and content_type for files up to 30 MB (documents, images). Send exactly one of text or content_base64 per call. Two optional fields: title (up to 400 characters) names the resulting document, useful for a text capture where there's no filename, and source_ref (up to 2000 characters) is your own opaque pointer back to the original (a URL, an object key, a database id). Fryri stores source_ref verbatim, never parses or fetches it, echoes it back on the response, and returns it on GET /v1/documents. Drives the same pipeline as a dashboard upload. Honors Idempotency-Key.

# Text note
curl -X POST https://api.fryri.com/v1/capture \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text":"Remember: garage code is 4417."}'

# A file
curl -X POST https://api.fryri.com/v1/capture \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content_base64":"<base64>","filename":"lease.pdf","content_type":"application/pdf"}'

# -> { "ok": true, "status": "created", "dedup": false,
#      "detail": "Captured.", "id": 42, "file_id": 42 }

Three ways to add a document

You pick one per call by what you send. There's no mode setting; the choice is implicit in the body.

  1. Reach-in. Configure your own bucket on the key (see Bring your own storage), then POST content_base64. Fryri writes the bytes into your bucket and reads them back to extract. The files live in your bucket; Fryri holds the credentials.
  2. Bytes push. No bucket configured. POST content_base64 with a filename and content_type. The file lands on Fryri storage and Fryri handles the rest. Simplest, but the file sits on Fryri.
  3. Text push. You extract the document yourself (your own OCR or parsing) and POST text only. The raw file bytes never reach Fryri. You keep them, and send only the text plus, optionally, a title to name the document and a source_ref pointing back to your file. This is the maximum-custody option.
# Text push: you did the extraction, only the text reaches Fryri
curl -X POST https://api.fryri.com/v1/capture \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text":"...your own extracted text...","title":"Acme MSA 2026","source_ref":"s3://your-bucket/acme-msa.pdf"}'

# -> { "ok": true, "status": "created", "id": 123, "file_id": 123,
#      "source_ref": "s3://your-bucket/acme-msa.pdf" }

A text push with title "Acme MSA 2026" names the document Acme MSA 2026.txt; with no title it gets an auto name. The source_ref you sent comes back on the response and on GET /v1/documents, so you can map a search hit back to your original file. It's null on reach-in and app-uploaded documents.

status is a stable enum to switch on: created (a new item), deduped (identical content was already there, no new write), or rejected. dedup is the same signal as a boolean. id (aliased as file_id) is the created or existing library file's id, ready to chain into /v1/documents and /v1/grep. It's null for a photo capture (photos aren't line-readable) and on a rejection.

On a rejection, two optional fields say why and whether to try again: reject_code is the machine cause when known (for example "storage_unreachable"), and retryable says whether the same request can succeed later unchanged. Both are null on success.

POST /v1/capture/batch

Capture many things in one request, up to 100 items. Each item is the same shape as a single capture body. The whole batch costs one rate-limit token, not one per item, so a folder is a single call. One item failing does not abort the rest; you get a per-item result back, keyed by its position. Honors Idempotency-Key.

curl -X POST https://api.fryri.com/v1/capture/batch \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"items":[
        {"text":"Garage code is 4417."},
        {"content_base64":"<base64>","filename":"lease.pdf","content_type":"application/pdf"}
      ]}'

# -> { "total": 2, "succeeded": 2,
#      "results": [ { "index": 0, "ok": true, "status": "created", "dedup": false, "detail": "Captured.", "id": 41, "file_id": 41 },
#                   { "index": 1, "ok": true, "status": "created", "dedup": false, "detail": "Captured.", "id": 42, "file_id": 42 } ] }

Each per-item result carries the same fields as a single capture response, including reject_code and retryable on a rejected item (null on success).

Answers

Chat and research answers grounded in the library, with citations.

POST /v1/chat

One call, a full agentic chat turn: the model answers grounded in your library, runs tools when it needs them, and returns the reply plus any artifacts (charts, tables, runnable apps) as JSON, so your app can render or run them itself.

curl -X POST https://api.fryri.com/v1/chat \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question":"Chart my sales: Jan 10, Feb 20, Mar 15."}'

# -> { "reply": "...", "artifacts": [{ "type": "...", "title": "...", "source": "..." }],
#      "sources": [...], "tokens_used": 1234, "run_id": "1f8a..." }

Body fields: question (required, up to 4000 characters), model_choice (free | standard | pro | premium, plan permitting), web_search (auto | on | off, default auto. auto lets the agent search the web when the question needs it, on forces one search, off withholds the search tool for the whole run so no web call or search fee can occur. Booleans still work: true is on, false is auto), include_domains + exclude_domains (bare hosts like "reddit.com", up to 20 each. They pin any web search in the run to, or away from, those sites, overriding anything the question text implies), store (none | library | all, default library), stream (boolean), include_trace (boolean), and the provider + model + api_key + max_output_tokens fields for bring-your-own-key routing (paid plans). The response may also carry facts_saved, search, trace, and wallet_warning when those apply.

Sources

sources is one uniform list of grounding cards, each tagged with a kind. Items from your own library come first: files are kind: "file" and carry a file_id you can pass straight to GET /v1/documents/{file_id} and GET /v1/grep; photos and videos (kind: "photo" / "video") carry a source_id instead. Web results are kind: "web" with a url.

"sources": [
  { "kind": "file", "file_id": 42, "title": "lease.pdf", "snippet": "...the deposit is..." },
  { "kind": "photo", "source_id": 7, "title": "whiteboard photo", "snippet": "..." },
  { "kind": "web", "url": "https://...", "title": "..." }
]

Streaming

Add "stream": true to receive the same turn as server-sent events, so your app can show the reply as it's being written and the agent's tool activity while it works. Everything else (billing, limits, store) works exactly like a non-streaming call.

curl -N -X POST https://api.fryri.com/v1/chat \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question":"Summarize my week.","stream":true}'

data: {"type":"tool_call","phase":"start","tool":"search_library","label":"Searching your library"}
data: {"type":"tool_call","phase":"done","tool":"search_library","label":"Searching your library","ok":true,"count":3}
data: {"type":"delta","content":"Your week"}
data: {"type":"delta","content":" was mostly..."}
data: {"type":"done","reply":"...","artifacts":[],"sources":[...],"tokens_used":1234,"run_id":"1f8a..."}

Each event is a data: line carrying one JSON object with a type field. The full grammar:

{"type":"delta","content":"..."}   answer text as the model generates it
{"type":"reset"}                   discard the text so far (the model restarted)
{"type":"tool_call","phase":"start"|"done","tool":"...","label":"...","ok":bool,"count":int|null}
                                   a tool the agent ran mid-turn: name, friendly label,
                                   and outcome. Never tool arguments or results.
{"type":"error","error":{"code":"...","message":"..."}}
                                   terminal failure; the stream ends here
{"type":"done", ...}               terminal success; carries the exact same fields as a
                                   non-streaming response body

The done event's reply is the authoritative text: swap your concatenated deltas for it when the turn finishes, since it has artifact blocks removed and can differ from the deltas if a safety check rewrote the answer. Exactly one terminal event arrives: done on success, error on failure. Lines starting with a colon are keep-alive comments, ignore them. Streaming works on /v1/chat only.

What a run leaves behind: store

store controls what a call persists. library (default): the run is not saved as a conversation and does not appear in the chat list, but anything the assistant deliberately saves (a fact you asked it to remember, a reminder) still lands in your library. none: nothing is saved. all: the full conversation is saved in the app, identical to a turn typed in the dashboard. No mode modifies a conversation already open in the app, and billing is identical across all three.

Per-run observability: include_trace

include_trace adds a trace object to the response (on streaming calls it rides the final done event) so you can see what grounded the answer: which of the user's library items were used (ids, titles), which tools ran (names and result counts only, never their contents), whether a fallback fired, end-to-end latency, the token split, and cost_usd, the call's full price (the ask charge plus any web tool fees), the same dollars GET /v1/usage reports. model and provider are filled in on a bring-your-own-key run, where you picked them, and are null on our models: the tier you buy is the contract, and which model serves it changes as better ones ship.

"trace": {
  "retrieved": [{"kind":"file_card","id":42,"score":0.83,"title":"lease.pdf"}],
  "tools": [{"name":"grep_file","ok":true,"result_count":3}],
  "model":null, "provider":null, "fallback_fired":false,
  "latency_ms":4210, "tokens":{"prompt":18000,"completion":400,"total":18400},
  "cost_usd":0.02
}

POST /v1/research

Start a deep research run that builds a comparison table. Premium plan or prepaid credit. It runs in the background: the call returns a job id, and you poll for the result. Send an Idempotency-Key; a retried create returns the original run instead of billing a second one.

# Start
curl -X POST https://api.fryri.com/v1/research \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"instructions":"Compare the top 3 EVs under 50k","columns":[{"key":"model"},{"key":"range"},{"key":"price"}]}'

# -> 202 { "job_id": 42, "status": "queued", "poll_url": "/v1/research/42" }

# Poll: GET /v1/research/{job_id}
curl "https://api.fryri.com/v1/research/42" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "job_id": 42, "status": "succeeded", "error_kind": null, "sources_found": 6,
#      "table": { "columns": [...], "rows": [...], "sources": [...] } }

status walks queued, running, then succeeded or failed; table is null until succeeded. Each column is {"key": "...", "name": "..."} (name optional). Subscribe to the research.completed webhook to skip polling.

POST /v1/research/entity

Structured company and entity research: list building, row enrichment, KYB profiles, multi-step research with citations. An agent searches, reads, and reasons until it can return a natural-language answer plus JSON matching your output_schema (a JSON Schema, up to 50KB). Paid plan or prepaid credit. Async: you get a job id back and poll for the result. Runs typically take minutes; large list-building runs can take up to an hour.

# Start
curl -X POST https://api.fryri.com/v1/research/entity \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "Business profile for PepsiCo: legal name, HQ, revenue, key brands",
    "effort": "medium",
    "output_schema": {
      "type": "object",
      "required": ["legal_name", "hq", "revenue", "brands"],
      "properties": {
        "legal_name": {"type": "string"},
        "hq": {"type": "string"},
        "revenue": {"type": "string"},
        "brands": {"type": "array", "maxItems": 10, "items": {"type": "string"}}
      }
    }
  }'

# -> 202 { "job_id": 7, "status": "running", "effort": "medium",
#          "poll_url": "/v1/research/entity/7" }

# Poll: GET /v1/research/entity/{job_id}
curl "https://api.fryri.com/v1/research/entity/7" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "status": "completed",
#      "output": { "text": "...", "structured": { "legal_name": "PepsiCo, Inc.", ... },
#                  "grounding": [ { "field": "structured.revenue", "citations": [...] } ] },
#      "cost_usd": 0.125, ... }

effort sets the per-run price: minimal $0.015, low $0.03125, medium $0.125 (the default), high $0.625, xhigh $1.25 per request. auto scales cost to the task, best for list building where the entity count varies. Contact enrichment in your schema bills on top ($0.025 per email, $0.0875 per phone number), so bound those arrays with maxItems to cap the cost. The run's real total is in the poll response's cost_usd and in /v1/usage.

data_sources plugs premium data providers into the run alongside web search, per call: fiber B2B companies and people ($0.025), similarweb web traffic and rankings ($0.0375), baselayer US business verification and KYB ($0.0275), affiliate product catalogs ($0.01875), particle podcast transcripts ($0.01875), financial_datasets US stock-ticker news ($0.0125), jinko travel fares ($0.00625). Up to 5 per run.

# Enrich rows with a data provider
curl -X POST https://api.fryri.com/v1/research/entity \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "For each company, find current monthly web traffic and one competitor.",
    "effort": "auto",
    "data_sources": ["similarweb"],
    "input_data": [{"company": "Notion", "domain": "notion.so"},
                   {"company": "Linear", "domain": "linear.app"}]
  }'

Also accepts input_exclusion (records or entities to leave out of the answer) and system_prompt (source preferences, novelty constraints). Send an Idempotency-Key header; a retried create returns the original run instead of billing a second one. When the run finishes, the entity_research.completed webhook fires if you subscribe to it.

Your data

Read everything, export everything, and keep custody of the raw bytes if you want.

Documents

Enumerate and read your captured documents as data, whichever door they came in through (API, dashboard, email, voice). All three reads are free and owner-scoped: a file you don't own returns 404, identical to a file that doesn't exist.

GET /v1/documents

List your documents, newest first. Filters: extension (e.g. pdf), since (ISO-8601 timestamp), paged with skip / limit (default 25, max 100).

curl "https://api.fryri.com/v1/documents?extension=pdf&limit=25" \
  -H "Authorization: Bearer $FRYRI_KEY"

# -> { "count": 2, "documents": [
#      { "file_id": 42, "name": "lease.pdf", "media_type": "application/pdf", "size": 182044,
#        "extension": "pdf", "uploaded_at": "...", "preview_snippet": "..." }, ... ] }

GET /v1/documents/{file_id}

Read one document, in the view you need. view=structured (the default) returns the document already organised for you: document type, one-line summary, the key date, structured fields, and the entities in it.

# GET /v1/documents/{file_id}
curl "https://api.fryri.com/v1/documents/42?view=structured" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "view": "structured", "file_id": 42, "document_type": "lease",
#      "summary_one_line": "...", "primary_date": "2026-09-30",
#      "structured": { ... }, "entities": [{ "kind": "org", "name": "...", "confidence": 0.9 }] }

view=text returns the document's extracted text, line-windowed with offset (first line, 1-indexed) and limit (default 200 lines, max 2000). Raw text by default; pass line_numbers=true to prefix each line with its number, matching the line references /v1/grep reports. Grep first, then read the surrounding region.

curl "https://api.fryri.com/v1/documents/42?view=text&offset=80&limit=20" \
  -H "Authorization: Bearer $FRYRI_KEY"

# -> { "view": "text", "file_id": 42, "filename": "insurance.pdf",
#      "content": "...", "start_line": 80, "num_lines": 20, "total_lines": 340 }

GET /v1/documents/{file_id}/download

Get a short-lived link to the original uploaded bytes (not the extracted text). The URL is presigned and expires after 300 seconds; open or fetch it directly.

# GET /v1/documents/{file_id}/download
curl "https://api.fryri.com/v1/documents/42/download" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "file_id": 42, "url": "https://...", "filename": "lease.pdf", "expires_in": 300 }

Facts

Facts are the short structured things Fryri remembers about you, the same ones injected into every chat turn: lives_in: Auckland, works_at: Acme. These four calls read and write that layer directly, reachable without a /v1/chat turn. Fact writes are not gated by store mode; they always persist, the same as /v1/capture.

GET /v1/facts

List your facts, newest first. Paginated with skip/limit (default 200, max 1000); pass include_superseded=true to also get older values a fact has had, not just the current one. On superseded rows, "never_true": true marks a fact the user retracted as never having been true (a corrected mistake), as opposed to a value that changed.

curl "https://api.fryri.com/v1/facts?limit=50" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "facts": [{ "id": 1, "predicate": "lives_in", "value": "Auckland",
#                  "category": "location", "subject": null, "label": null,
#                  "valid_from": null, "confidence": 0.95, "source_quote": "...",
#                  "never_true": false, "created_at": "..." }],
#      "total": 1, "has_more": false }

POST /v1/facts

State a fact directly. predicate, value, and category are required; subject is optional and defaults to you, set it to a name for a fact about someone else; label and valid_from are optional too. Sending the same value again does nothing; sending a new value for the same predicate replaces what was there, so you never get duplicates.

curl -X POST https://api.fryri.com/v1/facts \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"predicate":"lives_in","value":"Auckland","category":"location"}'

# -> { "id": 1, "predicate": "lives_in", "value": "Auckland", "category": "location",
#      "subject": null, "confidence": 1.0, "created_at": "..." }

PATCH /v1/facts/{id}

Edit a fact in place. Send only the fields you want to change.

# PATCH /v1/facts/{fact_id}
curl -X PATCH https://api.fryri.com/v1/facts/1 \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value":"Hamilton"}'

# -> { "id": 1, "predicate": "lives_in", "value": "Hamilton", ... }

DELETE /v1/facts/{id}

Forget a fact for good.

# DELETE /v1/facts/{fact_id}
curl -X DELETE https://api.fryri.com/v1/facts/1 -H "Authorization: Bearer $FRYRI_KEY"

# -> { "deleted": true, "fact_id": 1 }

GET /v1/library/export

Your whole structured library in one JSON response: facts, events, portrait, documents with their fields and entities, media and file metadata, shared collections, schemas, companion, and skills. Null or empty sections are omitted from the response. Pass end_user_id to export one of your end users' libraries instead; its account block then carries that end_user_id, not Fryri-internal identifiers. Free, never a model call.

curl "https://api.fryri.com/v1/library/export" -H "Authorization: Bearer $FRYRI_KEY"

# -> {
#   "format": "fryri_export_v2",
#   "exported_at": "...",
#   "account": { ... },
#   "memory": { "facts": [...], "events": [...], "portrait": { ... } },
#   "documents": [ ... ],
#   "media": [ ... ],
#   "files": [ ... ],
#   "shared_collections": [ ... ],
#   "schemas": [ ... ],
#   "companion": { ... },
#   "skills": [ ... ]
# }

Raw bytes and chat transcripts are not inline; the export carries pointers instead. Read a thread with GET /v1/conversations/{id}/transcript and download a file's original bytes with GET /v1/documents/{id}/download.

Bring your own storage

Keep the raw files in your own bucket. Point this key at an S3-compatible bucket (AWS S3 or Cloudflare R2) and the original bytes of every document and image you capture with it are written to your bucket instead of ours. Everything else works exactly the same, so /v1/search, /v1/grep, and /v1/documents are unchanged; only the raw-byte custody moves to you. Config is per key, and the bucket is validated with a test write before it's saved, so a bad credential fails here, not at capture time. Paid plans only.

# Point this key at your own bucket (validated with a test write before saving)
curl -X POST https://api.fryri.com/v1/storage \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider":"s3", "bucket":"my-docs", "region":"us-east-1",
       "access_key_id":"AKIA...", "secret_access_key":"..."}'

# For Cloudflare R2, set provider "r2" and the endpoint:
#   "provider":"r2", "endpoint_url":"https://<account>.r2.cloudflarestorage.com"

# -> { "enabled": true, "has_bucket": true, "provider": "s3", "bucket": "my-docs", ... }

# Check status
curl https://api.fryri.com/v1/storage -H "Authorization: Bearer $FRYRI_KEY"

# Disconnect (new captures go to Fryri storage; files already in your bucket
# keep resolving to it)
curl -X DELETE https://api.fryri.com/v1/storage -H "Authorization: Bearer $FRYRI_KEY"

Custody

While your bucket is connected, three rules hold:

  1. If bring-your-own storage is ever paused platform-wide while your bucket is connected, captures are rejected (reject_code: "storage_disabled", retryable: true). Your bytes never silently land on Fryri-managed storage.
  2. A capture bound for your bucket always lands in your bucket, even if the same file already exists on Fryri-managed storage.
  3. If your bucket can't take a capture (unreachable, bad credentials), the capture is rejected with reject_code: "storage_unreachable" and retryable: true, and the detail names the cause. Nothing is stored elsewhere.

Calendar

Your upcoming obligations as data: the same events the app's calendar shows, chat-extracted and document-promoted, dated and undated, plus the verbs to manage them. One read and five writes, all owner-scoped (an event that isn't yours 404s) and all free, never a model call.

GET  /v1/calendar                             your open obligations, dated and undated
POST /v1/calendar/events                      create one event yourself
POST /v1/calendar/events/{event_id}/resolve   mark it done (undo reopens)
POST /v1/calendar/events/{event_id}/snooze    push it forward
POST /v1/calendar/events/{event_id}/mute      hide it from the timeline and chat
POST /v1/calendar/events/{event_id}/remind    a push reminder days before it

GET /v1/calendar

Read the timeline. Params: include_resolved (default false), days_ahead (default 120, 1 to 730), days_behind (default 7, 0 to 365), limit (default 150, 1 to 500). Each event carries a source saying where the obligation came from: a document (file_id chains into /v1/documents) or a chat (conversation_id chains into /v1/conversations).

curl "https://api.fryri.com/v1/calendar?days_ahead=60" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "count": 2, "events": [
#      { "id": 311, "title": "Car insurance renewal", "description": null,
#        "category": "...", "status": "open",
#        "scheduled_for": "2026-09-30T00:00:00", "scheduled_for_fuzzy": null,
#        "duration_minutes": null, "importance": 0.8, "outcome": null,
#        "source": { "kind": "document", "document_record_id": 88, "file_id": 42,
#                    "document_type": "...", "message_id": null, "conversation_id": null } },
#      { "id": 312, "title": "Call the landlord", ..., "scheduled_for": null,
#        "scheduled_for_fuzzy": "next week",
#        "source": { "kind": "chat", "message_id": 501, "conversation_id": 12, ... } } ] }

POST /v1/calendar/events

Create one event yourself. Body: title (required, up to 200 characters), date ("YYYY-MM-DD", user-local), time ("HH:MM" 24h; omit for all-day), tz (IANA name), description (up to 1000 characters), and duration_minutes (1 to 1440). The response's scheduled_for is an ISO timestamp in UTC (ends in Z).

curl -X POST https://api.fryri.com/v1/calendar/events \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Dentist","date":"2026-08-14","time":"09:30",
       "tz":"Pacific/Auckland","duration_minutes":45}'

# -> { "id": 312, "title": "Dentist", "description": null,
#      "scheduled_for": "2026-08-13T21:30:00Z", "duration_minutes": 45, "status": "open" }

POST /v1/calendar/events/{event_id}/resolve

Mark an event done. Body (both optional): outcome, a short note on how it went, and undo, which reopens a resolved event when true.

curl -X POST https://api.fryri.com/v1/calendar/events/312/resolve \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"outcome":"paid in full"}'

# -> { "event_id": 312, "status": "resolved", "outcome": "paid in full", "resolved_at": "..." }

# Reopen it:
#   -d '{"undo":true}'   ->  { "event_id": 312, "status": "open", ... }

POST /v1/calendar/events/{event_id}/snooze

Push an event forward. Body: either new_scheduled_for (an explicit ISO datetime) or delta, one of "1d", "3d", "1w", "2w", "1m", measured from now.

curl -X POST https://api.fryri.com/v1/calendar/events/312/snooze \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"delta":"1w"}'

# -> { "event_id": 312, "scheduled_for": "...", "status": "open" }

POST /v1/calendar/events/{event_id}/mute

Hide an event from the timeline and from chat awareness. The row stays; muted: false unmutes it. Body: muted (boolean, default true).

curl -X POST https://api.fryri.com/v1/calendar/events/312/mute \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"muted":true}'

# -> { "event_id": 312, "muted": true }

POST /v1/calendar/events/{event_id}/remind

Set a push reminder for a dated event. Body: days_before (0 to 365, default 0; 0 fires day-of). Idempotent per event and lead time: asking twice returns the existing reminder with already_set: true, never a duplicate.

curl -X POST https://api.fryri.com/v1/calendar/events/312/remind \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"days_before":3}'

# -> { "reminder_id": 9, "fire_at": "...", "message": "...",
#      "days_before": 3, "already_set": false }

Conversations

List your chat conversations and read any thread's full transcript, the same rows the app shows in the chat sidebar. Both reads are free and owner-scoped: a conversation that isn't yours returns 404.

GET /v1/conversations

List your conversations, newest first. Params: skip (0 to 100000) and limit (default 25, 1 to 100). Each row's id is what the transcript verb takes. Null fields (an unset title) are omitted rather than sent as null.

curl "https://api.fryri.com/v1/conversations?limit=25" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "count": 2, "conversations": [
#      { "id": 12, "title": "Lease questions", "created_at": "...", "updated_at": "...",
#        "is_active": true, "message_count": 14, "is_compacted": false }, ... ] }

GET /v1/conversations/{conversation_id}/transcript

Read one thread's messages. Params: limit (default 200, max 200) and before (a message id; returns the window before it, so you page backward through long threads). Each message carries role, content, created_at, and, when present, reasoning (the Deep Think trace), parent_message_id, and is_interjection. Null fields are omitted.

# GET /v1/conversations/{conversation_id}/transcript
curl "https://api.fryri.com/v1/conversations/12/transcript?limit=200" \
  -H "Authorization: Bearer $FRYRI_KEY"

# -> { "conversation_id": 12, "count": 14, "messages": [
#      { "id": 501, "role": "user", "content": "...", "created_at": "...",
#        "is_interjection": false },
#      { "id": 502, "role": "assistant", "content": "...", "created_at": "...",
#        "reasoning": "...", "parent_message_id": 501, "is_interjection": false } ] }

# Older messages: page backward from the earliest id you have.
curl "https://api.fryri.com/v1/conversations/12/transcript?before=501" \
  -H "Authorization: Bearer $FRYRI_KEY"

Events out

Fryri calls you when something happens.

Webhooks

The push side of the API: instead of polling, register a URL and Fryri POSTs to it when something happens on your account, research finishing, a document finishing extraction, or a radar brief being delivered. Account-wide: a webhook fires for these events regardless of whether they were triggered through the API or the dashboard. Up to 25 subscriptions per account.

POST /v1/webhooks

Register a URL. events is which types you want; omit it or pass ["*"] for everything. The signing secret is generated for you and returned once in this response; store it, it is not shown again.

curl -X POST https://api.fryri.com/v1/webhooks \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://yourapp.com/fryri-webhook","events":["research.completed","capture.completed"]}'

# -> { "id": 1, "url": "https://yourapp.com/fryri-webhook",
#      "events": ["research.completed", "capture.completed"], "enabled": true,
#      "secret": "whsec_...", "created_at": "..." }

The full set of event types (anything else is rejected with a 422): research.completed, research.failed, entity_research.completed, entity_research.failed, capture.completed, capture.failed, radar.brief.

Verifying a delivery

Every POST carries a Fryri-Signature header shaped t=<unix time>,v1=<hex>. Recompute the signature yourself as HMAC-SHA256 of "{t}.{raw body}" using your secret and compare against each v1 in the header (accept if any matches), the same convention Stripe's outbound webhooks use. The header normally carries one v1; while a signing-secret rotation is in progress it carries two, so checking every v1 keeps working through a rotation.

# Python
import hmac, hashlib
expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
sigs = [p.split("=", 1)[1] for p in header.split(",") if p.startswith("v1=")]
assert any(hmac.compare_digest(expected, s) for s in sigs)

What a delivery looks like

POST https://yourapp.com/fryri-webhook
Fryri-Event: research.completed
Fryri-Signature: t=1735689600,v1=5f2b...

{
  "id": "evt_123",
  "type": "research.completed",
  "created_at": "2026-07-02T12:00:00",
  "data": { "job_id": 42, "status": "succeeded", "sources_found": 6, "cost_usd": 0.08 }
}

Return any 2xx to acknowledge. Anything else is treated as a failure and retried with increasing delays, up to five attempts in total (about ninety minutes end to end). A subscription that keeps failing is disabled automatically, list it again to check enabled.

GET /v1/webhooks

List your subscriptions. The secret is never returned again here.

curl "https://api.fryri.com/v1/webhooks" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "webhooks": [{ "id": 1, "url": "...", "events": [...], "enabled": true, "created_at": "..." }] }

POST /v1/webhooks/{id}/test

Send one real signed test delivery right now, so you can check your endpoint and signature verification before waiting on a real event.

# POST /v1/webhooks/{webhook_id}/test
curl -X POST https://api.fryri.com/v1/webhooks/1/test -H "Authorization: Bearer $FRYRI_KEY"

# -> { "ok": true, "response_status": 200, "detail": "Delivered." }

DELETE /v1/webhooks/{id}

# DELETE /v1/webhooks/{webhook_id}
curl -X DELETE https://api.fryri.com/v1/webhooks/1 -H "Authorization: Bearer $FRYRI_KEY"

# -> { "deleted": true, "webhook_id": 1 }

Account

Know and control what you spend.

Billing & wallet

Live keys run on prepaid credit. Fund your wallet and every paid call (chat, search, research, entity research) bills it as you go. With an empty wallet, calls return 402 with error.code: "insufficient_credits"; only GET /v1/usage and the /v1/wallet endpoints stay reachable at a $0 balance, so you can read the balance and fund the wallet while unfunded. An app subscription's monthly allowance does not cover API usage; the wallet is the sole funding source for a live key. Any plan can top up, the free plan included (verify your email first). Verifying your email also starts your wallet with $1 of credit, enough for 50 chat calls, hundreds of memory searches, or thousands of captures before you top up.

What a call costs

  • POST /v1/chat: $0.02 per ask ($20 per 1,000; shows up in GET /v1/usage as api_ask). One ask covers a run of up to 32k input + 4k output tokens; a longer run counts as one more ask per additional block, automatically. Retrieval, memory extraction, and code execution are included in the price. With your own provider key the tokens bill to your provider account and Fryri charges $0.0025 per call instead (api_orchestration).
  • Web tools in a chat turn bill on top: web search $0.01 per search plus $0.00125 per page of content read ($10 per 1,000 searches + $1.25 per 1,000 pages; a search that grounds on 5 pages costs $0.01625); page render $0.005 per screenshot ($5.00 per 1,000).
  • GET /v1/search: $0.0025 per call, everything included.
  • Capture: fractions of a cent per capture for text. Scanned documents add $0.0025 per page ($2.50 per 1,000 pages), plus a per-token charge to read them.
  • Research: billed per run (entity research prices by effort tier, below).
  • Every other read is free: grep, graph, entities, facts, documents, export, conversations, calendar, end-users, usage, and wallet.

Every price here is what your wallet pays: the dollars in GET /v1/usage are the same dollars your balance drains. Every charge lands itemized, by call type and by end user, so after your first calls your own numbers price your exact workload.

GET /v1/wallet

Returns your funding state. over_budget: true means the next paid call will be refused (the 402 above). The topup_endpoint field names the endpoint to fund with.

curl "https://api.fryri.com/v1/wallet" -H "Authorization: Bearer $FRYRI_KEY"

# -> {
#   "tier": "pro",
#   "wallet_balance_usd": 0.0,
#   "wallet_funded": false,
#   "over_budget": true,                  // a paid call would 402 now
#   "topup_endpoint": "POST /v1/wallet/topup-link"
# }

You also get an early warning before the wall: once your balance drops to $1 or less, every /v1/chat response carries wallet_warning: {"balance_usd": 0.42, "topup_endpoint": "POST /v1/wallet/topup-link"}, so an agent can top up mid-task instead of hitting the 402.

POST /v1/wallet/topup-token

Fund the wallet without a browser. Your owner grants Fryri's Stripe profile a Stripe Shared Payment Token (an spt_… id), scoped to a max amount and an expiry on Stripe's side. You POST the token and the amount; Fryri charges it and credits your wallet in the same call. No browser or checkout page is involved, and Fryri does not receive card details. The charge is idempotent: a retried identical call funds once. Allowed amounts: 5, 10, 20, 50, 100.

curl -X POST https://api.fryri.com/v1/wallet/topup-token \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"spt":"spt_1ABCxyz...","amount_usd":10}'

# -> {
#   "ok": true,
#   "amount_usd": 10,
#   "wallet_balance_usd": 10.0,          // over_budget now cleared
#   "payment_id": "pi_3ABC..."
# }

# A bad or expired token is refused before any charge:
# -> 400 { "error": { "code": "spt_not_found", "message": "That payment token isn't valid." },
#          "request_id": "req_..." }

To mint a token, the account owner uses Stripe's agent tooling (link.com/agents) to grant a scoped token to Fryri's seller profile, then hands it to the agent. The scope (max amount, expiry) is set and enforced by Stripe; a charge over it is rejected on Stripe's side.

POST /v1/wallet/topup-link

When a person is available to pay, this returns a Stripe-hosted checkout URL. Open it in a browser, complete the card form on Stripe's page, and the wallet credits when the payment clears. Use this when no Shared Payment Token is available. Works on any plan (on the free plan, verify your email first). A free account's first top-up asks for a quick bank confirmation (3D Secure) on Stripe's page; top-ups are also rate limited per account and per IP (429 topup_rate_limited / topup_daily_limit).

curl -X POST https://api.fryri.com/v1/wallet/topup-link \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount_usd":20}'

# -> { "url": "https://checkout.stripe.com/...", "amount_usd": 20 }

GET /v1/usage

See what a key has spent, and what your whole account has spent, broken down by what you were charged for: each built-in tool by name, the per-call platform fee, and your model usage as one models line. The dollar figure is the all-in cost, the same number your wallet draws on. Read-only. Pass days to set the window (1 to 365, default 30).

curl "https://api.fryri.com/v1/usage?days=30" -H "Authorization: Bearer $FRYRI_KEY"

# -> {
#   "key":     { "scope": "key", "total_cost_usd": 0.42, "total_requests": 18,
#                "by_request_type": [ { "request_type": "models", "cost_usd": 0.40, "requests": 12 },
#                                     { "request_type": "tool:web_search", "cost_usd": 0.02, "requests": 2 }, ... ] },
#   "account": { "scope": "account", "total_cost_usd": 3.10, "total_requests": 211, "by_request_type": [ ... ] }
# }

Per user of your app: add group_by=end_user and each scope gains a by_end_user list, your end users ranked by cost with tokens and request count each (top 100; end_user_count is the full count). Pass end_user_id= instead to itemize one end user's spend. Both only read the ledger: an id that never made a call reads as zero spend and is not created.

# which of my users cost the most this month
curl "https://api.fryri.com/v1/usage?group_by=end_user" -H "Authorization: Bearer $FRYRI_KEY"

# -> "account": { ..., "end_user_count": 2,
#      "by_end_user": [
#        { "end_user_id": "user_42", "tokens": 51200, "cost_usd": 0.31, "requests": 12 },
#        { "end_user_id": "user_7",  "tokens": 9400,  "cost_usd": 0.04, "requests": 3 } ] }

# one user's bill, itemized by kind of call
curl "https://api.fryri.com/v1/usage?end_user_id=user_42" -H "Authorization: Bearer $FRYRI_KEY"

You can also cap a key's monthly spend in the developer console. Once a key passes its cap, its paid calls return 429 with error.code: "key_budget_reached" until you raise or clear the cap.

GET /v1/runs/{run_id}/usage

What one run cost, itemized. Every /v1/chat response includes a run_id (also sent as the X-Request-ID response header on every call); pass it here to get that run's token split and cost breakdown. Useful for per-customer billing on top of the API. Read-only; does not bill.

# GET /v1/runs/{run_id}/usage
curl "https://api.fryri.com/v1/runs/1f8a.../usage" -H "Authorization: Bearer $FRYRI_KEY"

# -> {
#   "run_id": "1f8a...", "requests": 4,
#   "input_tokens": 1200, "output_tokens": 400, "cache_read_tokens": 800, "total_tokens": 1600,
#   "model_cost_usd": 0.0, "tool_fees_usd": 0.01625, "platform_fee_usd": 0.02,
#   "total_cost_usd": 0.03625,
#   "started_at": "...", "ended_at": "...",
#   "items": [ { "request_type": "models", "model": null, "input_tokens": 1000, ... },
#              { "request_type": "tool:web_search", "tool_fee_usd": 0.01625, ... }, ... ]
# }

platform_fee_usd is the run's ask charge (or the per-call fee on a bring-your-own-key run); tool_fees_usd is its web tool spend. model_cost_usd is 0 on a normal run (tokens are covered by the ask price) and carries the metered token cost where tokens do bill. cache_read_tokens is the part of the input the provider served from cache. total_cost_usd is the sum of the three, the same all-in figure GET /v1/usage adds up. You can set the id yourself by sending an X-Request-ID header on the run; calls that reuse an id merge into it. For a beta skill run, capture the X-Request-ID header of the POST /v1/evolving/skills/{id}/run call that started it. Rows land as the run progresses; a streaming run's last rows settle within seconds of the done event.

Bring your own key

Run a turn on your own provider account. Send provider plus model (the raw model name on that provider) on /v1/chat, and the model tokens bill to you while Fryri charges $0.0025 per call plus any web tool fees. Supported providers: openrouter, anthropic, openai, xai, google, deepseek, zai (GLM), xiaomi (MiMo), minimax. Paid plans only. The key can travel with the request (api_key, used for that call only) or be saved once per provider below. A bad key or wrong model name comes back as a clear error with byok_key_rejected or byok_request_rejected, and the turn does not fall back to Fryri's keys.

# One call on your own Anthropic account (key travels with the request)
curl -X POST https://api.fryri.com/v1/chat \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question":"What deadlines am I facing this week?",
       "provider":"anthropic", "model":"claude-sonnet-5",
       "api_key":"sk-ant-..."}'

# Optional: cap the answer length (default 8192 output tokens)
#   "max_output_tokens": 16384

Or save a key once per provider and omit api_key on every call. Keys are validated with the provider before storing and encrypted at rest; the secret is never returned, only a masked prefix. A saved openrouter key also moves your normal plan-model turns onto your own OpenRouter bill.

# Check status (?provider= defaults to openrouter)
curl "https://api.fryri.com/v1/byo-key?provider=anthropic" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "enabled": true, "has_key": false, "key_prefix": null }

# Save a key for a provider (validated before it is stored, encrypted at rest)
curl -X POST https://api.fryri.com/v1/byo-key \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider":"anthropic", "key":"sk-ant-..."}'

# -> { "enabled": true, "has_key": true, "key_prefix": "sk-ant-...abcd" }

# Clear it (turns fall back to our keys + plan billing)
curl -X DELETE "https://api.fryri.com/v1/byo-key?provider=anthropic" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "enabled": true, "has_key": false, "key_prefix": null }

Errors

Every error response has the same shape, so you parse it once. The HTTP status carries the category (4xx your request, 5xx our side); error.code is a stable machine string to branch on; error.message is human-readable and may change; error.retryable is a boolean that tells you whether retrying this exact call unchanged could succeed (true for rate limits and transient server errors, false for anything you must fix first), so read it instead of inferring from the status. request_id is worth logging, quote it if you reach out. The same id rides every response (success too) as the X-Request-ID header, and on managed runs it doubles as the run_id for per-run usage.

# Shape (every non-2xx /v1 response):
# {
#   "error": { "code": "rate_limited", "message": "Rate limit exceeded. Slow down.", "retryable": true },
#   "request_id": "req_a1b2c3"
# }

# Common codes:
#   401 unauthorized        missing or revoked key
#   402 insufficient_credits   wallet is empty; every paid call needs prepaid
#                              credit. error.topup_endpoint carries the fix
#                              (POST /v1/wallet/topup-link), or top up in
#                              Settings. Only GET /v1/usage and /v1/wallet*
#                              stay reachable unfunded.
#   403 forbidden           feature not on your plan (e.g. research)
#   404 not_found
#   409 idempotency_key_reused   Idempotency-Key reused with a different body
#   422 invalid_request     bad body; error.errors has the field-level list
#   429 rate_limited        per-minute cap; honor the Retry-After header
#   429 monthly_budget_reached / day_budget_reached / key_budget_reached
#   429 topup_rate_limited / topup_daily_limit   top-up velocity brakes
#   5xx internal_error / upstream_error / unavailable   our side, safe to retry with backoff

Rate limits scale with your plan: free keys get 30 requests/min, and an account with a funded wallet gets at least 120/min. There are no rate-limit headers on normal responses, so don't try to pace against a quota; just back off when you see a 429, waiting the Retry-After seconds it carries.

MCP

Plug your library into MCP-native agents.

MCP server (POST /mcp)

The library tools (search, capture, grep, graph) are also exposed to MCP-native agents (Claude Desktop, Cursor, and anything that speaks the Model Context Protocol) at POST https://api.fryri.com/mcp. Point your agent at that URL with your API key as the bearer token; no integration code is required. It speaks JSON-RPC 2.0 (initialize, tools/list, tools/call).

# Discover the tools (no key needed for discovery):
curl -X POST https://api.fryri.com/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# -> { "result": { "tools": [ { "name": "search", "inputSchema": {...},
#        "annotations": { "readOnlyHint": true, "idempotentHint": true } }, ... ] } }

# Call a tool (key required, it runs the same billed /v1 route):
curl -X POST https://api.fryri.com/mcp \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"search","arguments":{"q":"insurance","limit":5}}}'
# -> { "result": { "isError": false,
#        "structuredContent": { "query": "insurance", "count": 3, "results": [...] },
#        "content": [ { "type": "text", "text": "..." } ] } }

A tools/call returns the same typed JSON the REST route does in structuredContent (not just prose), so your code parses a schema. It goes through the same auth, billing, and metering as the REST call. A keyless call comes back as the same typed 401 in structuredContent with isError set. The tool annotations mark reads as readOnly and capture as idempotent.

GET /v1/tools

For runtimes that speak OpenAI function calling instead of MCP: the same four tools as ready-made function definitions. Hand the tools array to your runtime, then execute each call the model makes against the matching REST route (search to GET /v1/search, grep to GET /v1/grep, graph to GET /v1/graph, capture to POST /v1/capture, same argument names).

curl "https://api.fryri.com/v1/tools" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "format": "openai-function-calling", "count": 4,
#      "tools": [ { "type": "function",
#                   "function": { "name": "search", "description": "...",
#                                 "parameters": { ... } } }, ... ] }

Beta: /v1/evolving

Everything under /v1/evolving is beta: these endpoints work today and use the same keys, billing, and error envelope as the stable surface, but they can change, move, or be retired with a 1 to 3 month deprecation window. Build on them with that in mind. GET /v1/evolving/health returns 200 while the surface is up.

GET /v1/evolving/query

Structural counts and lists over your library, no keyword matching (use /v1/search for that). operation is count, list, oldest, or newest; source is photos, files, chats, conversations, thoughts, events, or all (default). Optional date_from/date_to. Free.

curl "https://api.fryri.com/v1/evolving/query?operation=count&source=files" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "message": "Total: 12 files", "count": 12, "breakdown": { "files": 12 } }

GET /v1/evolving/trace

Walk your document graph from a person, place, or organisation: who and what they connect to, and the documents that link them. hops sets how far to walk (1 to 3, default 2). Returns structured nodes / edges / documents. Free.

curl "https://api.fryri.com/v1/evolving/trace?name=Mark+Thompson&hops=2" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "seed": "Mark Thompson", "seed_found": true, "hops_walked": 2,
#      "nodes": [...], "edges": [...], "documents": [...] }

Skills

A skill is a saved, re-runnable task recipe: plain-English instructions plus typed parameters with defaults. Running one executes the recipe through the full managed run (same retrieval, tools, and billing as /v1/chat). Create one from a full draft (title, body, parameters), or send just a description and the server writes the recipe for you. List, read, edit, and delete with the usual verbs.

POST   /v1/evolving/skills              create (draft, or {"description": "..."})
GET    /v1/evolving/skills              list (cursor paging)
GET    /v1/evolving/skills/{id}         one skill, including the recipe body
PATCH  /v1/evolving/skills/{id}         edit any subset of fields
DELETE /v1/evolving/skills/{id}         delete
POST   /v1/evolving/skills/{id}/run     start a run -> 202 { "run": {...}, "created": true }
GET    /v1/evolving/skills/{id}/runs    recent runs of one skill
GET    /v1/evolving/skills/runs/{run_id}   poll one run
curl -X POST https://api.fryri.com/v1/evolving/skills/1/run \
  -H "Authorization: Bearer $FRYRI_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: run-2026-07-06" \
  -d '{"params": {"email_filter": "label:newsletters"}}'

# -> 202 { "run": { "id": 42, "status": "queued", ... }, "created": true }

curl "https://api.fryri.com/v1/evolving/skills/runs/42" -H "Authorization: Bearer $FRYRI_KEY"

# -> { "run": { "id": 42, "status": "succeeded", "output": "Here's your digest: ...",
#               "output_artifact_ids": [7], "cost_usd": 0.002625, ... } }

Poll until status is succeeded or failed. A finished run's poll response includes output, the run's final answer text. Only one run per skill is in flight at a time: a duplicate POST returns the in-flight run with created: false, and an Idempotency-Key header makes a retry return the same run.

Agent runs

Read-only supervision of the background runs you fire from the dashboard: list them, poll one, read its step-by-step event trail, and cancel one that is heading the wrong way. Starting new runs and accepting a run's result stay in the dashboard for now.

GET  /v1/evolving/agent-runs                 list, newest first (status= filter, keyset paging)
GET  /v1/evolving/agent-runs/{id}            one run
GET  /v1/evolving/agent-runs/{id}/events     the event trail (pass after_seq= to catch up)
POST /v1/evolving/agent-runs/{id}/cancel     stop it, keeping the work already done

Events carry tool names, labels, and result counts, never tool arguments or outputs. A queued run cancels immediately (status: "canceled"); a running one acknowledges (status: "accepted") and stops at its next safe point. Canceling a finished run returns 409 with the run's outcome.