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.
- In Settings, Data, API keys, create a key. You get a
fryri_sk_...key. It's shown once, copy it then. - 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 } - Capture a note into your library.
statusis the typed receipt (created, deduped, or rejected), andidis 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 } - 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 } - Ask about it.
replyis the answer;sourceslists the library items that grounded it;run_idkeys 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/search
Search a user's library by meaning and get back the raw matching pieces, with no AI
answer, to build your own logic on top. Use this when you know what something is about; use /v1/grep when you know the exact
words. limit caps at 50. Page with the opaque next_cursor (pass it back as ?cursor=); do not assemble your own offset. Add include_trace=true for a provenance receipt with the cost split.
curl "https://api.fryri.com/v1/search?q=insurance&limit=10" \
-H "Authorization: Bearer $FRYRI_KEY"
# -> { "query": "insurance", "count": 3,
# "results": [
# { "id": 42, "kind": "file", "snippet": "...deductible is $500...",
# "score": 0.91, "from_keyword": false, "content_date": "2026-03-04" }
# ],
# "has_more": true,
# "next_cursor": "eyJvIjoxMH0...signed" }
# Next page: pass the cursor back verbatim.
curl "https://api.fryri.com/v1/search?q=insurance&limit=10&cursor=eyJvIjoxMH0...signed" \
-H "Authorization: Bearer $FRYRI_KEY"
# With include_trace=true, the response also carries:
# "provenance": { "cost_usd": 0.0025, "embedding_cost_usd": 0.0,
# "rerank_cost_usd": 0.0, "chunk_count": 15,
# "reranked": true, "folded": true } Each result's id + kind is the
stable key the other verbs take: an id for a file chains straight into /v1/documents/{id} and /v1/grep?file_id=. This is the one library read that isn't
free: $0.0025 per call, everything included, itemized in GET /v1/usage and in provenance. Every other read on this page is free.
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.
- 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. - Bytes push. No bucket configured. POST
content_base64with afilenameandcontent_type. The file lands on Fryri storage and Fryri handles the rest. Simplest, but the file sits on Fryri. - Text push. You extract the document
yourself (your own OCR or parsing) and POST
textonly. The raw file bytes never reach Fryri. You keep them, and send only the text plus, optionally, atitleto name the document and asource_refpointing 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:
- 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. - A capture bound for your bucket always lands in your bucket, even if the same file already exists on Fryri-managed storage.
- If your bucket can't take a capture (unreachable, bad credentials), the capture is
rejected with
reject_code: "storage_unreachable"andretryable: true, and thedetailnames 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 inGET /v1/usageasapi_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.