Fryri API reference
Start a run when you want Fryri to take on a task for your app. It can research a question, analyse data, make files and use actions you provide. For a question that only needs material from the library, use Answer. Use Search to get the matching passages for your own app to use.
| Call | What it does | Price |
|---|---|---|
POST /v1/runs | Starts the agent on a job, in a new thread or an existing one. | What its model and tools use |
GET /v1/runs/{run_id} | Returns a run with its status, reply, sources and files. | Free |
GET /v1/runs/{run_id}/events | Streams the steps and reply of a run, from any point. | Free |
POST /v1/runs/{run_id}/respond | Answers what a run paused for: an action result, an approval or a question. | Free |
POST /v1/runs/{run_id}/stop | Stops a run, including the step it is taking. | Free |
PUT /v1/actions, GET | Sets or reads the actions your app offers the agent. | Free |
POST /v1/webhooks, GET, DELETE | Tells your server when a run needs your app or settles. | Free |
GET /v1/threads, GET /v1/threads/{thread_id}, DELETE | Lists, reads and deletes the conversations runs happen in. | Free |
POST /v1/memories | Adds a note, document or conversation snapshot. | The model cost of reading it |
GET /v1/memories | Lists memories, newest first. | Free |
GET /v1/memories/{memory_id} | Returns one memory with its status, its text and its original file. | Free |
DELETE /v1/memories/{memory_id} | Deletes one memory. | Free |
POST /v1/search | Returns the memories that match a query, best match first. | What its model uses |
POST /v1/answer | Answers a question with citations and retrieved context. | What its model uses |
DELETE /v1/end-users/{end_user_id} | Erases one end user and all their memories. | Free |
Each of these calls starts at https://api.fryri.com/v1. A breaking change to
one of them ships as a new path.
A call rejects any field it does not define with 422 invalid_request, and error.message names the field. A typo such as enduser_id fails at once
instead of adding to the wrong library.
Browse endpoints
Quickstart
Make your first call from your server. Then use the same thread for a follow-up or stream a run to show progress in your app. The booking app guide shows how to connect your own actions and ask for approval.
- Create a key in the developer console.
A newly verified account starts with $1 of credit. Export the key as
FRYRI_API_KEY:export FRYRI_API_KEY="fryri_sk_..." - Start a run. Omit
thread_idto start a new conversation. The call waits up to 55 seconds. If it returns202, follow the run's events or check its status before using the reply.curl -X POST https://api.fryri.com/v1/runs \ -H "Authorization: Bearer $FRYRI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "Suggest three beginner classes for someone getting back into exercise, and say why in one line each.", "end_user_id": "member_812" }'{ "id": "run_b72d5e0a19c48f36d2a0e7c1", "thread_id": "thr_3e0b7c94d1a26f58c0e4b9a7", "end_user_id": "member_812", "status": "done", "reply": "1. **Gentle yoga**: it rebuilds mobility without strain.\n2. **Beginner spin**: you set the resistance, so the effort stays yours.\n3. **Low-impact circuit**: short stations build strength with rests in between.", "sources": [], "files": [], "error": null, "cost_usd": 0.016558, "created_at": "2026-11-07T09:02:11.540218Z", "finished_at": "2026-11-07T09:02:19.007431Z" } - To continue the conversation, send the returned
thread_idwith the user's next message once the first run has finished. Replace the example thread ID with your own.curl -X POST https://api.fryri.com/v1/runs \ -H "Authorization: Bearer $FRYRI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "Which of those suits a sore knee?", "end_user_id": "member_812", "thread_id": "thr_3e0b7c94d1a26f58c0e4b9a7" }' - Set
streamtotrueto receive server-sent events with progress and reply text.curl -N -X POST https://api.fryri.com/v1/runs \ -H "Authorization: Bearer $FRYRI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"message": "Find the opening hours of the city pool this week.", "end_user_id": "member_812", "stream": true}'
Complete example
Python, with the Python client (one file, no dependencies):
from fryri import Fryri
fryri = Fryri() # reads FRYRI_API_KEY
USER = "member_812"
# Send request
run = fryri.runs.create(
message="Suggest three beginner classes for someone getting back into exercise.",
end_user_id=USER,
)
run = fryri.runs.wait(run["id"])
# Continue the thread
follow_up = fryri.runs.create(
message="Which of those suits a sore knee?",
thread_id=run["thread_id"],
end_user_id=USER,
)
# Read response
print(run["reply"])
print(fryri.runs.wait(follow_up["id"])["reply"]) TypeScript on Node 18 or later, with the TypeScript client. Save both files side by side and run npx tsx quickstart.mts:
import { Fryri } from "./fryri.ts";
const fryri = new Fryri(); // reads FRYRI_API_KEY
const USER = "member_812";
// Send request
let run = await fryri.runs.create({
message: "Suggest three beginner classes for someone getting back into exercise.",
end_user_id: USER,
});
run = await fryri.runs.wait(run.id);
// Stream the follow-up as it is written
for await (const event of fryri.runs.stream({
message: "Which of those suits a sore knee?",
thread_id: run.thread_id,
end_user_id: USER,
})) {
if (event.type === "text") process.stdout.write(event.delta ?? "");
} Both clients are single files with no dependencies. They cover every call on this page,
follow a stream across dropped connections and verify webhook signatures. The OpenAPI spec at https://api.fryri.com/v1/openapi.json describes every call on this page, for a client of your own. https://api.fryri.com/v1/docs lets you try each call in the browser.
Authentication
An API key authenticates every request. Create keys in the developer console, which shows each key once. Send the key as a Bearer token:
Authorization: Bearer $FRYRI_API_KEY A missing, revoked or mistyped key fails with 401 unauthorized. A key works on the library of its own account
and on the end users of that account. It's recommended to keep keys on your server, in an
environment variable such as FRYRI_API_KEY.
Credit
Calls spend from the account's prepaid credit, separate from any Fryri plan. A newly
verified account starts with $1 of credit. Starting a run and every memory, search,
answer and end-user call need credit, including the free ones. Reading, following,
answering and stopping a run, threads, actions, webhooks and a retained answer work
without it. Without credit, a call that needs it fails with 402 insufficient_credits. Top-ups, spend caps per key, usage and
the request log are in the developer console. Pricing lists what each call costs.
Rate limits
Each key has a limit of 240 requests per minute, the
same on every plan.
A request over the limit fails with 429 rate_limited. Its Retry-After header gives the seconds to wait before the next try. Requests with a missing or
invalid key have a separate limit: after 100 of them in one minute from one client,
the next requests from that client also fail with 429 until the minute ends.
Request IDs
Every response carries an X-Request-ID header, and an error
body repeats it as request_id. It's recommended to log it
and quote it when you contact support.
Runs
A run works on one request. It belongs to a thread, which your app can reuse for follow-up messages.
The run object
Start, get, respond and stop return the same run object:
| Field | Type | Description |
|---|---|---|
id | string | The run id, such as run_5f0c2a9e41d87b3c6e20f914. |
thread_id | string | The thread the run belongs to. Pass it on the next run to continue the conversation. |
end_user_id | string or null | The end user the run works for, as sent. |
status | string | running while it works, needs_app while it waits for your app to respond, then done, failed or stopped. |
reply | string or null | The reply as Markdown, once the run settled. A stopped run keeps what it wrote. |
sources[] | array | What the reply drew on. Each source has type (text, document, conversation, reference or web), id for a memory, url for a web page, title, up to 300 characters of text, source_ref, location, citation and cited, as in answers. A memory from your shared library carries library: "shared". |
files[] | array | Files the run saved, each with title and type, such as markdown or chart. |
error | object or null | On a failed run: code, message and retryable. See Run errors. |
cost_usd | number or null | What the run cost, in US dollars, once settled. |
created_at, finished_at | string | ISO 8601 times in UTC. finished_at is null until the run settles. |
POST /v1/runs
POST /v1/runs starts the agent on a job. A run without thread_id starts a new thread, and a run with one continues
that conversation. A thread runs one run at a time.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
message | string | Yes | What to do, in natural language. Length [1, 400000]. | |
end_user_id | string | No | Runs for one of your end users, created on first use. The run reads their library and your shared library, and its thread belongs to them. Length [1, 256]. | |
thread_id | string | No | Continues this thread. Omit it to start a new thread. | |
abilities.web | boolean | No | true | Searches the web and reads pages. Default value is true. |
abilities.code | boolean | No | true | Writes and runs code to calculate or analyse. Default value is true. |
abilities.files | boolean | No | true | Saves what it makes, such as documents, tables and charts, as files. With false, what it makes stays in the reply as text. Default value is true. |
abilities.actions | boolean | No | true | Calls your actions and asks the person questions. Default value is true. |
stream | boolean | No | false | Answers with the run's events instead of the run object. Default value is false. |
request_id | string | No | Your own id for this request. Sending the same request_id again returns the same run instead of starting another. Letters, digits and _ . : -, length [1, 64]. |
Without stream, the call waits up to 55 seconds. A run that
settled returns 200. A run still working, or waiting for your
app, returns 202: follow it with events, a webhook or get. The run keeps working if your connection drops.
Every response carries the X-Fryri-Run-Id and X-Fryri-Thread-Id headers.
A run bills what its model and tools use, the same as the same job in the Fryri app, and cost_usd is what it charged. Web tools have listed prices. A run with thread_id while another run in that
thread works fails with 409 thread_busy.
An account runs up to 100 runs at once; the next one fails with 429 too_many_runs.
Request
curl -X POST https://api.fryri.com/v1/runs \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"message": "When did I last miss a class, and can I bring a guest to Thursday'\''s 6pm spin? Book me in if so.",
"end_user_id": "member_812"
}' Response 202
{
"id": "run_5f0c2a9e41d87b3c6e20f914",
"thread_id": "thr_8a31d07c5be24f96a1e0c3d2",
"end_user_id": "member_812",
"status": "needs_app",
"reply": null,
"sources": [],
"files": [],
"error": null,
"cost_usd": null,
"created_at": "2026-11-07T09:14:02.118407Z",
"finished_at": null
}GET /v1/runs/{run_id}
GET /v1/runs/{run_id} returns the run object: its
status and, once it settled, its reply, sources and files. The run id is enough: a run
belongs to your account, so a webhook handler that holds only the id can read it. end_user_id is optional on every call that names a run; when you
pass it, it must be the run's own, or the call returns 404.
Reading a run is free.
Request
curl "https://api.fryri.com/v1/runs/run_5f0c2a9e41d87b3c6e20f914" \
-H "Authorization: Bearer $FRYRI_API_KEY" Response
{
"id": "run_5f0c2a9e41d87b3c6e20f914",
"thread_id": "thr_8a31d07c5be24f96a1e0c3d2",
"end_user_id": "member_812",
"status": "done",
"reply": "You last missed a class on 28 October (Tuesday 7am yoga). Members can bring one guest a month to any class, so I booked you and a guest into Thursday's 6pm spin. [1]",
"sources": [
{
"type": "document",
"id": "file_Qm81vTzKc0aR2wLb",
"title": "Guest policy",
"url": null,
"text": "Members may bring one guest per calendar month to any class. Guests sign the waiver at the front desk...",
"text_truncated": true,
"source_ref": "policies/guests.md",
"location": {
"line_start": 3,
"line_end": 5
},
"citation": 1,
"cited": true,
"library": "shared"
}
],
"files": [],
"error": null,
"cost_usd": 0.016558,
"created_at": "2026-11-07T09:14:02.118407Z",
"finished_at": "2026-11-07T09:15:41.902733Z"
}GET /v1/runs/{run_id}/events
GET /v1/runs/{run_id}/events streams the events of a
run as text/event-stream, from the start or after a given seq. A POST /v1/runs with stream: true answers with the same events. Each event is an id: line and one data: line that
holds a JSON object with seq and type:
type | Fields | Meaning |
|---|---|---|
progress | step, status, label, outcome, search, targets | A step the agent takes. status is started or finished, and a later event for the same step replaces the earlier one. |
text | delta or reset | Reply text to append, or reset: true to clear the text shown so far. |
action_request | request_id, expires_at, action, input | The run asks your app to run an action. Respond with its result. |
approval_request | request_id, expires_at, action, input, description | The run asks the person to approve an action marked needs_approval. Respond with their decision. |
question | request_id, expires_at, question, choices, allow_free_text | The run asks the person a question. choices is a list of {id, label}, possibly empty. Respond with their answer. |
done | run | The run settled as done or stopped. run is the run object. |
error | error, run | The run failed. error says why. |
The stream ends after done or error.
The reply in done is final, so it
replaces the joined text. While a request waits for your app,
the run has the status needs_app and
the stream stays open; the events go on after you respond. Lines that start with : are heartbeats. Events are kept, so a dropped connection
reconnects with after set to the last seq it received, or with the standard Last-Event-ID header, and
misses nothing. Following a run is free.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
after | integer | No | 0 | Sends only the events after this seq. Default value is 0, every event. |
end_user_id | string | No | Optional. When given, it must be the run's end_user_id, or the call returns 404. |
Request
curl -N "https://api.fryri.com/v1/runs/run_5f0c2a9e41d87b3c6e20f914/events" \
-H "Authorization: Bearer $FRYRI_API_KEY" Response
id: 1
data: {"seq":1,"type":"progress","step":"step_1","status":"started","label":"Searching the library"}
id: 2
data: {"seq":2,"type":"progress","step":"step_1","status":"finished","label":"Searching the library","outcome":"succeeded","targets":[{"kind":"document","label":"Guest policy"}]}
id: 3
data: {"seq":3,"type":"action_request","request_id":"req_91b0e4d27c3a58f6b2d0e817","expires_at":"2026-11-07T09:29:05.664120Z","action":"find_bookings","input":{"from":"2026-10-01"}}
id: 4
data: {"seq":4,"type":"approval_request","request_id":"req_2c7e91f04ab35d68e0f1a9c4","expires_at":"2026-11-08T09:14:19.250981Z","action":"book_class","input":{"class_id":"spin_thu_1800","guests":1},"description":"Book the member into a class, with up to one guest."}
id: 5
data: {"seq":5,"type":"action_request","request_id":"req_d4e6a0b37f91c25e8a0b6c31","expires_at":"2026-11-07T09:30:37.118544Z","action":"book_class","input":{"class_id":"spin_thu_1800","guests":1}}
id: 6
data: {"seq":6,"type":"text","delta":"You last missed a class on 28 October (Tuesday 7am yoga). "}
id: 7
data: {"seq":7,"type":"text","delta":"Members can bring one guest a month to any class, so I booked you and a guest into Thursday's 6pm spin."}
id: 8
data: {"seq":8,"type":"done","run":{"id":"run_5f0c2a9e41d87b3c6e20f914","thread_id":"thr_8a31d07c5be24f96a1e0c3d2","end_user_id":"member_812","status":"done","reply":"You last missed a class on 28 October (Tuesday 7am yoga). Members can bring one guest a month to any class, so I booked you and a guest into Thursday's 6pm spin. [1]","sources":[{"type":"document","id":"file_Qm81vTzKc0aR2wLb","title":"Guest policy","url":null,"text":"Members may bring one guest per calendar month to any class. Guests sign the waiver at the front desk...","text_truncated":true,"source_ref":"policies/guests.md","location":{"line_start":3,"line_end":5},"citation":1,"cited":true,"library":"shared"}],"files":[],"error":null,"cost_usd":0.016558,"created_at":"2026-11-07T09:14:02.118407Z","finished_at":"2026-11-07T09:15:41.902733Z"}}POST /v1/runs/{run_id}/respond
POST /v1/runs/{run_id}/respond answers what a run
paused for, and the run carries on. One call answers all three kinds of request, each with
its own field:
| Request | Send | What happens |
|---|---|---|
action_request | result (any JSON, up to 256 KB), or error (text) when the action failed | The agent reads it as the action's result. |
approval_request | decision: approve or decline | approve is followed by an action_request for the same call. With decline, the action does not run and the agent replies without it. |
question | answer: a choice's id or label, or the person's own words when allow_free_text is true | The agent goes on with the answer. |
Every respond carries the request_id of the event. Answering the same request_id again with the same answer changes nothing. A
different answer fails with 409 request_answered, and an answer of the wrong kind with 422 wrong_answer_kind.
An action_request left unanswered expires after 15 minutes, and
an approval or a question after 24 hours: the call settles as failed and the run replies
without it. Like POST /v1/runs, the call waits up to 55
seconds and returns the run: 200 once it settled, else 202. Responding is free; the run bills its own work.
Request: an action's result
curl -X POST https://api.fryri.com/v1/runs/run_5f0c2a9e41d87b3c6e20f914/respond \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"request_id": "req_91b0e4d27c3a58f6b2d0e817",
"result": {
"bookings": [
{
"class": "Yoga, Tuesday 7am",
"date": "2026-10-28",
"attended": false
},
{
"class": "Spin, Thursday 6pm",
"date": "2026-10-30",
"attended": true
}
]
}
}' Request: an approval
curl -X POST https://api.fryri.com/v1/runs/run_5f0c2a9e41d87b3c6e20f914/respond \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"request_id": "req_2c7e91f04ab35d68e0f1a9c4",
"decision": "approve"
}' Response 202
{
"id": "run_5f0c2a9e41d87b3c6e20f914",
"thread_id": "thr_8a31d07c5be24f96a1e0c3d2",
"end_user_id": "member_812",
"status": "needs_app",
"reply": null,
"sources": [],
"files": [],
"error": null,
"cost_usd": null,
"created_at": "2026-11-07T09:14:02.118407Z",
"finished_at": null
}POST /v1/runs/{run_id}/stop
POST /v1/runs/{run_id}/stop stops a run, including
the step it is taking and a request waiting for your app. The run settles as stopped and keeps the reply it had written. Stopping a run that
already settled returns it unchanged. The call waits up to 10 seconds for the run to settle
and returns 200, or 202 while the
stop is on its way. The body can be empty; an end_user_id in it must
be the run's own. Stopping is free.
Request
curl -X POST https://api.fryri.com/v1/runs/run_5f0c2a9e41d87b3c6e20f914/stop \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{}' Response
{
"id": "run_5f0c2a9e41d87b3c6e20f914",
"thread_id": "thr_8a31d07c5be24f96a1e0c3d2",
"end_user_id": "member_812",
"status": "stopped",
"reply": "You last missed a class on 28 October.",
"sources": [],
"files": [],
"error": null,
"cost_usd": 0.016558,
"created_at": "2026-11-07T09:14:02.118407Z",
"finished_at": "2026-11-07T09:14:33.507112Z"
} Run errors
A failed run carries one of these in error.code:
code | retryable | Recovery |
|---|---|---|
run_interrupted | true | The server restarted before the run finished. Start a new run in the same thread. |
run_not_started | true | Start the run again. |
resume_failed | true | The run could not continue after your app answered. Start a new run in the same thread. |
upstream_error | true | The model returned an error. Start the run again. |
internal_error | true | Start the run again. Quote the run id if it persists. |
scope_unavailable | false | The key or the end user of the run no longer exists. |
insufficient_credits | false | The prepaid credit could not cover the run's next step, so it stopped before that step. Add credit in the developer console, then start a new run in the same thread. |
Your app
Actions let a run work in your own service, webhooks tell your server when a run needs it, and threads hold the conversations runs happen in.
PUT /v1/actions
PUT /v1/actions sets the actions your app offers the agent,
replacing the whole list, and GET /v1/actions reads it back.
Every run of your account, your end users' runs included, can call them. A run calls an
action by pausing with an action_request: your app runs its own
code and sends the result to POST /v1/runs/{run_id}/respond.
Fryri calls no address of yours, so an action works for any app, including one with no public API.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
actions | array | Yes | The whole list, up to 64 actions. An empty list removes every action. | |
actions[].name | string | Yes | How the agent names it: letters, digits and underscores, starting with a letter. Length [1, 64]. | |
actions[].description | string | Yes | What it does and when to use it, in plain words. The agent reads it, and an approval_request repeats it for the person. Length [1, 1000]. | |
actions[].input | object | No | A JSON Schema with type: "object" for its inputs, up to 8 KB. Fryri checks every call's input against it before your app is asked. Supported keywords: type, properties, required, enum, const, items, additionalProperties, minLength, maxLength, minimum, maximum, minItems, maxItems, plus title, description, default, examples and format. Omit it for an action with no inputs. | |
actions[].needs_approval | boolean | No | false | Asks the person first: the run emits an approval_request, and the action runs only after your app answers approve. Default value is false. |
A run that is already working uses the new list from its next call. A call to an action the new list drops does not reach your app. Setting and reading actions is free.
Request
curl -X PUT https://api.fryri.com/v1/actions \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"actions": [
{
"name": "find_bookings",
"description": "List the member'\''s class bookings and attendance between two dates.",
"input": {
"type": "object",
"properties": {
"from": {
"type": "string",
"format": "date"
},
"to": {
"type": "string",
"format": "date"
}
},
"required": [
"from"
]
},
"needs_approval": false
},
{
"name": "book_class",
"description": "Book the member into a class, with up to one guest.",
"input": {
"type": "object",
"properties": {
"class_id": {
"type": "string"
},
"guests": {
"type": "integer",
"minimum": 0,
"maximum": 1
}
},
"required": [
"class_id"
]
},
"needs_approval": true
}
]
}' Response
{
"actions": [
{
"name": "find_bookings",
"description": "List the member's class bookings and attendance between two dates.",
"input": {
"type": "object",
"properties": {
"from": {
"type": "string",
"format": "date"
},
"to": {
"type": "string",
"format": "date"
}
},
"required": [
"from"
]
},
"needs_approval": false
},
{
"name": "book_class",
"description": "Book the member into a class, with up to one guest.",
"input": {
"type": "object",
"properties": {
"class_id": {
"type": "string"
},
"guests": {
"type": "integer",
"minimum": 0,
"maximum": 1
}
},
"required": [
"class_id"
]
},
"needs_approval": true
}
]
}POST /v1/webhooks
POST /v1/webhooks registers an https address that Fryri sends a POST to when a run of your account needs your app or settles.
A webhook suits an app that cannot hold a stream open while a person decides. GET /v1/webhooks lists them without their secrets, and DELETE /v1/webhooks/{webhook_id} deletes one. An
account has up to 10 webhooks. Webhooks are free.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
url | string | Yes | An https address on the public internet that accepts POST. Length [8, 2048]. | |
events | array | No | All four | Which events to send: run.needs_app, run.done, run.failed and run.stopped. Default value is all four. |
The response carries the secret that signs every delivery. It is
shown only in this response, so store it. enabled turns false after 8 failed deliveries in a row; register the address
again to start over.
Request
curl -X POST https://api.fryri.com/v1/webhooks \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://gym.example.com/fryri/webhook"
}' Response
{
"id": "wh_14",
"url": "https://gym.example.com/fryri/webhook",
"events": [
"run.needs_app",
"run.done",
"run.failed",
"run.stopped"
],
"enabled": true,
"created_at": "2026-11-07T09:10:12.004571Z",
"last_success_at": null,
"last_failure_at": null,
"secret": "whsec_****"
} Deliveries
Each delivery is a JSON body with id, type, created_at and data. data holds run_id, thread_id, end_user_id and status, and for run.needs_app the open request with its id, kind (action, approval or question) and action. A delivery never carries the reply or what anyone
wrote: read those with GET /v1/runs/{run_id} and its events.
Delivery
{
"id": "evt_5821",
"type": "run.needs_app",
"created_at": "2026-11-07T09:14:19.251377Z",
"data": {
"run_id": "run_5f0c2a9e41d87b3c6e20f914",
"thread_id": "thr_8a31d07c5be24f96a1e0c3d2",
"end_user_id": "member_812",
"status": "needs_app",
"request": {
"id": "req_2c7e91f04ab35d68e0f1a9c4",
"kind": "approval",
"action": "book_class"
}
}
} The Fryri-Signature header reads t={unix seconds},v1={hex}, where v1 is the HMAC-SHA256 of {t}.{raw body} keyed with the secret. Verify it against the raw bytes before parsing, and reject a t far from now. Answer with a 2xx within 5 seconds. Any other answer is retried after 1, 5, 15, 60 and 240 minutes.
Verify a delivery
Python:
import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1) TypeScript on Node 18 or later:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(
header.split(",").map((part) => [part.slice(0, part.indexOf("=")), part.slice(part.indexOf("=") + 1)]),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(t + "." + rawBody).digest("hex");
const given = String(parts.v1 ?? "");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
} List webhooks
curl "https://api.fryri.com/v1/webhooks" \
-H "Authorization: Bearer $FRYRI_API_KEY" {
"webhooks": [
{
"id": "wh_14",
"url": "https://gym.example.com/fryri/webhook",
"events": [
"run.needs_app",
"run.done",
"run.failed",
"run.stopped"
],
"enabled": true,
"created_at": "2026-11-07T09:10:12.004571Z",
"last_success_at": "2026-11-07T09:15:42.311204Z",
"last_failure_at": null
}
]
} Delete a webhook
curl -X DELETE "https://api.fryri.com/v1/webhooks/wh_14" \
-H "Authorization: Bearer $FRYRI_API_KEY" {
"id": "wh_14",
"deleted": true
}GET /v1/threads
A thread is one ongoing conversation, and every run happens in one. The agent remembers
what was said in a thread. GET /v1/threads lists threads, most recently
used first, paged with next_cursor. GET /v1/threads/{thread_id} returns one thread with
up to its 200 newest messages, oldest first. Set end_user_id to list one of your end users' threads. A thread id alone finds that thread, whichever end
user it belongs to; an end_user_id passed with it must be the
thread's own, or the call returns 404. Reading threads is free.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
end_user_id | string | No | Lists the threads of one of your end users. | |
limit | integer | No | 20 | Threads on this page. Range [1, 100]. Default value is 20. |
cursor | string | No | The next_cursor from the previous page. |
List threads
curl "https://api.fryri.com/v1/threads?end_user_id=member_812&limit=20" \
-H "Authorization: Bearer $FRYRI_API_KEY" {
"threads": [
{
"id": "thr_8a31d07c5be24f96a1e0c3d2",
"end_user_id": "member_812",
"title": "Missed classes and a guest pass",
"created_at": "2026-11-07T09:14:02.110925Z",
"updated_at": "2026-11-07T09:15:41.902733Z"
}
],
"next_cursor": null
} Get a thread
curl "https://api.fryri.com/v1/threads/thr_8a31d07c5be24f96a1e0c3d2" \
-H "Authorization: Bearer $FRYRI_API_KEY" {
"id": "thr_8a31d07c5be24f96a1e0c3d2",
"end_user_id": "member_812",
"title": "Missed classes and a guest pass",
"created_at": "2026-11-07T09:14:02.110925Z",
"updated_at": "2026-11-07T09:15:41.902733Z",
"messages": [
{
"role": "user",
"text": "When did I last miss a class, and can I bring a guest to Thursday's 6pm spin? Book me in if so.",
"created_at": "2026-11-07T09:14:02.131760Z"
},
{
"role": "assistant",
"text": "You last missed a class on 28 October (Tuesday 7am yoga). Members can bring one guest a month to any class, so I booked you and a guest into Thursday's 6pm spin.",
"created_at": "2026-11-07T09:15:41.880214Z"
}
]
} DELETE /v1/threads/{thread_id} deletes a thread: a
run still working in it stops, its messages and run results are deleted, and what the agent
learned from it is forgotten with it. Deleting is free.
Delete a thread
curl -X DELETE "https://api.fryri.com/v1/threads/thr_8a31d07c5be24f96a1e0c3d2" \
-H "Authorization: Bearer $FRYRI_API_KEY" {
"id": "thr_8a31d07c5be24f96a1e0c3d2",
"deleted": true
}Memories
A memory is one thing your app adds: a note, document or conversation snapshot. Runs, search and answers read memories, and your shared library holds the memories every end user reads.
The memory object
Add, list and get return the same memory object:
| Field | Type | Description |
|---|---|---|
id | string | The memory id, such as file_NkolfLrElMmM2fCn. |
type | string | text (plain text and Markdown), document (other files), or conversation (ordered turns). |
title | string or null | The title sent with the add, else the file name. null for text added without a title. |
status | string | processing until search can find the memory, then ready. failed when it cannot be processed. |
status_reason | string or null | Why the memory is failed or still processing. See the table below. |
created_at | string | When the memory was added, as an ISO 8601 time in UTC. |
source_ref | string or null | Your own id for the original, such as a file path, a URL or a record id, as sent with the add. See Replace by reference. |
sha256 | string or null | The SHA-256 of the content as it was added, in hex: file bytes, UTF-8 text, or canonical JSON for a conversation. Compare it with the hash of your own copy to skip sending unchanged content. null when Fryri holds no hash for the memory. |
library | string or null | shared for a memory in your shared library, otherwise null. |
status and status_reason go
together as follows:
status | status_reason | Meaning |
|---|---|---|
processing | null | Fryri is reading and indexing the memory. |
processing | queued | The memory was added in the Fryri app and waits for the daily document allowance of the account, then processes on its own. Memories added through the API never wait. |
ready | null | Search and answers can find the memory. |
failed | processing_failed | Reading the memory failed. Adding the same content again starts a new attempt. |
failed | unsupported_format | Fryri cannot read the format of the file. |
failed | reading_allowance_reached | The monthly allowance of the account for reading scanned pages is used up. |
POST /v1/memories
POST /v1/memories adds a note, document or conversation snapshot. The
memory comes back at once with the status processing, and search finds it once it is ready. A short note is usually ready within seconds. A long
document, or a large batch added at once, can take a few minutes.
Send exactly one of text, file or messages as JSON, or upload a file as multipart/form-data. A JSON body
must carry exactly one of the three. Otherwise, the request fails with 422 invalid_request.
JSON body
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
text | string | One of three | Text to remember, such as a note, a message or a transcript. Length [1, 200000]. | |
file | object | One of three | A document sent inline, with the three fields below. A file over about 8 MB goes as a multipart/form-data upload instead. | |
messages | array | One of three | An ordered conversation snapshot of 1 to 200 user or assistant turns, as described below. | |
file.name | string | Yes, in file | The file name with its extension, such as contract.pdf. Length [1, 400]. | |
file.data | string | Yes, in file | The file bytes, base64-encoded, up to about 8 MB of file. | |
file.type | string | No | The MIME type, such as application/pdf. Fryri works it out from file.name when it is absent. | |
title | string | No | A name for the memory. Length [1, 400]. | |
source_ref | string | No | Your own id for the original, such as a file path, a URL or a record id. Adding again with the same source_ref replaces the earlier memory. See Replace by reference. Length [1, 2000]. | |
end_user_id | string | No | Adds the memory to one of your end users, created on first use. Length [1, 256]. | |
scope | string | No | own | shared adds the memory to your shared library, which every end user reads. Cannot be combined with end_user_id. Default value is own. |
messages is a JSON array of 1 to 200 turns. Each turn has role (user or assistant),
nonblank content and optional created_at with a time zone. Array order is authoritative. The rendered transcript, including turn headings,
must fit within 30,000 characters. Send a complete updated snapshot with the same source_ref to replace an earlier one. See Conversation snapshots.
Multipart form fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
file | file | Yes | The document to remember, up to 50 MB. | |
title | string | No | As in the JSON body. | |
source_ref | string | No | As in the JSON body. | |
end_user_id | string | No | As in the JSON body. |
Header
| Header | Type | Required | Default | Description |
|---|---|---|---|---|
Idempotency-Key | string | No | Makes a retry safe. The same key and body replay the first response for 24 hours. The same key with a different body fails with 409 idempotency_key_reused. If the request is still running on another worker, 409 idempotency_request_pending is retryable: wait for Retry-After, then send the same key and body. An interrupted request can resume after its five-minute execution lease expires; its key stays bound to the original body for 24 hours, and longer while an active attempt holds its lease. |
It's recommended to send an Idempotency-Key with every add,
so a retry after a dropped connection never adds the memory twice.
The response is a memory object with two more
fields. duplicate is true when
identical content was already stored. id is then the existing
memory, and nothing new is added. replaced lists the ids of
the earlier memories that this add replaced, and it is empty when nothing was replaced.
Reading and indexing the content bills its model cost to
the prepaid credit of the account: about $0.003 for a short note. A long document costs
more, because the cost follows the length of the content.
Request
curl -X POST https://api.fryri.com/v1/memories \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Project Atlas uses Python and FastAPI. The launch review is on 14 October.",
"title": "Project Atlas notes",
"source_ref": "crm:note:881",
"end_user_id": "user_42"
}' Response
{
"id": "file_NkolfLrElMmM2fCn",
"type": "text",
"title": "Project Atlas notes",
"status": "processing",
"status_reason": null,
"created_at": "2026-09-25T09:02:54.915644Z",
"source_ref": "crm:note:881",
"sha256": "886d5886b41216cf0dc8e2234f4ffb24cb5666425200e5e2c5ee33d841b91be4",
"duplicate": false,
"replaced": []
} Duplicates
The same request, sent again, returns the existing memory with duplicate set to true.
Response
{
"id": "file_NkolfLrElMmM2fCn",
"type": "text",
"title": "Project Atlas notes",
"status": "processing",
"status_reason": null,
"created_at": "2026-09-25T09:02:54.915644Z",
"source_ref": "crm:note:881",
"sha256": "886d5886b41216cf0dc8e2234f4ffb24cb5666425200e5e2c5ee33d841b91be4",
"duplicate": true,
"replaced": []
} Replace by reference
A memory is one copy of one original. An add with a source_ref that earlier memories carry replaces them. The new content gets a new id, the earlier memories stop appearing in search and answers
at once, and replaced lists their ids. An add of unchanged
content returns the existing memory with duplicate set to true and keeps it. A failed add leaves the earlier memory in
place.
Here src/auth.py was added once before and has changed since. Keep a codebase in sync has a
complete script for a whole folder or repository.
Request
curl -X POST https://api.fryri.com/v1/memories \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-F "file=@src/auth.py" \
-F "source_ref=myrepo/src/auth.py" \
-F "end_user_id=user_42" Response
{
"id": "file_hqjP2q_DlrHZT-4U",
"type": "document",
"title": "auth.py",
"status": "processing",
"status_reason": null,
"created_at": "2026-09-25T09:07:09.917301Z",
"source_ref": "myrepo/src/auth.py",
"sha256": "785108e6758610a47355924cf936ce293ac1c6e5e6c7cd9b8752c19c78c00e26",
"duplicate": false,
"replaced": [
"file_kR1jHO7CDRwaoCEw"
]
} Upload a file
A multipart/form-data upload takes a file of up to 50 MB.
The file type comes from the content type of the part, or from the file name when that is
missing or application/octet-stream.
Request
curl -X POST https://api.fryri.com/v1/memories \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-F "file=@atlas-budget.txt" \
-F "title=Atlas budget" \
-F "end_user_id=user_42" Response
{
"id": "file_khWTQ9FQRD0vNdAx",
"type": "text",
"title": "Atlas budget",
"status": "processing",
"status_reason": null,
"created_at": "2026-09-25T09:02:58.286297Z",
"source_ref": null,
"sha256": "c6a6c1b533130aa2c02bb580edb9263885ac229f1ebd7016e1a341885a908ee4",
"duplicate": false,
"replaced": []
} Supported files
A file memory holds a document. Fryri reads these files:
| Kind | Files |
|---|---|
| Documents | PDF, Word (.doc, .docx), PowerPoint (.pptx), Excel (.xlsx), OpenDocument (.odt, .ods, .odp), RTF and EPUB |
| Text and data | Plain text, Markdown, CSV, JSON and HTML |
.eml and .msg | |
| Code | Source code files and Jupyter notebooks |
An add of a photo, a video or an audio file, judged by its content type or its file
extension, fails with 422 unsupported_format and stores nothing.
Add a web page
To remember a web page, fetch it in your own code and add its text, with the address of the
page as source_ref. An add of the same address later replaces
the earlier copy.
Request
curl -X POST https://api.fryri.com/v1/memories \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Example Domain. This domain is for use in documentation examples.",
"title": "Example Domain",
"source_ref": "https://example.com",
"end_user_id": "user_42"
}'GET /v1/memories
GET /v1/memories lists memories, newest first. The list covers
every note, document and conversation snapshot in the library, however it arrived. Listing is free.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
limit | integer | No | 20 | Memories on this page. Default value is 20. Range [1, 100]. |
cursor | string | No | The next_cursor from the previous page. | |
source_ref_prefix | string | No | Lists only the memories whose source_ref starts with this text, such as myrepo/. Characters like % and _ match themselves. Length [1, 2000]. | |
end_user_id | string | No | Lists the memories of one of your end users. An id with no memories returns an empty list. |
The response holds memories, an array of memory objects, and next_cursor. Pass next_cursor back as cursor for the next page; it is null on the last page. A cursor from a different list starts
again at the first page.
Request
curl "https://api.fryri.com/v1/memories?end_user_id=user_42&limit=2" \
-H "Authorization: Bearer $FRYRI_API_KEY" Response
{
"memories": [
{
"id": "file_tjSa4DvE15aMARb4",
"type": "document",
"title": "Example Domain.html",
"status": "ready",
"status_reason": null,
"created_at": "2026-09-25T09:03:01.562079Z",
"source_ref": "https://example.com",
"sha256": "ff67a9d764d6a2367a187734e697f6a53217db9a21c101d410a113ca871a299d"
},
{
"id": "file_khWTQ9FQRD0vNdAx",
"type": "text",
"title": "Atlas budget",
"status": "ready",
"status_reason": null,
"created_at": "2026-09-25T09:02:58.286297Z",
"source_ref": null,
"sha256": "c6a6c1b533130aa2c02bb580edb9263885ac229f1ebd7016e1a341885a908ee4"
}
],
"next_cursor": "eyJwIjp7ImkiOjQ1OSwiayI6..."
} List one folder or codebase
source_ref_prefix lists the memories you added under one
prefix of source_ref, such as the files of one repository.
Request
curl "https://api.fryri.com/v1/memories?end_user_id=user_42&source_ref_prefix=myrepo%2F" \
-H "Authorization: Bearer $FRYRI_API_KEY" Response
{
"memories": [
{
"id": "file_hqjP2q_DlrHZT-4U",
"type": "document",
"title": "auth.py",
"status": "processing",
"status_reason": null,
"created_at": "2026-09-25T09:07:09.917301Z",
"source_ref": "myrepo/src/auth.py",
"sha256": "785108e6758610a47355924cf936ce293ac1c6e5e6c7cd9b8752c19c78c00e26"
}
],
"next_cursor": null
}GET /v1/memories/{memory_id}
GET /v1/memories/{memory_id} returns one memory with
its status, its text and a link to its original file. Reading is free.
Poll it after an add until status is ready or failed. A short note
is usually ready within seconds. A long document can take a few minutes.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
memory_id | string | Yes | The memory id, in the path. | |
cursor | string | No | The next_cursor from the previous read of this memory. | |
end_user_id | string | No | The end user the memory belongs to. |
A memory of an end user must be read with its end_user_id.
Otherwise, the request fails with 404 not_found. A cursor from another memory, or from an earlier
version of its text, fails with 422 invalid_cursor.
The response is a memory object with six more fields:
| Field | Type | Description |
|---|---|---|
summary | string or null | A one-line summary of a document, once it is processed. |
text | string or null | The text of the memory: a note as it was added, or the extracted text of a document. |
lines | object or null | For a document, which lines text holds: start and end (1-based and inclusive) out of total. |
next_cursor | string or null | More text follows. Pass it back as cursor to read the next part. |
messages | array or null | The original turns of a processed conversation, on its first page only, as described below. |
download_url | string or null | A link to the original file, valid for 5 minutes. null when no original file is available. |
A processed conversation also returns messages on its first page: original role, content and created_at, with 1-based turn, line_start and line_end. Other memories and later pages return messages: null.
The text of a long document arrives in parts. lines counts the
same lines as location.line_start in a search result.
Request
curl "https://api.fryri.com/v1/memories/file_NkolfLrElMmM2fCn?end_user_id=user_42" \
-H "Authorization: Bearer $FRYRI_API_KEY" Response
{
"id": "file_NkolfLrElMmM2fCn",
"type": "text",
"title": "Project Atlas notes",
"status": "ready",
"status_reason": null,
"created_at": "2026-09-25T09:02:54.915644Z",
"source_ref": "crm:note:881",
"sha256": "886d5886b41216cf0dc8e2234f4ffb24cb5666425200e5e2c5ee33d841b91be4",
"summary": "A brief note about Project Atlas, which uses Python and FastAPI, with a launch review scheduled for 14 October.",
"text": "Project Atlas uses Python and FastAPI. The launch review is on 14 October.",
"lines": {
"start": 1,
"end": 1,
"total": 1
},
"next_cursor": null,
"download_url": "https://****.r2.cloudfla..."
}DELETE /v1/memories/{memory_id}
DELETE /v1/memories/{memory_id} deletes one memory.
It stops appearing in search and answers at once, together with anything learned from it.
Deleting is free.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
memory_id | string | Yes | The memory id, in the path. | |
end_user_id | string | No | The end user the memory belongs to. |
A memory of an end user must be deleted with its end_user_id.
Otherwise, the request fails with 404 not_found. A second delete of the same memory also fails with 404. To erase everything stored for one end user, call DELETE /v1/end-users/{end_user_id}.
Request
curl -X DELETE "https://api.fryri.com/v1/memories/file_khWTQ9FQRD0vNdAx?end_user_id=user_42" \
-H "Authorization: Bearer $FRYRI_API_KEY" Response
{
"id": "file_khWTQ9FQRD0vNdAx",
"deleted": true
}POST /v1/search
POST /v1/search returns the memories that match a query, best
match first. It matches by meaning and by exact words, so a query does not need the wording
of the memory. Each result is a memory with its most relevant passage.
Search writes no answer. For an answer with sources, call POST /v1/answer. Each
search bills the model cost of reading the query and ranking the matches, reported in cost_usd.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
query | string | Yes | What to find, in natural language or exact words. Length [1, 8000]. | |
limit | integer | No | 10 | Maximum results on this page. Default value is 10. Range [1, 50]. |
cursor | string | No | The next_cursor from the previous page of the same query. A cursor from another query or end user starts again at the first page. | |
end_user_id | string | No | Searches the memories of one of your end users. |
Response fields:
| Field | Type | Description |
|---|---|---|
results[].id | string | The memory id, for GET /v1/memories/{memory_id} and DELETE /v1/memories/{memory_id}. |
results[].type | string | The type of the memory, as in the memory object. |
results[].title | string or null | The title of the memory. |
results[].text | string | The most relevant passage of the memory, up to 1,200 characters. |
results[].score | number | Relevance in [0, 1]. Higher is a better match. |
results[].score_basis | string | How score was measured: relevance (the calibrated mixed score), cosine (how close the meanings are) or lexical (a keyword-only match, which stays below 0.5). |
results[].location | object or null | Where text sits in the memory. Conversations can also include turn_start and turn_end. Lines use line_start and line_end for a document. null when the match covers the whole memory. |
results[].created_at | string | When the memory was added. |
results[].source_ref | string or null | Your own pointer back to the original. |
results[].library | string or null | shared when the memory is in your shared library. Open it with GET /v1/memories/{memory_id} and the same end_user_id. |
next_cursor | string or null | Pass it back as cursor for the next page. null on the last page. |
cost_usd | number | What this call cost, in US dollars. |
Request
curl -X POST https://api.fryri.com/v1/search \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "When is the Atlas launch review?",
"end_user_id": "user_42",
"limit": 3
}' Response
{
"results": [
{
"id": "file_NkolfLrElMmM2fCn",
"type": "text",
"title": "Project Atlas notes",
"text": "Project Atlas uses Python and FastAPI. The launch review is on 14 October.",
"score": 0.526897227015439,
"score_basis": "cosine",
"location": null,
"created_at": "2026-09-25T09:02:54.915644Z",
"source_ref": "crm:note:881"
},
{
"id": "file_khWTQ9FQRD0vNdAx",
"type": "text",
"title": "Atlas budget",
"text": "Atlas budget\nDesign: 12,000 NZD\nBuild: 30,000 NZD",
"score": 0.234705295047091,
"score_basis": "cosine",
"location": null,
"created_at": "2026-09-25T09:02:58.286297Z",
"source_ref": null
}
],
"next_cursor": null,
"cost_usd": 0.000001
}POST /v1/answer
POST /v1/answer answers a question from memories and lists
cited evidence and retrieved context separately. One call finds what is relevant, reads it and writes the answer. An
answer draws only on the memories of the library, and it never adds or changes a memory.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
question | string | Yes | The question, in natural language. Length [1, 50000]. | |
end_user_id | string | No | Answers from the memories of one of your end users. | |
stream | boolean | No | false | Delivers the answer as server-sent events while it is written. Default value is false. |
resumable | boolean | No | false | Retain this answer for 48 hours from acceptance and continue after disconnect. Requires request_id. The library stays read-only. |
request_id | UUID | With resumable | Your UUID, kept before sending. Reuse it with the same question, API key and end-user scope to retrieve the same answer. |
Answers are unsaved by default. With resumable: true,
Fryri retains the completed answer and sources for 48 hours from acceptance, and a completed
answer is billed even if you disconnect. The question and incremental transcript are not retained.
A repeated POST with the same UUID and question joins or retrieves that answer; a different
question returns 409 answer_request_conflict.
GET /v1/answer/{request_id} returns id, status, expires_at, result, error. The statuses are queued, running, completed, failed and interrupted.
A completed result has the usual response body below. A POST waiting on another server
returns 202 with status and Retry-After: 5.
Retrieval does no new inference and needs no additional credit.
A started answer interrupted before its result is saved becomes interrupted after its five-minute lease expires.
Fryri does not repeat uncertain model work automatically. A repeated POST returns 409 answer_interrupted; a new UUID explicitly starts a separate answer. DELETE /v1/answer/{request_id} erases the retained answer and returns id, deleted, like the other deletes. After deletion or expiry, retrieval returns 404. Reads and deletes require the original API key and
the same end_user_id query parameter, when used on POST.
End-user and account deletion also erase retained answers. Deletion does not guarantee
cancellation of processing already in progress. A content-free deleted receipt remains
until the original expiry: reusing its UUID returns 410 answer_deleted,
so a retry cannot silently start and bill a second answer. Use a new UUID for a separate answer.
Response fields:
| Field | Type | Description |
|---|---|---|
answer | string | The answer, as Markdown text. |
sources[].type | string | text, document or conversation for a memory, or reference for a quoted passage of a reference work. |
sources[].id | string or null | The memory id, for a memory source. |
sources[].title | string or null | The title of the memory, or the citation of a reference. |
sources[].text | string or null | Up to 300 characters of source text or retrieved context. For a numbered citation, these are the delivered source lines. |
sources[].cited | boolean | True when the answer cites verified, delivered source lines. False means retrieved context. |
sources[].citation | integer or null | The number in the answer's [N] marker. Null for retrieved context. |
sources[].location | object or null | Inclusive 1-based line_start and line_end. Conversation citations also have turn_start and turn_end. |
sources[].source_ref | string or null | Your reference for this source, when supplied on add. |
sources[].library | string or null | shared when the source is in your shared library. |
sources[].text_truncated | boolean | True when the cited range exceeds the 300-character quote. Read the memory to continue. |
cost_usd | number | What this call cost, in US dollars. |
sources lists numbered citations first, then retrieved context.
Read cited to distinguish the two. An answer bills what its
model uses, the same as asking in the Fryri app, and reports it in cost_usd.
Request
curl -X POST https://api.fryri.com/v1/answer \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"question": "When is the Atlas launch review, and what does the project use?",
"end_user_id": "user_42"
}' Response
{
"answer": "The **launch review is on 14 October**, and Project Atlas uses **Python and FastAPI**.\n\nThat's from your \"Project Atlas notes.txt\". Given today's date, that review is about three weeks away. The notes...",
"sources": [
{
"type": "text",
"id": "file_NkolfLrElMmM2fCn",
"title": "Project Atlas notes",
"text": "Project Atlas uses Python and FastAPI. The launch review is on 14 October."
}
],
"cost_usd": 0.005918
} Streaming
With stream set to true, the
response is text/event-stream. Each event is one data: line that holds a JSON object with a type:
type | Fields | Meaning |
|---|---|---|
status | text | What the answer is doing right now, such as looking through the library. |
delta | text | The next piece of the answer. |
reset | Discard the text so far. The answer starts again. | |
done | answer, sources, cost_usd | The last event. It carries the same body as a response without stream. |
error | error | The last event when the answer stops early, with code, message and retryable. |
The stream ends with done or error.
The answer in done is final, so
it replaces the joined delta text. Lines that start with : are heartbeats that keep the connection open. A failure
before the first event, such as a missing key, returns the JSON error envelope with its HTTP
status.
Request
curl -N -X POST https://api.fryri.com/v1/answer \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"question": "When is the Atlas launch review?",
"end_user_id": "user_42",
"stream": true
}' Response
data: {"type": "delta", "text": "The Atlas launch review is on **14 October** (per your Project Atlas notes, so 14 October 2026, just under three weeks away)."}
data: {"type": "done", "answer": "The Atlas launch review is on **14 October** (per your Project Atlas notes, so 14 October 2026, just under three weeks away).", "sources": [{"type": "text", "id": "file_NkolfLrElMmM2fCn", "title": "Project Atlas notes", "text": "Project Atlas uses Python and FastAPI. The launch review is on 14 October."}], "cost_usd": 0.005918}End users
end_user_id scopes a call to one of your own users. Each end
user has a private library and their own threads. Calls with the same id only ever reach
that end user, and calls without end_user_id use your own
account. Every end user also reads your shared library.
The id is any stable id you already use for the user, [1, 256] printable characters. Fryri keeps it exactly as sent, so user_42 and User_42 are two different end users. POST /v1/runs, POST /v1/memories, POST /v1/search and POST /v1/answer create the end user the first time they use its id. Reads and deletes never create one, so a mistyped id reads as an empty library.
The ids belong to your account, so every key of the account reaches the same end users. Spend always lands on your account. The developer console lists your end users and what each one spent.
DELETE /v1/end-users/{end_user_id}
DELETE /v1/end-users/{end_user_id} permanently
deletes one end user and everything stored for them: memories, their original files,
threads and runs, and everything learned from them. It takes effect at once and cannot be undone. Your own memories
and your other end users stay as they are.
Erasing the same id again fails with 404 not_found, and the next add, search or answer with the same
id starts a new, empty library. It's recommended to call it from your own account-deletion flow. Deleting is
free.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
end_user_id | string | Yes | The id you passed as end_user_id, in the path. Length [1, 256]. An id with a slash goes in percent-encoded, such as team%2F42. |
Request
curl -X DELETE "https://api.fryri.com/v1/end-users/user_42" \
-H "Authorization: Bearer $FRYRI_API_KEY" Response
{
"deleted": true,
"end_user_id": "user_42"
}Pricing
You pay for what the model and tools use, the same as the Fryri app, from the prepaid credit
of the account. Every run, search and answer reports what it charged in cost_usd, and the developer console shows the same amount for
each call.
| Call | Price |
|---|---|
POST /v1/runs | What the model uses to read the job and write the reply, which grows with the length of the thread and the work. A short run costs about $0.017. Web tools add their own prices: $0.00875 per web search of up to ten results, $0.00125 per extra result, and $0.00125 per web page read. Code bills by the seconds it runs. |
POST /v1/memories | The model cost of reading and indexing the content: about $0.003 for a short note. A long document costs more. |
POST /v1/search | The model cost of reading the query and ranking the matches: a small fraction of a cent. |
POST /v1/answer | What the model uses to read your memories and write the answer. A short answer costs about $0.006. |
GET /v1/memories, GET /v1/memories/{memory_id}, DELETE /v1/memories/{memory_id}, DELETE /v1/end-users/{end_user_id}, GET /v1/answer/{request_id}, DELETE /v1/answer/{request_id}, and every runs, threads, actions and webhooks call except starting a run | Free. |
A newly verified account starts with $1 of credit. Starting a run and every memory, search, answer and end-user call need credit, including the free ones. Top-ups, spend caps per key, usage and the request log are in the developer console.
Errors
Every error returns one JSON envelope with its HTTP status. error.code is a stable machine code to branch on, and error.message is prose that may change. error.retryable says whether the same call, sent again
unchanged, can succeed. It's recommended to read it instead of inferring from the status. request_id matches the X-Request-ID header.
Some errors carry extra fields: use on a 410, errors (every field
problem) on a 422, and key_monthly_budget_usd with key_spend_this_month_usd on key_budget_reached.
| HTTP | code | retryable | Recovery |
|---|---|---|---|
4xx | |||
401 | unauthorized | false | Send a live key as Authorization: Bearer $FRYRI_API_KEY. |
401 | sandbox_discontinued | false | Create a live key in the developer console. Sandbox keys no longer work. |
401 | account_deleted | false | Log in to restore the account. Its keys then work again. |
402 | insufficient_credits | false | Add credit in the developer console, then retry. When auto-recharge is refilling the account, retryable is true: retry after the Retry-After seconds. |
404 | not_found | false | Check the id, and send the end_user_id the memory belongs to. |
404 | request_not_found | false | Send the request_id of an event from this run. |
405 | method_not_allowed | false | Use the method that error.message names. |
409 | conflict | false | Read the current state, then resend. |
409 | idempotency_key_reused | false | Send a new Idempotency-Key for a new request. |
409 | idempotency_request_pending | true | Wait for Retry-After, then retry with the same key and body. |
409 | thread_busy | true | Another run in the thread is working. Wait for it to settle, or stop it; run_id names it. |
409 | request_id_conflict | false | The request_id of a run was used for a different run. Send a new one. |
409 | request_answered | false | The request already has a different answer. Read the run. |
409 | request_expired, request_cancelled, request_closed, run_not_waiting | false | The request can no longer be answered: it expired, or the run stopped or moved on. Read the run. |
409 | memory_format_conflict | false | Identical conversation bytes already exist in another format. Remove that source explicitly before importing them as a conversation. |
410 | endpoint_retired | false | Call the endpoint in error.use. See Retired endpoints. |
413 | file_too_large | false | Send a smaller file. error.message names the limit. |
413 | request_too_large | false | Send a smaller request body. error.message names the limit. |
422 | invalid_request | false | Fix the field named in error.message ({field}: {reason}), or remove a field the call does not define. error.errors lists every problem. |
422 | wrong_answer_kind | false | Answer with the field of the request: result or error, decision, or answer. |
422 | answer_invalid, answer_not_a_choice, decision_invalid, result_too_large | false | Fix the answer as error.message says. |
422 | unsupported_format | false | Add text or a document. Photos, video and audio can't be added. |
422 | invalid_cursor | false | Read the memory again without cursor. |
429 | rate_limited | true | Wait for the Retry-After header, then retry. |
429 | too_many_runs | true | 100 runs are already working for the account. Wait for one to settle. |
429 | key_budget_reached | false | Raise or clear the spend cap of the key in the developer console, or use another key. |
5xx | |||
500 | internal_error | true | Retry once. Quote request_id if it persists. |
502 | upstream_error | true | Retry. |
502 | ingest_failed | true | Retry the add with the same Idempotency-Key. |
503 | unavailable | true | Fryri is busy. Retry after a few seconds. |
503 | idempotency_unavailable | true | Retry with the same key and body once the request receipt is available. |
An add that the upload pipeline refuses carries that pipeline's own code, HTTP status and retryable,
such as storage_quota_exceeded (413)
or upload_rate_limit (429, with Retry-After). A 5xx error
carries the same envelope, with retryable set to true.
Missing key
curl -X POST https://api.fryri.com/v1/search \
-H "Content-Type: application/json" \
-d '{
"query": "x"
}' Response 401
{
"error": {
"code": "unauthorized",
"message": "Invalid or missing API key. Pass it as 'Authorization: Bearer fryri_sk_...'.",
"retryable": false
},
"request_id": "b4de2b67719241b4b319d6f8fc7cb0d4"
} Unknown memory id
curl "https://api.fryri.com/v1/memories/file_AAAAAAAAAAAAAAAA" \
-H "Authorization: Bearer $FRYRI_API_KEY" Response 404
{
"error": {
"code": "not_found",
"message": "No memory with that id in this library.",
"retryable": false
},
"request_id": "b8bc43a847964c60a8c05612506be894"
} Unknown field
curl -X POST https://api.fryri.com/v1/memories \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Project Atlas uses Python and FastAPI.",
"enduser_id": "user_42"
}' Response 422
{
"error": {
"code": "invalid_request",
"message": "enduser_id: Extra inputs are not permitted",
"retryable": false,
"errors": [
{
"type": "extra_forbidden",
"loc": [
"body",
"enduser_id"
],
"msg": "Extra inputs are not permitted",
"input": "user_42",
"url": "https://errors.pydantic.dev/2.10/v/extra_forbidden"
}
]
},
"request_id": "92fa7a87447044adafc2b36aa8a50203"
}Retired endpoints
On 25 September 2026 the API moved to the calls on this page. A path from the earlier
API fails with 410 endpoint_retired, and error.use names the call to make instead, when one exists.
| Old path | Now | Call instead |
|---|---|---|
POST /v1/capture | 410 | POST /v1/memories. file.data, file.name and file.type replace content_base64, filename and content_type. |
POST /v1/capture/batch | 410 | POST /v1/memories, once per item. |
POST /v1/chat | 410 | POST /v1/answer. The answer text moves from reply to answer. |
GET /v1/search | 405 | POST /v1/search, with query in the JSON body in place of q. Results carry type and text in place of kind and snippet. |
/v1/documents | 410 | GET /v1/memories and GET /v1/memories/{memory_id}. |
/v1/library/export | 410 | GET /v1/memories. |
/v1/grep, /v1/graph, /v1/entities | 410 | POST /v1/search. |
/v1/facts | 410 | POST /v1/memories to add, POST /v1/search to find. |
GET /v1/end-users | 410 | The developer console shows the list of end users. DELETE /v1/end-users/{end_user_id} stays. |
/v1/usage, /v1/wallet | 410 | The developer console. |
/v1/tools, /v1/byo-key, /v1/storage, /v1/research, /v1/conversations, /v1/calendar, /v1/evolving | 410 | No replacement. These left the public API. |
Retired path
curl -X POST https://api.fryri.com/v1/capture \
-H "Authorization: Bearer $FRYRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "x"
}' Response 410
{
"error": {
"code": "endpoint_retired",
"message": "`POST /v1/capture` was retired on 2026-09-25. Use POST /v1/memories.",
"retryable": false,
"use": "POST /v1/memories"
},
"request_id": "f13dd44eafa741f4953f93df4e9410a4"
} Old search method
curl "https://api.fryri.com/v1/search" \
-H "Authorization: Bearer $FRYRI_API_KEY" Response 405
{
"error": {
"code": "method_not_allowed",
"message": "`GET /v1/search` is not supported. Use POST.",
"retryable": false
},
"request_id": "913f828f5e7d4c8699222e09845a89be"
} Removed on 26 September 2026
On 26 September 2026 the API narrowed to text and documents. A request that still uses one of these parts fails as follows:
| Removed | Now | Instead |
|---|---|---|
url on POST /v1/memories | 422 invalid_request | Fetch the page in your own code and add its text, with its address as source_ref. See Add a web page. |
Photos, video and audio on POST /v1/memories | 422 unsupported_format | Add text or a document. See Supported files. |
web_search on POST /v1/answer | 422 invalid_request | Leave the field out. An answer draws only on memories. For an answer that can search the web, start a run. |
The capture.completed and capture.failed webhooks | No deliveries | Read the memory with GET /v1/memories/{memory_id} until status is ready or failed. |
POST /mcp | 410 endpoint_retired | Call the /v1 routes over HTTP. |