# Ainoflow Documentation (/docs) Ainoflow provides two integration interfaces: a standard REST API for automation platforms and direct integrations, and an MCP Protocol for AI agent workflows. Interfaces [#interfaces] HTTP endpoints for automation workflows, backend integrations, and direct API calls. Model Context Protocol for AI agent integrations - Claude Desktop, Cursor, OpenAI. Services [#services] | Service | Description | | -------------- | ---------------------------------------------------------------- | | **Shield** | Idempotency keys and execution protection | | **Guard** | Rate limiting and cost control | | **Convert** | Document conversion and OCR processing | | **Data store** | JSON key-value storage | | **Files** | Binary file storage (up to 100 MB) | | **Memory** | Markdown documents with revisions, wiki-links, and ranked recall | | **Inbox** | Email receiving and processing | | **Account** | Read-only access to the caller's scopes, projects, and API keys | Authentication [#authentication] All API endpoints require a Bearer token: ```bash curl -H "Authorization: Bearer YOUR_API_KEY" \ https://api.ainoflow.io/api/v1/files/documents ``` Get an API key [#get-an-api-key] **Existing account:** Sign in at [ainoflow.io](https://www.ainoflow.io), choose **Open App**, select the project, open **Keys**, and choose **Issue key**. **First-time account:** [Create an account](https://www.ainoflow.io/signup), then choose **Open App**. A brand-new session can also provision its first scope, project, and Client API key through [Account API self-onboarding](/docs/api/account#self-onboarding). After a project exists, additional keys are issued from **Keys → Issue key**. Store the key in your agent or runtime environment, or in a secret manager. Do **not** paste long-lived keys into an AI chat. The plaintext is shown only once when the key is issued. # Account (/docs/api/account) The Account API is your self-service surface. An authenticated user or integration can discover the scopes, projects, and API keys it has access to, **and** manage its own projects and per-project API keys — strictly within its own tenant. It is used by dashboards, CLIs, and agent integrations. A brand-new user can also **self-onboard** here: a freshly authenticated session with no tenant yet provisions its first scope, project, and API key in a single call, with no manual setup. **Base URL** ``` https://api.ainoflow.io ``` All endpoints require `Authorization: Bearer YOUR_API_KEY` header. A user JWT from the environment's trusted identity provider (Supabase in production) is also accepted for dashboard and onboarding integrations. (Two endpoints differ: self-onboarding accepts a brand-new user's JWT before any tenant exists, and onboarding-code redemption is anonymous — see [Self-Onboarding](#self-onboarding).) Access Rules [#access-rules] Reads are strictly filtered to what the caller has been granted, and writes are confined to the caller's own grants. A caller can never widen its view or reach another tenant by passing a `scopeId` or `projectId` it has no grant to. | Caller | Scopes visible | Projects visible | API keys visible | | ----------------- | --------------- | ----------------------------------- | ------------------------------- | | System API key | All | All | All | | Admin API key | Own scope | All projects in own scope | All keys in own scope | | Client API key | Own scope | Own project only | **Only the calling key itself** | | JWT — Admin role | Assigned scopes | All projects inside assigned scopes | Keys in assigned scopes | | JWT — Client role | Assigned scopes | Explicitly granted projects | Keys in granted projects | **Write operations** (creating projects, issuing/rotating/revoking API keys) target a scope or project supplied by the caller, which is validated against the caller's own grants. Anything outside those grants is rejected: * `GET /.../{id}` for a resource outside the caller's grants returns `404 Not Found` (existence is not revealed across tenants). * List filters (`?scopeId=…`, `?projectId=…`) and write targets that reference resources outside the caller's grants return `403 Forbidden`. * Unauthenticated requests return `401 Unauthorized`. This surface manages **your own** projects and Client API keys. Creating new tenants/scopes and minting Admin or System keys are separate, privileged operations and are not part of the Account API. *** Scopes [#scopes] List Scopes [#list-scopes] **GET** /api/v1/account/scopes Lists scopes the caller has access to. System sees all; Admin/Client see their assigned scopes only. **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `isActive` | boolean | No | Filter by active status | | `page` | integer | No | Page number (1-based); must be 1 or greater. Mutually exclusive with `offset`. | | `offset` | integer | No | Items to skip; must be 0 or greater. Mutually exclusive with `page`. | | `limit` | integer | No | Items per page (0–5000, default: 100). `limit=0` answers an empty page carrying the real `totalCount` — the count without the rows. | | `aggregate` | boolean | No | If `true`, returns object with pagination metadata (default: `false`) | **Pagination is refused when it is out of range, never adjusted** — on this and every other list endpoint here. A `limit` below 0 or above 5000, a `page` below 1, or a negative `offset` answers `400 Bad Request`; nothing is clamped to the maximum and nothing falls back to the default, so a request for 10000 rows is told so rather than handed 5000 it would read as a complete page. `limit=0` is the one value outside 1–5000 that is accepted, because it is a capability rather than a mistake: it answers an empty page carrying the real `totalCount`, which is how you ask how many rows there are without reading them. **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/account/scopes?aggregate=true" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/account/scopes", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"aggregate": "true"}, ) scopes = response.json()["items"] ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/account/scopes?aggregate=true", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const { items } = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/account/scopes?aggregate=true"); var json = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "items": [ { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "Acme Corp", "userId": "9d41c2b1-2cba-4fde-a0a2-7f4ab19fd8dd", "isActive": true, "createdAt": "2026-02-01T10:00:00Z" } ], "totalCount": 1, "page": 1, "pageSize": 100, "totalPages": 1 } ``` **Headers:** `X-Total-Count`, `X-Page`, `X-Limit`, `X-Total-Pages` **Error Responses** | Status | Description | | ------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Both `page` and `offset` provided, or a pagination value outside its range (`limit` outside 0–5000, `page` below 1, `offset` below 0) | | `401` | Missing or invalid authentication | *** Get Scope [#get-scope] **GET** /api/v1/account/scopes/{id} Returns a single scope by id. Returns `404` when the scope does not exist **or** the caller has no grant to it — existence is hidden across tenants. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------- | | `id` | string (uuid) | Yes | Scope identifier | **Response** `200 OK` ```json { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "Acme Corp", "userId": "9d41c2b1-2cba-4fde-a0a2-7f4ab19fd8dd", "isActive": true, "createdAt": "2026-02-01T10:00:00Z" } ``` **Error Responses** | Status | Description | | ------ | -------------------------------------------------- | | `401` | Missing or invalid authentication | | `404` | Scope does not exist, or caller has no grant to it | *** Projects [#projects] List Projects [#list-projects] **GET** /api/v1/account/projects Lists projects visible to the caller within a specified scope. **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `scopeId` | string (uuid) | **Yes for JWT**, optional for API key | Target scope. API keys always operate on the baked-in scope — a mismatching `scopeId` is rejected. | | `isActive` | boolean | No | Filter by active status | | `page` | integer | No | Page number (1-based); must be 1 or greater. Mutually exclusive with `offset`. | | `offset` | integer | No | Items to skip; must be 0 or greater. Mutually exclusive with `page`. | | `limit` | integer | No | Items per page (0–5000, default: 100). `limit=0` answers an empty page carrying the real `totalCount` — the count without the rows. | | `aggregate` | boolean | No | Aggregated response mode (default: `false`) | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/account/projects?scopeId=3fa85f64-5717-4562-b3fc-2c963f66afa6&aggregate=true" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/account/projects", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "aggregate": "true"}, ) projects = response.json()["items"] ``` ```javascript const scopeId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/account/projects?scopeId=${scopeId}&aggregate=true`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const { items } = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/account/projects?scopeId=3fa85f64-5717-4562-b3fc-2c963f66afa6&aggregate=true"); var json = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "items": [ { "id": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "name": "Invoices", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "scopeName": "Acme Corp", "apiKeyCount": 3, "isActive": true, "createdAt": "2026-02-05T09:15:00Z" } ], "totalCount": 1, "page": 1, "pageSize": 100, "totalPages": 1 } ``` **Error Responses** | Status | Description | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `scopeId` missing for JWT caller, or invalid pagination — `page` and `offset` together, or a value outside its range (`limit` outside 0–5000, `page` below 1, `offset` below 0) | | `401` | Missing or invalid authentication | | `403` | `scopeId` is outside the caller's grants | *** Get Project [#get-project] **GET** /api/v1/account/projects/{id} Returns a single project by id. Returns `404` when the project does not exist or the caller has no grant to its scope/project. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ------------------ | | `id` | string (uuid) | Yes | Project identifier | **Response** `200 OK` ```json { "id": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "name": "Invoices", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "scopeName": "Acme Corp", "userId": "9d41c2b1-2cba-4fde-a0a2-7f4ab19fd8dd", "apiKeyCount": 3, "isActive": true, "createdAt": "2026-02-05T09:15:00Z" } ``` **Error Responses** | Status | Description | | ------ | ---------------------------------------------------- | | `401` | Missing or invalid authentication | | `404` | Project does not exist, or caller has no grant to it | *** Create Project [#create-project] **POST** /api/v1/account/projects Creates a project in a scope you have access to. The target scope is supplied in the body and validated against your own scope grants — a scope you do not own is rejected with `403`, so a project can never be created outside your tenant. A JWT caller must supply `scopeId`. An API-key caller may omit it (the key's own scope is used) or, if supplied, it must match the key's scope. A JWT user receives access to the project they create and can immediately read it and manage its keys. This does not grant access to other projects or scopes. **Body** | Field | Type | Required | Description | | --------- | ------------- | ------------------------------------- | ------------------------------------------------- | | `name` | string | Yes | Project name | | `scopeId` | string (uuid) | **Yes for JWT**, optional for API key | Target scope. Must be one of your granted scopes. | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/account/projects" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Invoices", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"}' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/account/projects", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={"name": "Invoices", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"}, ) project = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/account/projects", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ name: "Invoices", scopeId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", }), } ); const project = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var body = new StringContent( """{"name":"Invoices","scopeId":"3fa85f64-5717-4562-b3fc-2c963f66afa6"}""", Encoding.UTF8, "application/json"); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/account/projects", body); var json = await response.Content.ReadAsStringAsync(); ``` **Response** `201 Created` Includes a `Location: /api/v1/account/projects/{id}` header. ```json { "id": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "name": "Invoices", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "scopeName": "Acme Corp", "createdAt": "2026-02-05T09:15:00Z", "isActive": true } ``` **Error Responses** | Status | Description | | ------ | ----------------------------------------------------- | | `400` | `name` missing, or `scopeId` missing for a JWT caller | | `401` | Missing or invalid authentication | | `403` | `scopeId` is outside the caller's grants | *** Update Project [#update-project] **PUT** /api/v1/account/projects/{id} Renames a project and/or toggles its active status. Both body fields are optional. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ------------------ | | `id` | string (uuid) | Yes | Project identifier | **Body** | Field | Type | Required | Description | | ---------- | ------- | -------- | ----------------- | | `name` | string | No | New project name | | `isActive` | boolean | No | New active status | **Code Samples** ```bash curl -X PUT "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Invoices 2026", "isActive": true}' ``` ```python import requests response = requests.put( "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={"name": "Invoices 2026", "isActive": True}, ) project = response.json() ``` **Response** `200 OK` ```json { "id": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "name": "Invoices 2026", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "scopeName": "Acme Corp", "createdAt": "2026-02-05T09:15:00Z", "isActive": true } ``` **Error Responses** | Status | Description | | ------ | -------------------------------------- | | `401` | Missing or invalid authentication | | `403` | Project is outside the caller's grants | | `404` | Project does not exist | *** Delete Project [#delete-project] **DELETE** /api/v1/account/projects/{id} Deletes a project. By default the request fails if the project still has API keys linked to it; pass `force=true` to delete it together with its linked keys. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ------------------ | | `id` | string (uuid) | Yes | Project identifier | **Query Parameters** | Parameter | Type | Required | Description | | --------- | ------- | -------- | ------------------------------------------------------ | | `force` | boolean | No | Delete even when API keys are linked (default `false`) | **Code Samples** ```bash curl -X DELETE "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a?force=true" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.delete( "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"force": "true"}, ) result = response.json() ``` **Response** `200 OK` ```json { "id": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "success": true } ``` **Error Responses** | Status | Description | | ------ | --------------------------------------------------- | | `400` | Project has linked API keys and `force` was not set | | `401` | Missing or invalid authentication | | `403` | Project is outside the caller's grants | | `404` | Project does not exist | *** Get Project Home [#get-project-home] **GET** /api/v1/account/projects/{id}/home Returns the project's live content inventory and five most recently updated items in each area, across every category and inbox. It does not depend on request history. Only metadata is returned: no document values, markdown, message bodies, file content or download URLs. Deleted and expired content is excluded. Messages in a disabled inbox still count while stored. Intake distinguishes configured inboxes, enabled inboxes, stored messages and unhandled messages; these are live counts, not billing-cycle received volume. Empty areas have zero totals and empty arrays. Recent memory, data and file items are ordered by update time descending, then category and key ascending. Intake is ordered by arrival time descending, then message identifier descending. An item appears once with its latest saved state; this is not a complete edit history. `readAt` is the read's reference time, not a frozen snapshot. `id` is the project UUID. Authentication is required; an absent or inaccessible project returns `404`. ```bash curl "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a/home" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a/home", headers={"Authorization": "Bearer YOUR_API_KEY"}, ) response.raise_for_status() project_home = response.json() ``` ```javascript const projectId = "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a"; const response = await fetch( `https://api.ainoflow.io/api/v1/account/projects/${projectId}/home`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); if (!response.ok) throw new Error(`Request failed (${response.status})`); const projectHome = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync("https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a/home"); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "readAt": "2026-10-10T09:45:00Z", "memory": { "count": 1, "recentItems": [ { "category": "main", "key": "runbook", "title": "Service runbook", "updatedAt": "2026-10-10T09:40:00Z", "sizeBytes": 820 } ] }, "storage": { "count": 1, "recentItems": [ { "category": "config", "key": "app", "updatedAt": "2026-10-10T09:35:00Z", "sizeBytes": 120 } ] }, "files": { "count": 1, "bytes": 1200, "recentItems": [ { "category": "reports", "key": "latest", "fileName": "report.txt", "updatedAt": "2026-10-10T09:30:00Z", "sizeBytes": 1200 } ] }, "intake": { "inboxCount": 1, "enabledInboxCount": 1, "storedMessageCount": 1, "unhandledMessageCount": 1, "recentMessages": [ { "inboxId": "a187c811-6297-4d2f-8fbe-39dcd5366db1", "messageId": "b298d922-7308-4e30-90cf-40ece6477ec2", "inboxName": "Support", "subject": "Report received", "fromAddress": "sender@example.com", "receivedAt": "2026-10-10T09:25:00Z", "isHandled": false } ] } } ``` | Field | Description | | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `readAt` | UTC reference time used to decide whether content has expired. | | `memory.count` / `storage.count` | Number of live documents or JSON records. | | `files.count` / `files.bytes` | Live file count and total size in bytes. | | `recentItems` | Up to five live items for that area, with `category`, `key`, `updatedAt` and `sizeBytes`. | | `memory.recentItems[].title` | Document title, or `null`; use the key when no title is present. | | `files.recentItems[].fileName` | Original file name; use the key when it is empty. | | `intake.inboxCount` / `enabledInboxCount` | All configured inboxes and the enabled subset. | | `intake.storedMessageCount` / `unhandledMessageCount` | Live stored messages and the unhandled subset. | | `intake.recentMessages` | Up to five live arrivals, with inbox/message identifiers, inbox name, subject, sender, received time and handled state. | *** Get Project Activity [#get-project-activity] **GET** /api/v1/account/projects/{id}/activity Returns the project's request activity over the last 24 hours, its busiest endpoints, and existing metered volume fields. Returns `404` when the project does not exist or the caller has no grant to its scope/project. A request belongs to a project through the API key that made it, recorded when the request is made. It stays the project's whatever happens to the key afterwards — revoked, expired and deleted keys included, since their requests happened — while requests made from a signed-in dashboard session are never counted as the project's traffic. Requests appear here shortly after they are answered, not instantly. A request counts as failed by what it ended as, not only by its HTTP status. An MCP tool call answers HTTP `200` even when the tool refuses it (`isError: true`) or the call is a JSON-RPC error; those count as failed — or as limited, when the tool failure is `RATE_LIMITED` — exactly as the same refusal over REST does. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ------------------ | | `id` | string (uuid) | Yes | Project identifier | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a/activity" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a/activity", headers={"Authorization": "Bearer YOUR_API_KEY"}, ) activity = response.json() ``` ```javascript const projectId = "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a"; const response = await fetch( `https://api.ainoflow.io/api/v1/account/projects/${projectId}/activity`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const activity = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a/activity"); var json = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "hasTraffic": true, "windowHours": 24, "calls": 1250, "failed": 12, "limited": 3, "errorRate": 0.0096, "topEndpoints": [ { "method": "PUT", "route": "api/v1/storage/json/{category}/{key}", "calls": 820, "failed": 10 }, { "method": "GET", "route": "api/v1/memory/{category}/{key}", "calls": 430, "failed": 2 } ], "storedVolume": { "storage": { "count": 5400 }, "memory": { "count": 312 }, "files": { "count": 48, "bytes": 73400320 }, "inbox": { "count": 920, "bytes": 1843200 } } } ``` | Field | Description | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hasTraffic` | Whether retained request history exists for the project. False does not mean there is no saved content or that the project has never been used. | | `windowHours` | The window `calls`, `failed`, `limited` and `topEndpoints` cover, in hours. | | `calls` | Requests made with the project's keys in the window. | | `failed` | Requests answered `4xx` or `5xx`, or refused over MCP (a tool failure or a JSON-RPC error, both answered `200`), excluding those refused because an allowance was exhausted. | | `limited` | Requests refused because an allowance was exhausted (failure code `RATE_LIMITED`). A limit, not a fault — counted here and not in `failed`. | | `errorRate` | `failed / calls`, from 0 to 1. `null` when there were no calls in the window. | | `topEndpoints` | Up to 5 busiest endpoints in the window, most called first. `route` is the route template, so calls to different keys group under one endpoint. | | `storedVolume` | Existing metered volume: storage and memory counts, file count/bytes, and inbox received count/bytes for the current billing cycle. Inbox values reset with that cycle and do not measure stored backlog. Use `/home` for live inventory. | **Error Responses** | Status | Description | | ------ | ---------------------------------------------------- | | `401` | Missing or invalid authentication | | `404` | Project does not exist, or caller has no grant to it | *** List Project Requests [#list-project-requests] **GET** /api/v1/account/projects/{id}/activity/requests Lists the most recent requests made with the project's API keys, newest first, including requests made with keys that have since been deleted. A refused request carries the same failure code and title its caller received, so you can see why a call failed without reproducing it. Returns `404` when the project does not exist or the caller has no grant to its scope/project. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ------------------ | | `id` | string (uuid) | Yes | Project identifier | **Query Parameters** | Parameter | Type | Required | Description | | --------- | ------- | -------- | --------------------------------------------------------------------- | | `limit` | integer | No | Page size, 1-100, default 100. Values outside the range return `400`. | | `cursor` | string | No | Opaque continuation returned as `nextCursor` by the preceding page. | Pass the returned `nextCursor` unchanged with the same `limit` to load older requests. `nextCursor: null` marks the end of the retained history. Omitting `cursor` refreshes from the latest request. Rows are ordered by request time and unique identifier, newest first. Keep identifiers as strings rather than converting them to JavaScript numbers. This endpoint uses cursor continuation rather than page/offset pagination. It returns no total count or total-page count. Newer arrivals do not shift older pages; delayed recording may add a row to an older page or to a portion already visited, which is visible after refresh. Retention cleanup can remove rows. The history is neither a frozen snapshot nor a complete lifetime record. The cursor is bound to the project, scope, query and page size. Access is checked again for every page. Malformed, unsupported or mismatched cursors and incompatible pagination parameters return `400`; an inaccessible project still returns `404` without exposing cursor details. **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a/activity/requests?limit=20" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a/activity/requests", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"limit": 20}, ) requests_made = response.json()["items"] ``` ```javascript const projectId = "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a"; const response = await fetch( `https://api.ainoflow.io/api/v1/account/projects/${projectId}/activity/requests?limit=20`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const { items } = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/account/projects/c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a/activity/requests?limit=20"); var json = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "items": [ { "id": "1042", "timestamp": "2026-10-01T09:42:17Z", "method": "PUT", "endpoint": "/api/v1/storage/json/orders/order-1042", "route": "api/v1/storage/json/{category}/{key}", "statusCode": 413, "durationMs": 14, "kid": "kid_01HXXXXX", "keyName": "Production Client", "failureCode": "ITEM_TOO_LARGE", "failureTitle": "JSON data is 1.2 MB and exceeds the 1 MB size limit" }, { "id": "1041", "timestamp": "2026-10-01T09:41:58Z", "method": "GET", "endpoint": "/api/v1/storage/json/orders/order-1041", "route": "api/v1/storage/json/{category}/{key}", "statusCode": 200, "durationMs": 9, "kid": "kid_01HXXXXX", "keyName": "Production Client", "failureCode": null, "failureTitle": null } ], "nextCursor": null } ``` | Field | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `id` | Stable request identifier, encoded as a string. | | `timestamp` | When the request arrived (UTC), preserved through delayed recording. | | `method` / `endpoint` | HTTP method and the path as called. | | `route` | Matched route template, or `null` when no route matched. | | `statusCode` | HTTP status the caller received. MCP logical refusals and JSON-RPC errors may use HTTP `200`, so also inspect `failureCode` and `failureTitle`; transport errors retain their HTTP status. | | `durationMs` | Time the server took, in milliseconds. | | `kid` / `keyName` | The API key that made the request, and its name. `keyName` is `null` when the key has since been deleted; `kid` still identifies it. | | `failureCode` | The machine-readable `code` the caller received — on the REST error response, or in the MCP tool failure (for example `RATE_LIMITED`, `ITEM_TOO_LARGE`). `null` for a success and for a failure without a shared code, such as a JSON-RPC error. | | `failureTitle` | The error title the caller was told — for an MCP tool failure or JSON-RPC error, its message — or `null`. | **Error Responses** | Status | Description | | ------ | ----------------------------------------------------- | | `400` | Invalid limit/cursor, or unsupported query parameters | | `401` | Missing or invalid authentication | | `404` | Project does not exist, or caller has no grant to it | *** API Keys [#api-keys] In the App, existing-account keys are issued from the selected project: **Open App → Project → Keys → Issue key**. Store the plaintext in an environment or secret manager; it is shown only once. Do not paste long-lived keys into an AI chat. List API Keys [#list-api-keys] **GET** /api/v1/account/api-keys Lists API keys visible to the caller. Response never includes the secret itself or its hash — only public metadata like `kid`, `keyPrefix`, `keySuffix`, hash-scheme version numbers (`argon2Version`, `fastVersion`), role, status, and usage counters. **Visibility summary:** * **System** — all keys in the requested scope/project * **Admin** — all keys in the caller's scope (useful for team / tenant-wide audit) * **Client API key** — only the calling key itself (peer keys are not listed, even in the same project) * **Client JWT** — all keys in projects the user has been explicitly granted **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `scopeId` | string (uuid) | **Yes for JWT**, optional for API key | Target scope. Mismatching values are rejected with `403`. | | `projectId` | string (uuid) | No | Narrow to a specific project. Mismatching values are rejected with `403`. | | `kid` | string | No | Exact-match filter by key identifier | | `isActive` | boolean | No | Filter by active status | | `page` | integer | No | Page number (1-based); must be 1 or greater. Mutually exclusive with `offset`. | | `offset` | integer | No | Items to skip; must be 0 or greater. Mutually exclusive with `page`. | | `limit` | integer | No | Items per page (0–5000, default: 100). `limit=0` answers an empty page carrying the real `totalCount` — the count without the rows. | | `aggregate` | boolean | No | Aggregated response mode | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/account/api-keys?scopeId=3fa85f64-5717-4562-b3fc-2c963f66afa6&aggregate=true" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/account/api-keys", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "aggregate": "true"}, ) keys = response.json()["items"] ``` ```javascript const scopeId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/account/api-keys?scopeId=${scopeId}&aggregate=true`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const { items } = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/account/api-keys?scopeId=3fa85f64-5717-4562-b3fc-2c963f66afa6&aggregate=true"); var json = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "items": [ { "id": 42, "name": "Production Client", "role": "Client", "createdAt": "2026-02-05T09:20:00Z", "expiresAt": null, "createdBy": "admin@acme.com", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "projectId": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "projectName": "Invoices", "userId": "9d41c2b1-2cba-4fde-a0a2-7f4ab19fd8dd", "kid": "kid_01HXXXXX", "keyPrefix": "akv1.abc", "keySuffix": "xyz", "argon2Version": 1, "fastVersion": 1, "status": "Active", "active": true, "revokedAt": null, "totalCalls": 12345, "lastUsedAt": "2026-04-10T08:12:00Z", "summaryUpdatedAt": "2026-04-10T08:15:00Z" } ], "totalCount": 1, "page": 1, "pageSize": 100, "totalPages": 1 } ``` **Error Responses** | Status | Description | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `scopeId` missing for JWT caller, or invalid pagination — `page` and `offset` together, or a value outside its range (`limit` outside 0–5000, `page` below 1, `offset` below 0) | | `401` | Missing or invalid authentication | | `403` | `scopeId` or `projectId` is outside the caller's grants | *** Issue API Key [#issue-api-key] **POST** /api/v1/account/api-keys Issues a new API key in one of your projects. The key is always a **Client** key — the role cannot be chosen — and the target project must belong to one of your scopes. The plaintext key is returned **once** in the `apiKey` field; store it securely. Cryptographic hashes are never returned. **Body** | Field | Type | Required | Description | | ----------- | ------------------ | -------- | ---------------------------------------------------------- | | `projectId` | string (uuid) | Yes | Project the key is bound to. Must be one of your projects. | | `name` | string | Yes | Human-readable key name | | `expiresAt` | string (date-time) | No | Optional expiry. Omit for a non-expiring key. | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/account/api-keys" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "name": "CI key", "expiresAt": "2026-12-31T00:00:00Z"}' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/account/api-keys", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={ "projectId": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "name": "CI key", "expiresAt": "2026-12-31T00:00:00Z", }, ) # Store response.json()["apiKey"] now — it is never shown again. new_key = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/account/api-keys", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ projectId: "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", name: "CI key", expiresAt: "2026-12-31T00:00:00Z", }), } ); // Store result.apiKey now — it is never shown again. const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var body = new StringContent( """{"projectId":"c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a","name":"CI key","expiresAt":"2026-12-31T00:00:00Z"}""", Encoding.UTF8, "application/json"); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/account/api-keys", body); // Read "apiKey" from the response now — it is never shown again. var json = await response.Content.ReadAsStringAsync(); ``` **Response** `201 Created` Includes a `Location: /api/v1/account/api-keys/{id}` header. ```json { "id": 42, "name": "CI key", "role": "Client", "expiresAt": "2026-12-31T00:00:00Z", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "projectId": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "apiKey": "akv1.xxxxxxxxxxxxxxxxxxxxxxxx", "kid": "kid_01HXXXXX", "keyPrefix": "akv1.abc", "keySuffix": "xyz", "message": "API key generated successfully. Store it securely, it will not be shown again." } ``` The `apiKey` value is shown **only once**. There is no way to retrieve it later — if you lose it, rotate the key to get a new one. **Error Responses** | Status | Description | | ------ | -------------------------------------- | | `400` | `name` or `projectId` missing | | `401` | Missing or invalid authentication | | `403` | Project is outside the caller's grants | *** Rotate API Key [#rotate-api-key] **POST** /api/v1/account/api-keys/{id}/rotate Rotates an existing key: mints a fresh Client key in the same project and deactivates the old one. The old secret stops working immediately. The new plaintext key is returned **once**. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------- | -------- | ------------------------------- | | `id` | integer | Yes | Identifier of the key to rotate | **Body** | Field | Type | Required | Description | | ------ | ------ | -------- | --------------------------------------------------------------- | | `name` | string | No | Name for the new key. The existing name is reused when omitted. | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/account/api-keys/42/rotate" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "CI key (rotated)"}' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/account/api-keys/42/rotate", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={"name": "CI key (rotated)"}, ) # Store response.json()["apiKey"] now — it is never shown again. rotated = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/account/api-keys/42/rotate", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ name: "CI key (rotated)" }), } ); // Store result.apiKey now — it is never shown again. const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var body = new StringContent( """{"name":"CI key (rotated)"}""", Encoding.UTF8, "application/json"); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/account/api-keys/42/rotate", body); var json = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "success": true, "id": 57, "previousId": 42, "name": "CI key (rotated)", "role": "Client", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "projectId": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "expiresAt": null, "apiKey": "akv1.yyyyyyyyyyyyyyyyyyyyyyyy", "kid": "kid_01HYYYYY", "keyPrefix": "akv1.def", "keySuffix": "uvw", "message": "API key rotated successfully. Store the new key securely, it will not be shown again." } ``` `id` is the new key and `previousId` is the now-deactivated one. **Error Responses** | Status | Description | | ------ | ---------------------------------- | | `401` | Missing or invalid authentication | | `403` | Key is outside the caller's grants | | `404` | Key does not exist | *** Revoke API Key [#revoke-api-key] **DELETE** /api/v1/account/api-keys/{id} Revokes (deactivates) a key. It stops authenticating immediately. This is a soft deactivation; the key record remains for audit. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------- | -------- | ------------------------------- | | `id` | integer | Yes | Identifier of the key to revoke | **Code Samples** ```bash curl -X DELETE "https://api.ainoflow.io/api/v1/account/api-keys/42" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.delete( "https://api.ainoflow.io/api/v1/account/api-keys/42", headers={"Authorization": "Bearer YOUR_API_KEY"}, ) result = response.json() ``` **Response** `200 OK` ```json { "id": 42, "kid": "kid_01HXXXXX", "success": true } ``` **Error Responses** | Status | Description | | ------ | ---------------------------------- | | `401` | Missing or invalid authentication | | `403` | Key is outside the caller's grants | | `404` | Key does not exist | *** Self-Onboarding [#self-onboarding] These endpoints power the self-install flow: a brand-new user turns a fresh login into a working tenant in one call, and an agent or CLI can provision its own key from a short-lived code without ever handling the user's session token. This is the programmatic first-tenant path. After a project exists, issue additional keys in the App from **Keys → Issue key**, or with the API key endpoints above. Onboard [#onboard] **POST** /api/v1/account/onboard New scopes default to **20,000 Shield lock acquisitions and 20,000 Guard checks per billing month**, shared across their projects. Existing scopes keep their configured allowances. Read `GET /api/v1/capabilities` for your effective limits. Provisions your first tenant. For a freshly authenticated user (a valid user JWT from the environment's trusted identity provider) who does not have a tenant yet, this creates — atomically — your first scope, a default project, and a first Client API key. This endpoint accepts a brand-new user's session before any tenant exists, so **no scope/project context is required**. Because the session starts with no grants, onboarding can only ever create a new tenant for you. **Body** *(all fields optional)* | Field | Type | Required | Description | | ------------- | ------ | -------- | -------------------------------------------------------- | | `scopeName` | string | No | Name for the new scope (tenant). Generated when omitted. | | `projectName` | string | No | Name for the default project. Defaults to `Default`. | | `keyName` | string | No | Name for the first API key. Defaults to `Default key`. | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/account/onboard" \ -H "Authorization: Bearer SUPABASE_USER_JWT" \ -H "Content-Type: application/json" \ -d '{"scopeName": "Acme Corp", "projectName": "Invoices", "keyName": "First key"}' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/account/onboard", headers={"Authorization": "Bearer SUPABASE_USER_JWT"}, json={"scopeName": "Acme Corp", "projectName": "Invoices", "keyName": "First key"}, ) # Store response.json()["apiKey"] now — it is never shown again. tenant = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/account/onboard", { method: "POST", headers: { Authorization: "Bearer SUPABASE_USER_JWT", "Content-Type": "application/json", }, body: JSON.stringify({ scopeName: "Acme Corp", projectName: "Invoices", keyName: "First key", }), } ); // Store result.apiKey now — it is never shown again. const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "SUPABASE_USER_JWT"); var body = new StringContent( """{"scopeName":"Acme Corp","projectName":"Invoices","keyName":"First key"}""", Encoding.UTF8, "application/json"); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/account/onboard", body); var json = await response.Content.ReadAsStringAsync(); ``` **Response** `201 Created` Includes a `Location: /api/v1/account/scopes/{scopeId}` header. ```json { "success": true, "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "projectId": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "apiKey": "akv1.xxxxxxxxxxxxxxxxxxxxxxxx", "kid": "kid_01HXXXXX", "keyPrefix": "akv1.abc", "keySuffix": "xyz", "message": "Tenant provisioned successfully. Store the API key securely, it will not be shown again." } ``` The `apiKey` value is shown **only once**. Store it before continuing. **Error Responses** | Status | Description | | ------ | ----------------------------------------------------------------------------- | | `401` | Missing or invalid authentication, or the session is not a user (JWT) session | | `409` | The account already has a tenant — a second tenant is never created | *** Create Onboarding Code [#create-onboarding-code] **POST** /api/v1/account/onboarding-tokens Mints a single-use, short-lived **device code** (prefixed `aonb_`) bound to one of your projects. An external agent or CLI later exchanges the code for a scoped Client key — so you can install a tool on another device without pasting your session token. The plaintext code is returned **once**. **Body** | Field | Type | Required | Description | | ------------ | ------------- | -------- | ---------------------------------------------------------------------- | | `projectId` | string (uuid) | Yes | Project the issued key will be bound to. Must be one of your projects. | | `ttlMinutes` | integer | No | Lifetime in minutes. Defaults to 15; clamped to a safe maximum. | | `keyName` | string | No | Name applied to the key issued on redemption. | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/account/onboarding-tokens" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "ttlMinutes": 15, "keyName": "Agent key"}' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/account/onboarding-tokens", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={ "projectId": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "ttlMinutes": 15, "keyName": "Agent key", }, ) # Share response.json()["code"] once — it is single-use and expires soon. token = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/account/onboarding-tokens", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ projectId: "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", ttlMinutes: 15, keyName: "Agent key", }), } ); // Share result.code once — it is single-use and expires soon. const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var body = new StringContent( """{"projectId":"c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a","ttlMinutes":15,"keyName":"Agent key"}""", Encoding.UTF8, "application/json"); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/account/onboarding-tokens", body); var json = await response.Content.ReadAsStringAsync(); ``` **Response** `201 Created` Includes a `Location: /api/v1/account/onboarding-tokens/{id}` header. ```json { "success": true, "id": "a1b2c3d4-0000-0000-0000-000000000000", "code": "aonb_xxxxxxxxxxxxxxxxxxxx", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "projectId": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "expiresAt": "2026-06-15T12:15:00Z", "message": "Onboarding code created. Share it once; it is single-use and expires soon." } ``` The `code` is shown **only once** and only a hash is stored. Hand it to the agent promptly — it is single-use and expires. **Error Responses** | Status | Description | | ------ | -------------------------------------- | | `400` | `projectId` missing | | `401` | Missing or invalid authentication | | `403` | Project is outside the caller's grants | | `404` | Project does not exist | *** Redeem Onboarding Code [#redeem-onboarding-code] **POST** /api/v1/account/onboarding-tokens/redeem Exchanges a valid, unexpired, unused onboarding code for a scoped Client API key. This endpoint is **anonymous** — the code is the only credential — so an agent or CLI can self-install without the user ever pasting a session token. The new plaintext key is returned **once**. The code is single-use: any unknown, expired, or already-used code returns the same generic `400`, so a code cannot be probed. **Body** | Field | Type | Required | Description | | --------- | ------ | -------- | ----------------------------------------------------------------- | | `code` | string | Yes | The onboarding code (`aonb_…`) shared by the user | | `keyName` | string | No | Name for the issued key. Overrides the name stored with the code. | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/account/onboarding-tokens/redeem" \ -H "Content-Type: application/json" \ -d '{"code": "aonb_xxxxxxxxxxxxxxxxxxxx", "keyName": "Agent key"}' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/account/onboarding-tokens/redeem", json={"code": "aonb_xxxxxxxxxxxxxxxxxxxx", "keyName": "Agent key"}, ) # Store response.json()["apiKey"] now — it is never shown again. key = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/account/onboarding-tokens/redeem", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: "aonb_xxxxxxxxxxxxxxxxxxxx", keyName: "Agent key", }), } ); // Store result.apiKey now — it is never shown again. const result = await response.json(); ``` ```csharp var client = new HttpClient(); var body = new StringContent( """{"code":"aonb_xxxxxxxxxxxxxxxxxxxx","keyName":"Agent key"}""", Encoding.UTF8, "application/json"); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/account/onboarding-tokens/redeem", body); var json = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "success": true, "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "projectId": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "apiKey": "akv1.xxxxxxxxxxxxxxxxxxxxxxxx", "kid": "kid_01HXXXXX", "keyPrefix": "akv1.abc", "keySuffix": "xyz", "message": "API key issued. Store it securely, it will not be shown again." } ``` The `apiKey` value is shown **only once**. Store it before continuing. **Error Responses** | Status | Description | | ------ | --------------------------------------------------------------------------------------------- | | `400` | The code is invalid, expired, or already used (single generic error — codes cannot be probed) | *** Capabilities [#capabilities] **GET** /api/v1/capabilities Advertises which optional features the deployment currently supports **and the limits in force for you**, so client adapters can degrade gracefully as the API evolves and size a payload before sending it. Available to any authenticated principal. **Response** `200 OK` A flat object of boolean flags plus a `limits` object: ```json { "storageKeyPrefix": true, "storageWhereFilter": true, "storageIfMatch": true, "guardCost": false, "guardBudget": false, "guardConcurrency": true, "scopedReadOnlyTokens": false, "selfOnboarding": true, "keyRotation": true, "projectManagement": true, "memory": true, "limits": { "storageJsonSizeBytes": 10485760, "storageDocumentCount": 5000, "memoryDocumentSizeBytes": 102400, "memoryDocumentCount": 10000, "filesFileSizeBytes": 104857600, "filesStorageBytes": 10737418240, "convertFileSizeBytes": 104857600, "convertJobsMonthly": 1000, "guardChecksMonthly": 20000, "shieldLocksMonthly": 20000, "inboxMessagesMonthly": 500, "inboxCount": 1 } } ``` The shape is stable; later releases flip individual flags to `true` as features land. **`limits` is resolved for the calling key, not the deployment.** If your scope carries an override, these are the override's values — the numbers the write handlers will actually enforce for you. Read them instead of assuming a documented default; a limit stated anywhere else is the default, this one is yours. | Field | Meaning | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `limits.storageJsonSizeBytes` | Largest JSON document `POST`/`PUT`/`PATCH` on Data store will accept | | `limits.storageDocumentCount` | JSON documents allowed in the scope (`0` or less means unlimited) | | `limits.memoryDocumentSizeBytes` | Largest markdown body a memory write will accept | | `limits.memoryDocumentCount` | Maximum live memory documents the scope may hold, summed across every project in it. Deleted and expired documents stop counting. (`0` or less means unlimited) | | `limits.filesFileSizeBytes` | Largest binary file an upload will accept | | `limits.filesStorageBytes` | Total bytes the scope may keep stored in the Files API, across every category (`0` means unlimited) — reported in bytes rather than the gigabytes it is configured in, because that is the unit an upload is measured in and the unit `filesFileSizeBytes` is already stated in | | `limits.convertFileSizeBytes` | Largest file Convert will accept for processing | | `limits.convertJobsMonthly` | Convert jobs allowed per scope per calendar month | | `limits.guardChecksMonthly` | Guard checks per scope per calendar month, counted across every key | | `limits.shieldLocksMonthly` | Shield lock **acquires** per scope per calendar month — renew, complete and status act on a lock already counted | | `limits.inboxMessagesMonthly` | Messages received into the scope per calendar month, across every inbox | | `limits.inboxCount` | Inboxes the scope may hold at once | **The set matches what the MCP `_guide` tools publish**, deliberately: the two are one promise made twice, and a caller told to read `/capabilities` rather than assume could previously not learn its Convert, Guard, Shield or Inbox allowance here while the guides published all of them. **`0` and `null` mean different things on the allowances.** On `storageDocumentCount`, `memoryDocumentCount`, `filesStorageBytes`, `convertJobsMonthly`, `guardChecksMonthly`, `shieldLocksMonthly`, `inboxMessagesMonthly` and `inboxCount`, `0` means *unlimited* — the value is enforced only when it is greater than zero. `null` means the allowance could not be resolved for you (the calling identity carries no scope, or its scope record was not found), which is not the same as "unlimited": treat it as unknown, not as permission. Eight fields can be `null` — the scope-resolved allowances `storageDocumentCount`, `memoryDocumentCount`, `filesStorageBytes`, `convertJobsMonthly`, `guardChecksMonthly`, `shieldLocksMonthly`, `inboxMessagesMonthly` and `inboxCount`. The four size limits are never null. Exceeding one of these is a business failure, not a server error — over REST it is a `4xx`, and over MCP a tool result with `isError: true`. The three families fail differently: * **The size limits** (`storageJsonSizeBytes`, `memoryDocumentSizeBytes`, `filesFileSizeBytes`, `convertFileSizeBytes`) answer `413 Payload Too Large` over REST, and `ITEM_TOO_LARGE` over MCP carrying `currentBytes` and `maximumBytes`. Send less. * **The stored-amount allowances** (`storageDocumentCount`, `memoryDocumentCount`, `filesStorageBytes`, `inboxCount`) answer `429 Too Many Requests` over REST, and `RATE_LIMITED` over MCP. They measure what the scope holds right now, so **waiting does not help** — none of them resets on a billing cycle. Free room by deleting; until then, retrying an unchanged request answers the same way. * **The monthly allowances** (`convertJobsMonthly`, `guardChecksMonthly`, `shieldLocksMonthly`, `inboxMessagesMonthly`) answer the same `429` / `RATE_LIMITED`, for the opposite reason: the allowance is spent for this billing cycle and frees when the calendar month turns over, and not before. Wait rather than retry — deleting makes no difference here. **The two `429` families look identical on the wire.** Nothing in the status code says whether to wait for the month to turn over or to delete something, so decide from the limit that applies to the call. Over MCP both arrive as `RATE_LIMITED` with `retryable: true`; on a stored-amount allowance that means the request can succeed again once room is freed, not that an immediate retry will. **`filesStorageBytes` is the one to watch on upload.** It is counted in bytes and sits beside the per-file size limit, but it predicts a `RATE_LIMITED` refusal (`429` over REST), not a `413`: a file well under `filesFileSizeBytes` is still refused once the scope is full, however small it is. Compare it against what the scope currently stores before uploading, rather than against the per-file size limit alone. Only the growth is charged when a `PUT` replaces an existing file, so a replacement that shrinks a file is never refused by it. **Error Responses** | Status | Description | | ------ | --------------------------------- | | `401` | Missing or invalid authentication | *** Identity Discovery [#identity-discovery] **GET** /api/v1/whoami Returns the caller's own identity context (kid, role, scope, project). Combined with the endpoints above it is typically enough to build a dashboard scope/project picker without any other privileged call. **Response** `200 OK` ```json { "id": 42, "kid": "kid_01HXXXXX", "name": "Production Client", "role": "Client", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "projectId": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "projectName": "Invoices", "userId": "9d41c2b1-2cba-4fde-a0a2-7f4ab19fd8dd", "status": "Active", "isActive": true, "scopes": [ { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "Acme Corp" } ], "projects": [ { "id": "c51f3c8d-8c6b-4c2f-9b28-4e54ac6b1e5a", "name": "Invoices", "scopeId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" } ] } ``` For API-key callers, `id` and `kid` identify the calling key. For JWT callers they are `null`, and `scopes`/`projects` enumerate the granted access used to populate pickers. # Convert (/docs/api/convert) Extract text from PDFs, images, Office documents, and transcribe audio files in 60+ languages. Supports multiple output formats, response modes, and webhook notifications. **Base URL** ``` https://api.ainoflow.io ``` All endpoints require `Authorization: Bearer YOUR_API_KEY` header. Supported Formats [#supported-formats] | Category | Formats | | ------------- | ---------------------------------------------- | | Images (OCR) | PDF, JPEG, PNG, TIFF, BMP, WebP, GIF | | Documents | Word (.doc, .docx), RTF, ODT, TXT | | Spreadsheets | Excel (.xls, .xlsx), ODS | | Presentations | PowerPoint (.ppt, .pptx), ODP | | Audio | WAV, MP3, M4A, MP4, WebM, OGG, FLAC, AAC, Opus | **Output formats:** `text` (plain text extraction or transcription), `pdf` (searchable PDF with OCR text layer - not available for audio files). File size [#file-size] One limit governs every submission path: **100 MB by default**. An uploaded file, base64 content and a file fetched from a source URL are all measured against the same number — there is no smaller, separate allowance for base64. The limit is resolved per scope, so a scope may carry an override above or below the default. `GET /api/v1/capabilities` reports the value in force for your key as `convertFileSizeBytes`. **Where a size refusal costs you a job.** On `submit-file` and `submit-base64` the size is checked before the job exists, so an oversized file is refused without touching your monthly job allowance. On `submit-url` the job is created first and the size is only known once the download runs, so a refusal there has already spent one job. *** Submit File [#submit-file] **POST** /api/v1/convert/submit-file Upload a document or audio file directly using multipart form data. **Request Body `multipart/form-data`** | Parameter | Type | Required | Description | | -------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------- | | `file` | binary | Yes | File to process (100 MB by default; the effective limit is resolved for your key — see [File size](#file-size)) | | `languages` | string | Yes\* | Comma-separated ISO 639-1 codes (e.g., `en,de,fr`). Required for images, PDFs, and audio. Optional for text documents. | | `outputs` | string | Yes | Output formats: `text`, `pdf`, or `text,pdf` | | `models` | string | No | Processing model: `auto` (default), `tesseract`, `paddleocr`, `whispersmall`, `whispertiny`, `whisperbase` | | `ocr` | string | No | OCR control: `auto` (default) | | `response` | string | No | Response mode: `polling` (default), `direct`, `webhook`, `persisted` | | `webhookUrl` | string | No | URL for completion notification (must be publicly accessible) | | `reference` | string | No | Your custom reference ID for tracking | | `jobExpiryInMinutes` | number | No | Job expiration in minutes (default: 1440) | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/convert/submit-file" \ -H "Authorization: Bearer YOUR_API_KEY" \ -F "file=@document.pdf" \ -F "languages=en,de" \ -F "outputs=text,pdf" \ -F "response=polling" ``` ```python import requests with open("document.pdf", "rb") as f: response = requests.post( "https://api.ainoflow.io/api/v1/convert/submit-file", headers={"Authorization": "Bearer YOUR_API_KEY"}, files={"file": ("document.pdf", f, "application/pdf")}, data={ "languages": "en,de", "outputs": "text,pdf", "response": "polling" } ) result = response.json() job_id = result["id"] ``` ```javascript const formData = new FormData(); formData.append("file", fileBlob, "document.pdf"); formData.append("languages", "en,de"); formData.append("outputs", "text,pdf"); formData.append("response", "polling"); const response = await fetch( "https://api.ainoflow.io/api/v1/convert/submit-file", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY" }, body: formData, } ); const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); using var content = new MultipartFormDataContent(); content.Add(new StreamContent(File.OpenRead("document.pdf")), "file", "document.pdf"); content.Add(new StringContent("en,de"), "languages"); content.Add(new StringContent("text,pdf"), "outputs"); content.Add(new StringContent("polling"), "response"); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/convert/submit-file", content); var result = await response.Content.ReadAsStringAsync(); ``` **Response `200 OK`** **Headers:** `X-Job-Id` with the job identifier. ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "processing", "reference": "doc123", "models": "auto", "processingTimeInSeconds": 0.5, "responseMode": "polling", "content": null, "files": null, "error": null } ``` **Response fields:** | Field | Type | Description | | ------------------------- | ------ | ----------------------------------------------------------------------------- | | `id` | string | Unique job identifier | | `status` | string | `created`, `accepted`, `processing`, `completed`, `failed`, `cancelled` | | `reference` | string | Your reference ID if provided | | `models` | string | Processing model(s) used | | `processingTimeInSeconds` | number | Time elapsed for processing | | `responseMode` | string | `polling`, `direct`, `webhook`, `persisted` | | `content` | array | Immediate results (direct mode only) with `text` and `pdf` fields | | `files` | array | Download URLs (polling/persisted mode) with pre-signed `url` and `expiration` | | `error` | object | Error details with `message` field if processing failed | **Error Responses** | Status | Description | | ------ | ------------------------------------------------------------------------- | | `400` | Invalid parameters, unsupported file format, or unsupported language code | | `401` | Missing or invalid API key | | `429` | Monthly job limit reached for the current billing cycle | *** Submit Base64 Document [#submit-base64-document] **POST** /api/v1/convert/submit-base64 Submit a document or audio file using base64-encoded content in a JSON body. **Request Body `application/json`** | Parameter | Type | Required | Description | | -------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------- | | `documentBase64` | string | Yes | Base64-encoded file content (measured against the same limit as an upload — see [File size](#file-size)) | | `filename` | string | No | Original filename for content type detection (e.g., `document.pdf`) | | `languages` | string | Yes\* | Comma-separated ISO 639-1 codes. Required for images, PDFs, and audio. Optional for text documents. | | `outputs` | string | Yes | Output formats: `text`, `pdf`, or `text,pdf` | | `models` | string | No | Processing model (default: `auto`) | | `ocr` | string | No | OCR control (default: `auto`) | | `response` | string | No | Response mode (default: `polling`) | | `webhookUrl` | string | No | URL for completion notification | | `reference` | string | No | Your custom reference ID | | `jobExpiryInMinutes` | number | No | Job expiration in minutes (default: 1440) | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/convert/submit-base64" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "documentBase64": "JVBERi0xLjQK...", "filename": "document.pdf", "languages": "en,de", "outputs": "text,pdf" }' ``` ```python import requests import base64 with open("document.pdf", "rb") as f: encoded = base64.b64encode(f.read()).decode("utf-8") response = requests.post( "https://api.ainoflow.io/api/v1/convert/submit-base64", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "documentBase64": encoded, "filename": "document.pdf", "languages": "en,de", "outputs": "text,pdf" } ) result = response.json() ``` ```javascript const base64Content = btoa(String.fromCharCode(...new Uint8Array(fileBuffer))); const response = await fetch( "https://api.ainoflow.io/api/v1/convert/submit-base64", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ documentBase64: base64Content, filename: "document.pdf", languages: "en,de", outputs: "text,pdf", }), } ); const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var fileBytes = await File.ReadAllBytesAsync("document.pdf"); var base64 = Convert.ToBase64String(fileBytes); var response = await client.PostAsJsonAsync( "https://api.ainoflow.io/api/v1/convert/submit-base64", new { documentBase64 = base64, filename = "document.pdf", languages = "en,de", outputs = "text,pdf" }); var result = await response.Content.ReadAsStringAsync(); ``` **Response `200 OK`** Same format as [Submit File](#submit-file). **Error Responses** | Status | Description | | ------ | ----------------------------------------------------------------- | | `400` | Invalid parameters, unsupported format, or invalid base64 content | | `401` | Missing or invalid API key | | `429` | Monthly job limit reached | *** Submit URL [#submit-url] **POST** /api/v1/convert/submit-url Submit a document or audio file from an external URL. Accepts both `application/json` and `multipart/form-data` content types. **Request Body `application/json`** | Parameter | Type | Required | Description | | -------------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `sourceUrl` | string | Yes | HTTP/HTTPS URL to download the file (the downloaded file is measured against the same limit as an upload — see [File size](#file-size)) | | `languages` | string | Yes\* | Comma-separated ISO 639-1 codes. Required for images, PDFs, and audio. Optional for text documents. | | `outputs` | string | Yes | Output formats: `text`, `pdf`, or `text,pdf` | | `models` | string | No | Processing model (default: `auto`) | | `ocr` | string | No | OCR control (default: `auto`) | | `response` | string | No | Response mode (default: `polling`) | | `webhookUrl` | string | No | URL for completion notification | | `reference` | string | No | Your custom reference ID | | `jobExpiryInMinutes` | number | No | Job expiration in minutes (default: 1440) | **Request Body `multipart/form-data`** The same parameters can be sent as form fields instead of JSON. This is useful for integration platforms that prefer form-based submissions. **Code Samples** ```bash # JSON body curl -X POST "https://api.ainoflow.io/api/v1/convert/submit-url" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sourceUrl": "https://example.com/document.pdf", "languages": "en,de", "outputs": "text,pdf" }' # Form data (alternative) curl -X POST "https://api.ainoflow.io/api/v1/convert/submit-url" \ -H "Authorization: Bearer YOUR_API_KEY" \ -F "sourceUrl=https://example.com/document.pdf" \ -F "languages=en,de" \ -F "outputs=text,pdf" ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/convert/submit-url", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "sourceUrl": "https://example.com/document.pdf", "languages": "en,de", "outputs": "text,pdf" } ) result = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/convert/submit-url", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ sourceUrl: "https://example.com/document.pdf", languages: "en,de", outputs: "text,pdf", }), } ); const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.PostAsJsonAsync( "https://api.ainoflow.io/api/v1/convert/submit-url", new { sourceUrl = "https://example.com/document.pdf", languages = "en,de", outputs = "text,pdf" }); var result = await response.Content.ReadAsStringAsync(); ``` **Response `200 OK`** Same format as [Submit File](#submit-file). **Error Responses** | Status | Description | | ------ | ----------------------------------------------------------- | | `400` | Invalid parameters, unreachable URL, or blocked webhook URL | | `401` | Missing or invalid API key | | `429` | Monthly job limit reached | *** Get Job Status [#get-job-status] **GET** /api/v1/convert/jobs/{jobId} Check the processing status of a submitted job and retrieve download URLs when complete. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ---- | -------- | ---------------------------------------- | | `jobId` | uuid | Yes | Job ID returned from any submit endpoint | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000", headers={"Authorization": "Bearer YOUR_API_KEY"} ) job = response.json() print(f"Status: {job['status']}") ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const job = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000"); var job = await response.Content.ReadAsStringAsync(); ``` **Response `200 OK`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "completed", "reference": "doc123", "models": "tesseract", "responseMode": "polling", "createdAt": "2026-01-15T10:30:00Z", "startedAt": "2026-01-15T10:30:01Z", "completedAt": "2026-01-15T10:30:05Z", "expiryAt": "2026-01-16T10:30:00Z", "processingTimeInSeconds": 5.2, "files": [ { "models": "tesseract", "text": { "url": "https://storage.example.com/results/job_text.txt?signature=...", "expiration": "2026-01-16T10:30:05Z" }, "pdf": { "url": "https://storage.example.com/results/job_pdf.pdf?signature=...", "expiration": "2026-01-16T10:30:05Z" } } ], "error": null } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `404` | Job not found | *** Get Extracted Text [#get-extracted-text] **GET** /api/v1/convert/jobs/{jobId}/text Download the extracted text output for a completed job. Returns plain text content directly. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ---- | -------- | ------------------------- | | `jobId` | uuid | Yes | Job ID of a completed job | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000/text" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000/text", headers={"Authorization": "Bearer YOUR_API_KEY"} ) text = response.text ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000/text", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const text = await response.text(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var text = await client.GetStringAsync( "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000/text"); ``` **Response `200 OK`** **Content-Type:** `text/plain` Plain text content of the processed document or audio transcription. **Error Responses** | Status | Description | | ------ | ------------------------------------------ | | `400` | Job is not completed yet | | `401` | Missing or invalid API key | | `404` | Job not found or text output not available | *** Get Searchable PDF [#get-searchable-pdf] **GET** /api/v1/convert/jobs/{jobId}/pdf Download the PDF output with a searchable text layer for a completed job. Not available for audio files. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ---- | -------- | ------------------------- | | `jobId` | uuid | Yes | Job ID of a completed job | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000/pdf" \ -H "Authorization: Bearer YOUR_API_KEY" \ -o output.pdf ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000/pdf", headers={"Authorization": "Bearer YOUR_API_KEY"} ) with open("output.pdf", "wb") as f: f.write(response.content) ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000/pdf", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const blob = await response.blob(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var pdfBytes = await client.GetByteArrayAsync( "https://api.ainoflow.io/api/v1/convert/jobs/550e8400-e29b-41d4-a716-446655440000/pdf"); await File.WriteAllBytesAsync("output.pdf", pdfBytes); ``` **Response `200 OK`** **Content-Type:** `application/pdf` Binary PDF file with searchable text layer. **Error Responses** | Status | Description | | ------ | ----------------------------------------- | | `400` | Job is not completed yet | | `401` | Missing or invalid API key | | `404` | Job not found or PDF output not available | *** Response Modes [#response-modes] | Mode | Behavior | | ----------- | -------------------------------------------------------------------------------------------------------- | | `polling` | Returns immediately with job ID. Poll `GET /api/v1/convert/jobs/\{jobId\}` for status and download URLs. | | `direct` | Waits for processing. Returns file contents in response body (base64 for PDFs, plain text for others). | | `webhook` | Returns immediately with job ID. Sends POST to your `webhookUrl` when complete. | | `persisted` | Waits for processing. Returns pre-signed download URLs instead of file contents. | Processing Models [#processing-models] | Model | Best For | | ---------------- | ------------------------------------------------------ | | `auto` (default) | Automatically selects optimal model based on file type | | `tesseract` | Traditional OCR engine, best for PDFs | | `paddleocr` | AI-powered OCR, best for images | | `whispersmall` | Used by `auto` for audio transcription (best quality) | | `whispertiny` | Faster audio transcription, lower quality | | `whisperbase` | Balanced audio transcription speed and quality | Supported Languages [#supported-languages] Over 60 languages are supported using ISO 639-1 codes. Common examples: `en` (English), `de` (German), `fr` (French), `es` (Spanish), `it` (Italian), `pt` (Portuguese), `ru` (Russian), `pl` (Polish), `uk` (Ukrainian), `nl` (Dutch), `cs` (Czech), `sv` (Swedish), `da` (Danish), `fi` (Finnish), `no` (Norwegian), `lt` (Lithuanian), `lv` (Latvian), `et` (Estonian), `hu` (Hungarian), `ro` (Romanian), `bg` (Bulgarian), `hr` (Croatian), `sk` (Slovak), `sl` (Slovenian), `el` (Greek), `tr` (Turkish), `ar` (Arabic), `hi` (Hindi), `zh-cn` (Chinese Simplified), `zh-tw` (Chinese Traditional), `ja` (Japanese), `ko` (Korean), `th` (Thai), `vi` (Vietnamese) Webhook Integration [#webhook-integration] Set `response` to `webhook` and provide a `webhookUrl` to receive POST notifications when processing completes. The webhook payload is identical to the `GET /api/v1/convert/jobs/\{jobId\}` response, wrapped in a `result` object. Webhook URLs must point to publicly accessible hosts. Private/internal network addresses, loopback addresses, and cloud metadata endpoints are blocked. # Files (/docs/api/files) Binary file storage for documents, images, and any file format up to 100 MB. Files are organized by **category** (namespace) and identified by a unique **key** within each category. **Base URL** ``` https://api.ainoflow.io ``` All endpoints require `Authorization: Bearer YOUR_API_KEY` header. Supported File Types [#supported-file-types] | Category | Formats | | --------- | ------------------------------- | | Documents | PDF, DOC, DOCX, TXT, RTF | | Images | JPEG, PNG, GIF, BMP, TIFF, WebP | | Archives | ZIP, RAR, 7Z, TAR | | Media | MP4, AVI, MP3, WAV | | Data | JSON, XML, CSV, Excel | Any binary format is accepted. Maximum file size: **100 MB**. **Which media types are accepted is not restricted, but `contentType` has to be one.** There is no allow-list — an invented but well-formed `application/x-my-format` is stored as you send it — so a file is never refused for being of an unexpected *type*. Three rules do apply, in this order: the value has to parse as `type/subtype` (optionally with parameters, as in `text/plain; charset=utf-8`), it has to be printable ASCII, and it is at most 255 characters. A value that is no media type at all, such as `notatype`, answers `400 Bad Request` and **stores nothing**, because it cannot be carried as a `Content-Type` header. A value that is wrong both ways is told about its **shape** first — shortening something that is not a media type only earns the other refusal — and the message quotes at most 60 characters of what you sent rather than echoing the whole value back. **`text/plain; charset="é"` is refused too**, and parsing is why it needs saying: the parser holds the type and subtype to an ASCII grammar but leaves a quoted parameter value alone, so such a value used to be stored and could then be sent neither to storage nor back to a downloader. A media type is ASCII by definition, so this one is refused rather than encoded. **Your file's *address* answers the opposite way** — a `category` or `key` outside ASCII is stored and answered exactly as sent, in any script. **Omit `contentType`, or send it empty, and the uploaded part's own `Content-Type` is stored** — or `application/octet-stream` when the part declares none either. Empty counts as absent at every step, so `contentType=` no longer stores a blank type that is served back blank. **The published limit counts the content, not the request.** A format that inflates the content — multipart framing here, base64 over MCP — spends more of the request allowance on the same file, so the transport ceiling is derived from the published limit and sits above it. Send a file up to the published size and the encoding is our problem, not yours. **The file allowance is a stored amount, not a monthly one.** It counts the bytes your scope is keeping right now, across every category. Once there is no room for what you are sending, the write answers `429 Too Many Requests` and **stores nothing** — no partial write, no orphaned object, because the check runs before the bytes reach storage. It does not reset on a billing cycle: retrying the same upload unchanged answers `429` again for as long as the scope stays full, so waiting for a reset is waiting for something that never arrives. Delete files — or let a TTL expire them — and the room is free again immediately. **A write is charged for the bytes it adds**, which is what makes the three write endpoints differ: | Endpoint | What the allowance is charged | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `POST /api/v1/files/{category}` | The whole upload — a new file always adds all of its bytes | | `POST /api/v1/files/{category}/{key}` | The whole upload. The address is checked first, so an occupied key still answers `409 Conflict`, never `429` | | `PUT /api/v1/files/{category}/{key}` | **Only the growth** over the file it replaces. A replacement the same size as, or smaller than, what it replaces is never refused by the allowance; when a growing one is refused, the file already there is left untouched. When the key is free, the `PUT` creates rather than replaces and the whole upload is charged, exactly like a `POST` | `429` and `413` are different refusals: `413` says this one file is over the per-file size limit, `429` says the scope has no room for it however small it is. **Every Files write can answer `409 Conflict` when it loses a race** — both `POST` endpoints, `PUT` and `DELETE`. It means another writer changed the file between the moment your call read the address and the moment it wrote, so your write was refused rather than applied on top of that writer's work. **Nothing of yours is recorded, replaced, deleted or charged**: the file at the address is the other writer's, with its version, ETag, name, content type and expiry exactly as that writer left them, and your allowance is untouched. **Its content is that writer's too** — download it and the bytes match the metadata the same API just served you, so an integrity check against the published `ETag` holds. A refused replacement leaves the previous content *readable*, not merely the previous metadata. The message names what was in flight — an `upload`, a `replacement` or a `delete`: ``` The file was modified by another writer while this upload was in flight. Re-read the file and apply the upload again - repeating this call unchanged cannot succeed. ``` **`409` now covers two different refusals, and the message is what tells them apart.** | What happened | Where it can occur | What to do | | --------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------- | | **The key is already taken** — `File already exists. Use PUT to update existing files.` | `POST /api/v1/files/{category}/{key}` only | Choose another key, or replace with `PUT` | | **The write lost a race** — the message above | Every write: both `POST`s, `PUT` and `DELETE` | Read the file again and decide again against what is there | They share one code because a bare retry cannot succeed for either, and neither is something your request got wrong — Files writes take no preconditions, so nothing of yours failed. The distinction is still worth reading: an occupied key is a state you can inspect and plan around, a lost race is a collision you never asked to detect. **`409` is not retryable as sent.** Repeating the identical call replays a decision made against a file that has moved on. Read the current file, decide again, write again. A lost race used to arrive as `500 Internal Server Error` — a server fault reported for an ordinary collision between two writers — and on `DELETE` as `404`, claiming a still-readable file did not exist. **A refused write never leaves a half-replaced file, whatever refused it.** The `409` above is one way in; a `400` for an unstorable address or an over-long name, a `429` for an exhausted allowance, a request you cancelled part-way and an interruption mid-upload are the others. Every one of them ends the same way: **either your write is recorded whole, or the file you already had is untouched — record and content alike.** There is no third outcome to code around, no repair to run afterwards, and nothing to reconcile. What that buys you concretely: after any refusal, `GET /api/v1/files/{category}/{key}` serves bytes whose length matches the `Size` the metadata reports and whose MD5 matches the `ETag` published beside it. You can verify a download against the published `ETag` and trust the result. **Bytes a refused write had already transferred cost you nothing.** They are unreachable, they are **not** counted against your stored-bytes allowance, and they leave storage within the hour. There is no cleanup call to make and no allowance to reclaim. **The one thing a *successful* write does not preserve is a pre-signed URL.** A URL issued for a file before a replacement or a delete that lands stops working — see [Get Pre-signed Download URL](#get-pre-signed-download-url). That is the same guarantee seen from the other side: a link serves the bytes it was issued for, or nothing, and never something else. *** List Categories [#list-categories] **GET** /api/v1/files Get all distinct categories with file counts. **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ | | `page` | integer | No | Page number (1-based). Cannot be used with `offset`. A value below 1 is refused with `400`, not raised to 1. | | `offset` | integer | No | Items to skip. Cannot be used with `page`. A negative value is refused with `400`, not raised to 0. | | `limit` | integer | No | Items per page (0–5000, default: 100). `0` answers an empty page carrying the real `X-Total-Count`. | | `aggregate` | boolean | No | If `true`, returns object with pagination metadata (default: `false`) | **A pagination value outside its range is refused, not adjusted.** A `limit` above 5000 or below 0, a `page` below 1 and a negative `offset` all answer `400` and nothing is listed — no clamping to the maximum, no falling back to the default. A page that comes back short is short because the data ran out. `limit=0` is the one value outside 1–5000 that is accepted: it asks how many there are without reading any. **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/files" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/files", headers={"Authorization": "Bearer YOUR_API_KEY"} ) categories = response.json() ``` ```javascript const response = await fetch("https://api.ainoflow.io/api/v1/files", { headers: { Authorization: "Bearer YOUR_API_KEY" }, }); const categories = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync("https://api.ainoflow.io/api/v1/files"); var categories = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json [ { "category": "documents", "count": 87 }, { "category": "images", "count": 245 }, { "category": "temp", "count": 12 } ] ``` **Headers:** `X-Total-Count`, `X-Page`, `X-Limit`, `X-Total-Pages` *** Upload File (Auto-generated Key) [#upload-file-auto-generated-key] **POST** /api/v1/files/{category} Upload a file with auto-generated UUID key. Provide either `file` or `sourceUrl`. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping (namespace), 1–100 characters and at most 255 UTF-8 bytes per `/`-separated segment. Every segment has to name something (no `//`, no leading or trailing `/`, no `.` or `..`), and any script is accepted while a control character is not. Outside that the request is refused with `400` and nothing is stored. | **Request Body** `multipart/form-data` | Field | Type | Required | Description | | ------------- | ------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `file` | binary | Conditional | File data (max 100 MB). Required if `sourceUrl` not provided. Its file name is stored, at most 500 characters. | | `sourceUrl` | string | Conditional | URL to download file from. Required if `file` not provided. Validated for SSRF. The stored file name comes from the **remote server**, and is bounded at 500 characters like any other. | | `contentType` | string | No | Override automatic content type detection. It has to parse as `type/subtype`, then be printable ASCII, then be at most 255 characters — shape is checked first. Which media types are accepted is not restricted. Omitted or empty, the uploaded part's own type is stored, then `application/octet-stream`. | | `expiresAt` | string | No | Expiration date (ISO 8601). Must be in the future — an expiry that has already passed is refused with `400` and nothing is stored. | **The file name and content type are checked once resolved, not as sent.** The name is your upload filename, or the one derived from `sourceUrl`; the type is your override, then the part's declared type, then `application/octet-stream`. A name past its bound, a type that is not a media type at all, a type that is not printable ASCII, or a type past 255 characters answers `400` and **nothing is stored** — no partial write and no orphaned object. An *empty* file name is storable and is stored; an *empty* content type counts as absent and falls through to the next type in the chain. Only the address fields refuse empty. **A file name in any script is fine.** `résumé.pdf` is stored, and a download carries it back in `Content-Disposition` using RFC 6266's `filename` + `filename*` pair rather than failing on it — see [Download File](#download-file). Only the *content type* is held to ASCII. **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/files/documents" \ -H "Authorization: Bearer YOUR_API_KEY" \ -F "file=@contract.pdf" \ -F "contentType=application/pdf" ``` ```bash curl -X POST "https://api.ainoflow.io/api/v1/files/images" \ -H "Authorization: Bearer YOUR_API_KEY" \ -F "sourceUrl=https://example.com/photo.jpg" ``` ```python import requests # Direct file upload with open("contract.pdf", "rb") as f: response = requests.post( "https://api.ainoflow.io/api/v1/files/documents", headers={"Authorization": "Bearer YOUR_API_KEY"}, files={"file": ("contract.pdf", f, "application/pdf")} ) # URL-based upload response = requests.post( "https://api.ainoflow.io/api/v1/files/images", headers={"Authorization": "Bearer YOUR_API_KEY"}, data={"sourceUrl": "https://example.com/photo.jpg"} ) ``` ```javascript // Direct file upload const formData = new FormData(); formData.append("file", fileBlob, "contract.pdf"); formData.append("contentType", "application/pdf"); const response = await fetch( "https://api.ainoflow.io/api/v1/files/documents", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY" }, body: formData, } ); // URL-based upload const urlForm = new FormData(); urlForm.append("sourceUrl", "https://example.com/photo.jpg"); const response2 = await fetch( "https://api.ainoflow.io/api/v1/files/images", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY" }, body: urlForm, } ); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); // Direct file upload using var content = new MultipartFormDataContent(); content.Add(new StreamContent(File.OpenRead("contract.pdf")), "file", "contract.pdf"); content.Add(new StringContent("application/pdf"), "contentType"); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/files/documents", content); ``` **Response** `201 Created` ```json { "category": "documents", "key": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", "fileName": "contract.pdf", "contentType": "application/pdf", "size": 1048576, "version": 1, "etag": "d41d8cd98f00b204e9800998ecf8427e", "createdAt": "2026-01-15T10:30:00Z", "expiresAt": null, "downloadUrl": "https://storage.example.com/files/...", "downloadUrlExpiresAt": "2026-01-15T11:30:00Z" } ``` **Headers:** `Location`, `ETag`, `X-Category`, `X-Key`, `X-Version` — `X-Category` and `X-Key` are percent-encoded, with an `X-Category-Encoding` / `X-Key-Encoding` companion, when your address is not printable ASCII; see [Download File](#download-file) **Error Responses** | Status | Description | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Invalid parameters, missing file/sourceUrl, a category outside 1–100 characters or carrying a segment past 255 UTF-8 bytes, a category with a `/`-separated segment that names nothing or a control character in it, an `expiresAt` that is already past, a resolved file name or content type past its bound, or a content type that is not a printable-ASCII media type | | `401` | Missing or invalid API key | | `409` | A concurrent writer landed first while this upload was in flight and nothing was stored. The generated key cannot be taken, so here `409` only ever means the lost race — re-read the address and upload again | | `413` | File exceeds the per-file size limit, carrying the measured size and the limit | | `429` | The scope's stored-file allowance has no room for these bytes, and the whole upload is charged. Nothing is stored. The allowance is a stored amount, not a monthly one — it never resets on its own, so retrying unchanged answers the same way until files are deleted | *** Upload File (Explicit Key) [#upload-file-explicit-key] **POST** /api/v1/files/{category}/{key} Upload a file with your own key. Returns `409 Conflict` if a file already exists there — use `PUT` to overwrite — and the same `409` if the write loses a race with a concurrent writer; the message names which. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping (namespace), 1–100 characters and at most 255 UTF-8 bytes per `/`-separated segment. Same shape and character rules as the key | | `key` | string | Yes | Unique identifier within category. **At most 255 bytes per `/`-separated segment, at most `425 − length of the category` characters in total, and at most `944 − the category's bytes − 1` bytes in total** — see the callouts below. | **Three bounds apply to a key, and each governs something different.** All three are this API's own limits on the address you choose, and not one of them has moved: where a file's bytes are held is composed from system-generated identifiers alone and carries **none of your text**, so nothing you write competes with it for room. | Bound | Value | What it governs | How you get past it | | ----------------------- | ------------------------------------ | ------------------------------------- | ------------------------------------------------------------- | | Per segment, in bytes | **255 bytes** | each `/`-separated segment on its own | split the address with `/` | | In total, in characters | **`425 − length of the category`** | the key's length as a whole | shorten the key, or the category | | In total, in bytes | **`944 − the category's bytes − 1`** | the address's weight as a whole | shorten the key or the category — splitting does **not** help | * **255 bytes per `/`-separated segment.** An address is held as a path, and every `/`-separated segment of it is bounded on its own at 255 bytes. A `/` in your key starts another segment, so this one is measured segment by segment, never against the whole value — and splitting a long address with `/` is how you get past it. It applies to the `category` too, since both contribute segments to the same address. * **`425 − length of the category` characters in total.** The address the category and key compose has to fit 500 characters, of which the scope and project identifiers take 74 and the category's separator one. **A shorter category genuinely leaves room for a longer key, one character for one**: `documents` (9 characters) allows 416, a 100-character category allows 325. * **`944 − the category's bytes − 1` bytes in total.** The category and the key have 944 bytes between them, their separator included. `documents` leaves a key 934 of them. **This is the one splitting cannot escape**: the per-segment bound is spent once per segment, so a key of several perfectly legal segments still totals up to this one. * **A hierarchical key may total more than 255 and still be stored** — `2026/08/` plus 252 characters is three segments of 4, 2 and 252, and it is stored. Only a key with no separator is one segment, and only then is 255 its total. * **The character bound and the byte bounds count different things**, which is why there are three numbers. One ASCII character is one byte, so an ASCII key meets the character bound first and never sees the byte ones. Outside ASCII a character costs up to three bytes: a 200-character Cyrillic key with no separator is one segment of 400 bytes and is refused, and a long non-ASCII *hierarchical* key can sit inside every character bound and still be past the total-byte one. * 1024 characters is the column's own width and is **never reached**. Past any of them the request answers `400` and stores nothing, and each refusal is worded for the rule that fired, because you fix them differently: too long in total names the effective maximum for the category you sent; a segment too heavy names the byte count it measured and tells you that splitting the address with `/` stores it; too heavy in total tells you the opposite, so you do not waste a round trip trying. Either way you do not learn the number by being refused twice. **An address is more than a length: it has a shape and a character set.** **Every `/`-separated segment has to name something** — in the key and the `category` alike. No empty segment, so no leading or trailing `/` and no `//`, and no `.` or `..` segment. | Accepted | Refused | | ----------------------------------------------- | -------------------------------------------------- | | `2026/08/report`, `a/b/c`, `...`, `a/.hidden/b` | `a//b`, `/leading`, `trailing/`, `a/./b`, `a/../b` | **Any script is accepted.** `café`, `документы`, `2026/08/報告書` and emoji are stored, listed and answered exactly as you sent them — storage keeps names as UTF-8, and the address is yours to choose. **Only a control character is refused**, with `400` before anything is written: a tab, a newline, a `%00` that survived URL-decoding. Every printing character stays available in every script. **Two addresses that used to work are refused on a write now.** * **A key or `category` ending in `/`.** Storage treats a name ending in `/` as a directory marker, so such a key was accepted, stored, listed and downloadable — while `a//b` and `/leading` answered `500`. The rule was whatever the backend happened to do, which is not a rule you can be told; it is a stated rule now, and `trailing/` is what that costs. * **A key or `category` containing a tab**, under the control-character rule. **Files already stored that way stay reachable.** Downloads, metadata reads, listings and deletes do not validate the address — only a new write to such an address is refused. **A non-ASCII address, on the other hand, is not a narrowing and never should have been.** `café` as a key answered `500` before this change, and storage was never the reason: the write, the row and the object were all fine, but the *answer* echoes your address into `X-Category` and `X-Key`, and an HTTP header value is US-ASCII. The failure was thrown after the file had been stored, so a retry as a `POST` then answered `409 Conflict`. It is fixed by encoding the header — see [Download File](#download-file) — not by refusing the address. **Request Body** `multipart/form-data` Same as [Upload File (Auto-generated Key)](#upload-file-auto-generated-key). **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf" \ -H "Authorization: Bearer YOUR_API_KEY" \ -F "file=@contract.pdf" ``` ```python import requests with open("contract.pdf", "rb") as f: response = requests.post( "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf", headers={"Authorization": "Bearer YOUR_API_KEY"}, files={"file": ("contract.pdf", f, "application/pdf")} ) ``` ```javascript const formData = new FormData(); formData.append("file", fileBlob, "contract.pdf"); const response = await fetch( "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY" }, body: formData, } ); ``` **Response** `201 Created` Same format as auto-generated key endpoint. The `key` field contains the key you specified. **Error Responses** | Status | Description | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Invalid parameters; an address (category or key) that is empty, past a character bound, past a byte bound (per segment or in total), carrying a `/`-separated segment that names nothing, or carrying a control character; an `expiresAt` that is already past; a resolved file name that is too long; or a content type that is not a printable-ASCII media type or is too long | | `401` | Missing or invalid API key | | `409` | File already exists at this category/key — checked before the allowance, so an occupied key answers `409`, never `429` — **or** a concurrent writer landed first while this upload was in flight. Both mean "somebody else got there" and neither is retryable as sent; the message names which, and nothing is stored either way | | `413` | File exceeds the per-file size limit, carrying the measured size and the limit | | `429` | The scope's stored-file allowance has no room for these bytes, and the whole upload is charged. Nothing is stored. The allowance is a stored amount, not a monthly one — it never resets on its own, so retrying unchanged answers the same way until files are deleted | *** Update / Replace File [#update--replace-file] **PUT** /api/v1/files/{category}/{key} Upload or replace a file. Creates if not exists, replaces if exists. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping (namespace), 1–100 characters and at most 255 UTF-8 bytes per `/`-separated segment. Same shape and character rules as the key | | `key` | string | Yes | Unique identifier within category, bounded by the same three bounds — and held to the same shape and character rules — as [Upload File (Explicit Key)](#upload-file-explicit-key) | **Request Body** `multipart/form-data` Same as [Upload File (Auto-generated Key)](#upload-file-auto-generated-key). **Code Samples** ```bash curl -X PUT "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf" \ -H "Authorization: Bearer YOUR_API_KEY" \ -F "file=@contract-v2.pdf" ``` ```python import hashlib import requests with open("contract-v2.pdf", "rb") as f: response = requests.put( "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf", headers={"Authorization": "Bearer YOUR_API_KEY"}, files={"file": ("contract-v2.pdf", f, "application/pdf")} ) # response.status_code == 200 (replaced) or 201 (created) # 409 -- another writer changed the file between this call's read and its write; nothing was # stored. Re-read the file and replace again, do not simply retry. if response.status_code == 409: # The file is the other writer's -- record AND content. Read both back and they agree: # an integrity check against the published ETag holds after a refusal. auth = {"Authorization": "Bearer YOUR_API_KEY"} base = "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf" meta = requests.get(f"{base}/meta", headers=auth).json() body = requests.get(base, headers=auth).content assert hashlib.md5(body).hexdigest() == meta["etag"] assert len(body) == meta["size"] # Nothing to repair. Decide again against what is actually there, then PUT once more. ``` ```javascript const formData = new FormData(); formData.append("file", updatedFileBlob, "contract-v2.pdf"); const response = await fetch( "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf", { method: "PUT", headers: { Authorization: "Bearer YOUR_API_KEY" }, body: formData, } ); // response.status === 200 (replaced) or 201 (created) // 409 -- another writer landed first; nothing was stored. Re-read the file and replace again, // do not simply retry. if (response.status === 409) { // Nothing to repair: the previous content is intact and still matches its published ETag. // Read the current file, decide against it, then replace once more. const auth = { Authorization: "Bearer YOUR_API_KEY" }; const base = "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf"; const meta = await (await fetch(`${base}/meta`, { headers: auth })).json(); const bytes = await (await fetch(base, { headers: auth })).arrayBuffer(); // meta.etag identifies exactly these bytes, and meta.size is their length. } ``` **Response** * **`200 OK`** - file replaced (existing key) * **`201 Created`** - new file created Response body matches the upload endpoints, except a replace returns `updatedAt` (and `updatedBy`) instead of `createdAt`. Version is incremented on replace. **Error Responses** | Status | Description | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Invalid parameters; an address (category or key) that is empty, past a character bound, past a byte bound (per segment or in total), carrying a `/`-separated segment that names nothing, or carrying a control character; an `expiresAt` that is already past; a resolved file name that is too long; or a content type that is not a printable-ASCII media type or is too long | | `401` | Missing or invalid API key | | `409` | A concurrent writer changed the file while this replacement was in flight, so your replacement was not recorded. The file at the address is that writer's — version, ETag, file name, content type, expiry **and content** exactly as they left them, so reading the file back still verifies against the `ETag` the API publishes for it — and your allowance was not charged. Re-read the file and apply the replacement again; repeating the identical call cannot succeed | | `413` | File exceeds the per-file size limit, carrying the measured size and the limit | | `429` | The scope's stored-file allowance has no room for the bytes this replacement **adds** — only the growth is charged, so a replacement the same size as or smaller than what it replaces is never refused here. When the key is free the `PUT` creates instead, and the whole upload is charged. Nothing is stored when it is refused, and the existing file is left untouched. The allowance is a stored amount, not a monthly one — free room by deleting files | *** Download File [#download-file] **GET** /api/v1/files/{category}/{key} Download binary file content directly. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Query Parameters** | Parameter | Type | Required | Description | | ---------- | ------- | -------- | ------------------------------------------------------------------------------------ | | `isInline` | boolean | No | If `true`, sets `Content-Disposition: inline` for browser display (default: `false`) | **Code Samples** ```bash # Download as attachment curl "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf" \ -H "Authorization: Bearer YOUR_API_KEY" \ -o contract.pdf # Display inline in browser curl "https://api.ainoflow.io/api/v1/files/images/logo.png?isInline=true" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf", headers={"Authorization": "Bearer YOUR_API_KEY"} ) with open("contract.pdf", "wb") as f: f.write(response.content) ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const blob = await response.blob(); ``` **Response** `200 OK` Binary file content with headers: | Header | Description | | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | The file's content type. A stored type that is not printable ASCII is served as `application/octet-stream` instead, because that header cannot carry it | | `Content-Length` | File size in bytes | | `Content-Disposition` | `attachment` or `inline`. A plain-ASCII file name keeps the `attachment; filename="…"` form; a name that needs more gets RFC 6266's `filename` + `filename*` pair | | `ETag` | File hash for caching | | `X-File-Name` | Original file name, percent-encoded when it is not printable ASCII | | `X-Content-Type` | The content type **the row holds** (for integration platforms) — so it still reports a stored value that `Content-Type` fell back on | | `X-File-Name-Encoding`, `X-Content-Type-Encoding` | `percent-utf-8`, present **only** when the value beside them had to be encoded | **Headers carrying your own text are percent-encoded only when they have to be.** `X-Category`, `X-Key`, `X-File-Name` and `X-Content-Type` all follow one rule: * **A printable-ASCII value is written through untouched**, byte for byte, with no companion field — so every address and file name that worked before this change produces identical headers. That is deliberate: these headers exist for integration platforms, which is exactly the population that parses them with regular expressions. * **Anything else is percent-encoded as UTF-8**, with a companion field saying so: ```http X-Key: caf%C3%A9 X-Key-Encoding: percent-utf-8 ``` **Decode when the companion field is present, and only then.** A key you literally spelled `caf%C3%A9` arrives without one and means exactly that. `%` and `/` are never escaped, so a hierarchical key stays readable. **Error Responses** | Status | Description | | ------ | ----------------------------------- | | `401` | Missing or invalid API key | | `404` | File not found, deleted, or expired | **Reads do not validate the address.** A download, a metadata read, a listing or a delete reaches whatever is stored, so a file written at an address the write rules now refuse — one ending in `/`, say — is still reachable at exactly that address. *** Get File Metadata [#get-file-metadata] **GET** /api/v1/files/{category}/{key}/meta Get file metadata without downloading content. **Query Parameters** | Parameter | Type | Required | Description | | ------------ | ------- | -------- | ------------------------------------------------------------------------ | | `allowEmpty` | boolean | No | If `true`, returns `{}` instead of 404 when not found (default: `false`) | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf/meta" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf/meta", headers={"Authorization": "Bearer YOUR_API_KEY"} ) metadata = response.json() ``` **Response** `200 OK` ```json { "category": "documents", "key": "contract-2024.pdf", "fileName": "contract-2024.pdf", "contentType": "application/pdf", "size": 1048576, "etag": "d41d8cd98f00b204e9800998ecf8427e", "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T14:20:00Z", "expiresAt": null, "version": 3 } ``` **Headers:** `X-Category`, `X-Key`, `ETag`, `X-Version`, `X-Updated-At` — the address headers follow the encoding rule described under [Download File](#download-file) *** Get Pre-signed Download URL [#get-pre-signed-download-url] **GET** /api/v1/files/{category}/{key}/url Get a pre-signed URL for direct download without API authentication. **Query Parameters** | Parameter | Type | Required | Description | | --------------- | ------- | -------- | ----------------------------------------------------- | | `expirySeconds` | integer | No | URL expiration in seconds (default: 3600, max: 86400) | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf/url?expirySeconds=7200" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf/url", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"expirySeconds": 7200} ) url_data = response.json() # Use url_data["downloadUrl"] - no auth needed. # Hand it on promptly: replacing or deleting the file stops this URL working within the # hour, whatever expirySeconds said. Re-issue after a replacement rather than reusing it. ``` **Response** `200 OK` ```json { "downloadUrl": "https://storage.example.com/files/documents/contract-2024.pdf?signature=...", "downloadUrlExpiresAt": "2026-01-15T12:30:00Z" } ``` **A pre-signed URL links to the content it was issued for, not to the address — so it can stop working before `downloadUrlExpiresAt`.** Replace that file or delete it and the URL stops serving within the hour, answering the storage backend's own not-found response rather than anything of ours. It never serves a *different* file: the bytes behind a link are the bytes it was issued for, and a replacement's content is reachable only through a freshly issued URL. This is a change worth planning for, and the old behaviour was the worse one: a URL issued before a replacement used to keep working and **silently serve whatever happened to be at the address** — content its holder never asked for, under a link they were given for something else. **The window is about an hour, and it is not a guarantee of an hour.** Superseded content is held for an hour after the write that replaced it, then removed on the next cleanup pass. A URL may be issued for up to 24 hours, so a link can comfortably outlive the content it was issued for. **Practically:** treat a URL's usable life as the shorter of its expiry and the file's. Issue it close to the moment you hand it on, ask for the lifetime you actually need rather than the maximum, and re-issue after a replacement instead of assuming the old link followed the file. Nothing about the *address* changes — `GET /api/v1/files/{category}/{key}` and a freshly issued URL both serve the current file, as always. *** List Files in Category [#list-files-in-category] **GET** /api/v1/files/{category} List all files in a category with pagination, filtering, and sorting. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ---------------------------- | | `category` | string | Yes | Logical grouping (namespace) | **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ | | `page` | integer | No | Page number (1-based). Cannot be used with `offset`. A value below 1 is refused with `400`, not raised to 1. | | `offset` | integer | No | Items to skip. Cannot be used with `page`. A negative value is refused with `400`, not raised to 0. | | `limit` | integer | No | Items per page (0–5000, default: 100). `0` answers an empty page carrying the real `X-Total-Count`. | | `prefix` | string | No | Filter files by key prefix | | `sortBy` | string | No | Sort field: `key`, `createdAt`, `updatedAt`, `size` (default: `createdAt`) | | `sortOrder` | string | No | Sort direction: `asc`, `desc` (default: `asc`) | | `aggregate` | boolean | No | If `true`, returns object with pagination metadata (default: `false`) | **A pagination value outside its range is refused, not adjusted.** `limit=10000` answers `400` and lists nothing rather than quietly returning 5000 rows — clamping made a shrunk request indistinguishable from a finished one. `limit=0` remains valid and asks for the count without the rows. Supplying both `page` and `offset` answers `400` too. **Code Samples** ```bash # List all files in "documents" category curl "https://api.ainoflow.io/api/v1/files/documents?sortBy=createdAt&sortOrder=desc&limit=50" \ -H "Authorization: Bearer YOUR_API_KEY" # Filter by prefix curl "https://api.ainoflow.io/api/v1/files/documents?prefix=invoice-" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/files/documents", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"sortBy": "createdAt", "sortOrder": "desc", "limit": 50} ) files = response.json() total = response.headers.get("X-Total-Count") ``` ```javascript const params = new URLSearchParams({ sortBy: "createdAt", sortOrder: "desc", limit: "50", }); const response = await fetch( `https://api.ainoflow.io/api/v1/files/documents?${params}`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const files = await response.json(); const total = response.headers.get("X-Total-Count"); ``` **Response** `200 OK` **Default mode** (`aggregate=false`): ```json [ { "category": "documents", "key": "contract-2024.pdf", "fileName": "contract-2024.pdf", "contentType": "application/pdf", "size": 1048576, "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T14:20:00Z", "expiresAt": null } ] ``` **Headers:** `X-Total-Count`, `X-Page`, `X-Limit`, `X-Total-Pages` **Aggregated mode** (`aggregate=true`): ```json { "items": [...], "totalCount": 87, "page": 1, "pageSize": 50, "totalPages": 2, "category": "documents" } ``` *** Delete File [#delete-file] **DELETE** /api/v1/files/{category}/{key} Permanently delete a file from storage. The file stops being readable the moment the call answers `200`, and its bytes are credited back to your stored-file allowance in the same moment — the room is free immediately. The content itself leaves storage within the hour, which is also when any pre-signed URL issued for that file stops working. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Code Samples** ```bash curl -X DELETE "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.delete( "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf", headers={"Authorization": "Bearer YOUR_API_KEY"} ) # 404 -- nothing live is at that address; there is nothing to remove # 409 -- something is there, but it changed while the delete was in flight. Nothing was deleted # and no allowance was credited back -- re-read the address and decide again ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/files/documents/contract-2024.pdf", { method: "DELETE", headers: { Authorization: "Bearer YOUR_API_KEY" }, } ); // 404 -- nothing live is at that address; there is nothing to remove // 409 -- something is there, but it moved while the delete was in flight. Nothing was deleted // and no allowance was credited back -- re-read the address and decide again ``` **Response** `200 OK` ```json { "category": "documents", "key": "contract-2024.pdf" } ``` **`404` and `409` are different answers, and the difference is whether anything is there.** `404 Not Found` means nothing live is at the address — no such file, or it was already deleted or has expired. There is nothing to remove, so stop. `409 Conflict` means something **is** there and it changed while your delete was in flight. **Nothing was deleted**: the file is still readable — its content intact and still matching its published `ETag`, not merely its record — and its bytes still count against the scope's stored-bytes allowance, which was **not** credited back. Read the address again and decide against what the other writer left; repeating the identical call cannot succeed. Both used to answer `404`, which told a caller that a file it could still download did not exist — and the two need different repairs, so they are now different answers. **Error Responses** | Status | Description | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` | Missing or invalid API key | | `404` | Nothing live is at that address: no such file, or it was already deleted or has expired | | `409` | Something is at that address, but it changed while the delete was in flight. Nothing was deleted, nothing was credited back to the allowance, and the file is still readable — re-read the address and decide again | # Guard (/docs/api/guard) Policy-based execution control for no-code workflows. Guard prevents uncontrolled workflow runs that lead to operational or financial risks by enforcing two kinds of limit: a rate limit ("how often may this run?") and a concurrency cap ("how many may run at once?"). Policies are auto-created on first check and reused on subsequent calls. **Base URL** ``` https://api.ainoflow.io ``` All endpoints require `Authorization: Bearer YOUR_API_KEY` header. Policy Types [#policy-types] | Type | Question it answers | How it works | | --------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Rate limit** | "How often may this run?" | A counter over a fixed window -- at most `rateMax` executions per `rateWindow` seconds. | | **Concurrency** | "How many may run at once?" | A counting semaphore -- an allowed check leases one of `concurrencyMax` slots and returns a slot token. The holder releases the slot when the work finishes, and an unreleased lease expires by itself after `slotTtl`. | A key can carry one policy of each type. Each is auto-created on the first check that supplies its parameters, and each is evaluated independently on every check -- both must permit a run for the check to return `allow`. **A concurrency lease expires on its own.** If the holder crashes, hangs, or simply never releases, the slot returns to the pool when the lease TTL elapses. That is what makes the cap safe to rely on: a slot is never held forever by a process that no longer exists, so the pool cannot deadlock and needs no cleanup job. Check Policies (Increment Counter) [#check-policies-increment-counter] **POST** /api/v1/guard/{key}/counter Check every policy attached to the key: increment the execution counter and, when a concurrency policy applies, lease a slot. On first call with `rateMax`/`rateWindow` a rate-limit policy is auto-created for the key; on first call with `concurrencyMax` a concurrency policy is auto-created. Subsequent calls use the stored policies automatically. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------ | -------- | ------------------------------------------------------------------------------- | | `key` | string | Yes | Guard key identifier (e.g., "send-emails", "openai-calls"). Max 512 characters. | **Query Parameters** | Parameter | Type | Required | Description | | ---------------------- | ------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `rateMax` | integer | Conditional | Maximum executions allowed in window (1--1,000,000). Required on the first call that uses rate limiting, optional after the policy exists. Must be provided together with `rateWindow`. | | `rateWindow` | integer | Conditional | Window size in seconds (1--86,400). Required on the first call that uses rate limiting, optional after the policy exists. Must be provided together with `rateMax`. | | `concurrencyMax` | integer | Conditional | Maximum slots that may be held at once (1--1,000,000). Required on the first call that uses concurrency, optional after the policy exists. | | `slotTtl` | integer | No | Slot lease TTL in seconds (1--86,400, default: 1800). Applies only to a concurrency policy. | | `returnSuccess` | boolean | No | If `true`, return 200 OK for refused requests (`deny` and `queued`) instead of 429 (default: `false`). It applies only to a policy refusal -- an exhausted monthly check allowance still answers 429. | | `failOpen` | boolean | No | If `true`, allow execution when the enforcement service is unreachable (default: `false`). A fail-open allow grants no slot, so there is no token and nothing to release. | | `allowPolicyOverwrite` | boolean | No | If `true`, update the stored policy when the supplied parameters differ from existing values instead of returning 409 Conflict (default: `false`) | `concurrencyMax` is the parameter that creates a concurrency policy -- `slotTtl` on its own never does. A window of `0` is not a way to request one either: `rateWindow=0` is rejected with 400. **Code Samples** ```bash # First call - auto-creates policy with rateMax=100, rateWindow=3600 curl -X POST "https://api.ainoflow.io/api/v1/guard/send-emails/counter?rateMax=100&rateWindow=3600" \ -H "Authorization: Bearer YOUR_API_KEY" # Subsequent calls - uses stored policy curl -X POST "https://api.ainoflow.io/api/v1/guard/send-emails/counter" \ -H "Authorization: Bearer YOUR_API_KEY" # Concurrency - first call also creates the policy: 5 slots, 10-minute lease curl -i -X POST "https://api.ainoflow.io/api/v1/guard/report-workers/counter?concurrencyMax=5&slotTtl=600" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests headers = {"Authorization": "Bearer YOUR_API_KEY"} # First call - auto-creates policy response = requests.post( "https://api.ainoflow.io/api/v1/guard/send-emails/counter", headers=headers, params={"rateMax": 100, "rateWindow": 3600} ) result = response.json() # Subsequent calls - uses stored policy response = requests.post( "https://api.ainoflow.io/api/v1/guard/send-emails/counter", headers=headers ) result = response.json() if result["allowed"]: print(f"Allowed. {result['remaining']} remaining.") else: print(f"Denied. Retry in {result['resetsIn']} seconds.") # Concurrency - first call also creates the policy: 5 slots, 10-minute lease response = requests.post( "https://api.ainoflow.io/api/v1/guard/report-workers/counter", headers=headers, params={"concurrencyMax": 5, "slotTtl": 600, "returnSuccess": "true"} ) guard = response.json() if guard["verdict"] == "allow": slot_token = guard["concurrency"]["slotToken"] elif guard["verdict"] == "queued": print(f"All slots busy. Retry in {guard['retryAfter']} seconds.") ``` ```javascript const headers = { Authorization: "Bearer YOUR_API_KEY" }; // First call - auto-creates policy const response = await fetch( "https://api.ainoflow.io/api/v1/guard/send-emails/counter?rateMax=100&rateWindow=3600", { method: "POST", headers } ); const result = await response.json(); // Subsequent calls - uses stored policy const response2 = await fetch( "https://api.ainoflow.io/api/v1/guard/send-emails/counter", { method: "POST", headers } ); const result2 = await response2.json(); if (result2.allowed) { console.log(`Allowed. ${result2.remaining} remaining.`); } else { console.log(`Denied. Retry in ${result2.resetsIn} seconds.`); } // Concurrency - first call also creates the policy: 5 slots, 10-minute lease const slotResponse = await fetch( "https://api.ainoflow.io/api/v1/guard/report-workers/counter?concurrencyMax=5&slotTtl=600&returnSuccess=true", { method: "POST", headers } ); const guard = await slotResponse.json(); if (guard.verdict === "allow") { const slotToken = guard.concurrency.slotToken; } else if (guard.verdict === "queued") { console.log(`All slots busy. Retry in ${guard.retryAfter} seconds.`); } ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); // First call - auto-creates policy var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/guard/send-emails/counter?rateMax=100&rateWindow=3600", null); var json = await response.Content.ReadAsStringAsync(); // Subsequent calls - uses stored policy var response2 = await client.PostAsync( "https://api.ainoflow.io/api/v1/guard/send-emails/counter", null); // Concurrency - first call also creates the policy: 5 slots, 10-minute lease var slotResponse = await client.PostAsync( "https://api.ainoflow.io/api/v1/guard/report-workers/counter?concurrencyMax=5&slotTtl=600", null); var slotToken = slotResponse.Headers.TryGetValues("X-Guard-Slot-Token", out var tokens) ? tokens.FirstOrDefault() : null; ``` **Verdicts** Every check returns a `verdict`: | `verdict` | `allowed` | Status | Meaning | Caller action | | --------- | --------- | --------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | `allow` | `true` | `200` | Every policy permitted the run. A slot token is present when a concurrency policy granted a slot. | Do the work; release the slot afterwards if one was granted. | | `deny` | `false` | `429` (`200` with `returnSuccess=true`) | A rate limit is exhausted. | Skip this run; retry after `retryAfter` seconds. | | `queued` | `false` | `429` (`200` with `returnSuccess=true`) | The concurrency cap is full -- every slot is held. | Defer and retry after `retryAfter` seconds; no work was lost. | A refusal costs nothing. Whichever limit refuses, the check leaves no trace: no slot stays held and no rate-limit execution is spent, so a retry loop on `queued` is safe to write. A block that took no part in the check is `null` -- a concurrency-only key reports `"rateLimit": null` with `remaining`/`resetsIn` at `0`, rather than pretending to be a limit of zero. **Two different refusals share `429`. Tell them apart by the response's media type, never by the status code alone.** * **`Content-Type: application/json`** -- the body is the verdict object described above. Your own rate or concurrency policy refused this run (`deny` or `queued`), `Retry-After` says when to come back, the counter was charged, and `returnSuccess=true` turns this one into a `200`. * **`Content-Type: application/problem+json`** -- the body is an RFC 7807 problem document, *not* a verdict. Your scope's **monthly** check allowance is spent: no policy was consulted, no counter moved, and **no `Retry-After` is sent**, because what has to elapse is the billing cycle rather than a window. A client that deserializes every `429` as a verdict reads the allowance refusal as `allowed: false`, `verdict: "allow"` -- `allow` is the field's default value -- and `retryAfter: null`. That is a self-contradictory result with no wait hint, and a caller that trusts it hot-loops against an exhausted allowance. Branch on the media type, or on the presence of the problem document's `type` and `title` fields. The generated schema can describe only one body per status code, so it carries the verdict -- which is what nearly every `429` here is. This page carries the whole contract. **`409` is a third refusal, and it is not a verdict.** It means the parameters you sent describe a different policy than the one already stored on this key, and you did not pass `allowPolicyOverwrite`. The check is refused before it runs: **no counter moved, no slot was granted, and the stored policy is left exactly as it was** -- nothing to undo, and nothing to release. Unlike `deny` and `queued` it is not a budget outcome, so `returnSuccess=true` does **not** turn it into a `200` and there is no `Retry-After` to honour. Repeating the identical call cannot clear it; only a changed request can. Four ways out, in the order they are usually wanted: 1. Send the stored parameters -- read them with `GET /api/v1/guard/{key}/policies` and match them. 2. Omit the parameters entirely once the policy exists; a bare check reuses what is stored. 3. Pass `allowPolicyOverwrite=true` to retune the policy inline and let the check proceed. 4. Use a different key, when the two callers genuinely want different budgets. Guard describes the same refusal to MCP clients as the `CONFLICT` failure code on `guard_check`, with the same cause and the same remedies -- see [Guard MCP](/docs/mcp/guard). **Response Headers** | Header | When | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | | `X-Guard-Slot-Token` | On an `allow` that granted a concurrency slot | The slot lease token. Mirrors `concurrency.slotToken` in the body. | | `Retry-After` | On every verdict refusal (`deny` and `queued`), including when `returnSuccess=true` turns the status into `200`. Never on the monthly-allowance `429`. | Seconds to wait before retrying. Same value as `retryAfter`. | **Response `200 OK`** (Allowed) ```json { "key": "send-emails", "allowed": true, "verdict": "allow", "remaining": 58, "resetsIn": 2145, "retryAfter": null, "rateLimit": { "current": 42, "max": 100, "window": 3600 }, "concurrency": null } ``` **Response `200 OK`** (Allowed -- concurrency slot granted) **Headers:** `X-Guard-Slot-Token: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4` ```json { "key": "report-workers", "allowed": true, "verdict": "allow", "remaining": 0, "resetsIn": 0, "retryAfter": null, "rateLimit": null, "concurrency": { "active": 3, "max": 5, "slotToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "expiresIn": 600 } } ``` Keep `concurrency.slotToken` -- it is the proof of the lease, and releasing the slot requires it. `concurrency.expiresIn` is how long the lease lasts: after that many seconds the slot returns to the pool by itself, whether or not the holder is still alive. Size `slotTtl` above the longest run you expect -- a job that outlives its lease keeps working while its slot is handed to somebody else, and the pool can briefly exceed `max`. **Response `429 Too Many Requests`** (Denied -- rate limit exhausted) Includes `Retry-After` header with seconds until the current window resets. ```json { "key": "send-emails", "allowed": false, "verdict": "deny", "remaining": 0, "resetsIn": 855, "retryAfter": 855, "rateLimit": { "current": 100, "max": 100, "window": 3600 }, "concurrency": null } ``` **Response `429 Too Many Requests`** (Queued -- concurrency cap full) Includes `Retry-After` header with seconds until the earliest held lease frees itself (never less than 1). ```json { "key": "report-workers", "allowed": false, "verdict": "queued", "remaining": 0, "resetsIn": 17, "retryAfter": 17, "rateLimit": null, "concurrency": { "active": 5, "max": 5, "slotToken": null, "expiresIn": null } } ``` No token is issued, so there is nothing to release -- wait `retryAfter` seconds and check again. The hint says when a slot is next expected to come free, not when this caller is guaranteed to get one: a holder that releases early frees its slot sooner, and slots go first-come-first-served rather than in queue order. On a key that also carries a rate limit, a full pool answers `queued` before the rate limit is consulted (`"rateLimit": null`), so a caller waiting for capacity spends none of its budget. **Response `429 Too Many Requests`** (Monthly check allowance spent) **Headers:** `Content-Type: application/problem+json` -- and no `Retry-After`. ```json { "type": "https://docs.ainoflow.io/problems/rate-limited", "title": "Guard API checks limit exceeded. Limit=10000.", "status": 429, "code": "RATE_LIMITED", "retryable": true, "instance": "/api/v1/guard/send-emails/counter" } ``` This is the only `429` that is not a verdict. The check never ran: no policy was consulted, no counter moved, no slot changed hands. Nothing you can send clears it and there is no window to wait out -- the allowance frees when the calendar month turns over, so `Retry-After` would have nothing truthful to say. `returnSuccess` does not apply to it. **Response `409 Conflict`** (Parameters differ from the stored policy) **Headers:** `Content-Type: application/problem+json` -- and no `Retry-After`. ```json { "type": "https://docs.ainoflow.io/problems/conflict", "title": "Policy for key 'send-emails' already configured (max=100, window=3600s). Use a different key, update the policy via PUT /api/v1/guard/policies/{id}, or pass allowPolicyOverwrite=true to update inline", "status": 409, "code": "CONFLICT", "retryable": false, "instance": "/api/v1/guard/send-emails/counter" } ``` The `title` names the parameters actually stored, so it tells you what to send to match. A concurrency mismatch produces the same shape with its own values -- `Concurrency policy for key 'report-workers' already configured (max=5, ttl=600s)`. Like the allowance `429` the check never ran, but for a different reason and with a different remedy: here a **changed request** clears it, and only a changed request. **Handling both `429`s -- and the `409`** The `409` is a problem document too, so test the **status first**: a media-type check on its own reports a policy mismatch as an exhausted allowance and sends the caller looking at the wrong month. ```python response = requests.post( "https://api.ainoflow.io/api/v1/guard/send-emails/counter", headers=headers ) if response.status_code == 409: # Parameters differ from the stored policy - nothing counted, nothing to release. # Retrying unchanged returns the same 409; send the stored parameters, omit them, # or pass allowPolicyOverwrite=true. raise RuntimeError(f"Guard policy mismatch: {response.json()['title']}") if "application/problem+json" in response.headers.get("Content-Type", ""): # Monthly check allowance spent - not a verdict, and no Retry-After to honour problem = response.json() raise RuntimeError(problem["title"]) guard = response.json() if guard["allowed"]: do_the_work() else: # deny or queued - both carry a real wait hint time.sleep(guard["retryAfter"]) ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/guard/send-emails/counter", { method: "POST", headers } ); if (response.status === 409) { // Parameters differ from the stored policy - nothing counted, nothing to release. // Retrying unchanged returns the same 409; send the stored parameters, omit them, // or pass allowPolicyOverwrite=true. const problem = await response.json(); throw new Error(`Guard policy mismatch: ${problem.title}`); } if (response.headers.get("content-type")?.includes("application/problem+json")) { // Monthly check allowance spent - not a verdict, and no Retry-After to honour const problem = await response.json(); throw new Error(problem.title); } const guard = await response.json(); if (guard.allowed) { await doTheWork(); } else { // deny or queued - both carry a real wait hint await new Promise((resolve) => setTimeout(resolve, guard.retryAfter * 1000)); } ``` Once the `409` is out of the way, the media type is what keeps the two `429`s apart. Checking `allowed` cannot: the problem document carries no `allowed` field at all, so a client that parses it as a verdict is handed the defaults -- `allowed: false` with `verdict: "allow"` and no `retryAfter` to sleep on. **Error Responses** Problem Details `type` values follow the shared [failure vocabulary](/docs/api). They are stable identifiers and are not promised to resolve. | Status | Description | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | Invalid parameters, or no policy found and neither `rateMax`/`rateWindow` nor `concurrencyMax` provided | | `401` | Missing or invalid API key | | `409` | Supplied parameters differ from the stored policy of that type and `allowPolicyOverwrite` was not set. The check is refused before it runs: no counter moved, no slot was granted, and the stored policy is unchanged. Send the stored parameters, use a different key, retune via Policy CRUD, or pass `allowPolicyOverwrite=true`. | | `429` (`application/json`) | Refused by one of your own policies -- rate limit exhausted (`deny`) or concurrency cap full (`queued`). The body is the verdict object and the `Retry-After` header is present. Returns 200 if `returnSuccess=true`. | | `429` (`application/problem+json`) | The scope's monthly check allowance is spent. The body is an RFC 7807 problem document, not a verdict: no policy was consulted, no counter moved, and no `Retry-After` is sent. `returnSuccess` does not apply. | *** Release Slot [#release-slot] **PUT** /api/v1/guard/{key}/slot/release Release a concurrency slot granted by a check, freeing it for the next caller. Call it as soon as the protected work finishes -- from a `finally` block, so a failure inside the work still returns the slot. Releasing is not required for safety (the lease expires on its own) but it is what keeps a pool of short jobs running at full width. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------ | -------- | ----------------------------------------------------------- | | `key` | string | Yes | Guard key identifier -- the same key the slot was leased on | **Request Headers** | Header | Type | Required | Description | | -------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------- | | `X-Guard-Slot-Token` | string | Yes | The slot token from the check (`concurrency.slotToken`, or the `X-Guard-Slot-Token` response header) | **Code Samples** ```bash curl -X PUT "https://api.ainoflow.io/api/v1/guard/report-workers/slot/release" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "X-Guard-Slot-Token: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" ``` ```python import requests response = requests.put( "https://api.ainoflow.io/api/v1/guard/report-workers/slot/release", headers={ "Authorization": "Bearer YOUR_API_KEY", "X-Guard-Slot-Token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" } ) # 404 means the lease was already gone - the slot is free either way released = response.status_code == 200 ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/guard/report-workers/slot/release", { method: "PUT", headers: { Authorization: "Bearer YOUR_API_KEY", "X-Guard-Slot-Token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", }, } ); // 404 means the lease was already gone - the slot is free either way const released = response.ok; ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var request = new HttpRequestMessage(HttpMethod.Put, "https://api.ainoflow.io/api/v1/guard/report-workers/slot/release"); request.Headers.Add("X-Guard-Slot-Token", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"); var response = await client.SendAsync(request); ``` **Response `200 OK`** `active` is how many slots are still held after this release. ```json { "key": "report-workers", "success": true, "active": 2 } ``` **Error Responses** | Status | Description | | ------ | ---------------------------------------------------------------------------------------------------------- | | `400` | Missing `X-Guard-Slot-Token` header | | `401` | Missing or invalid API key | | `404` | The lease is gone: it already expired, it was already released, or the token never held a slot on this key | A `404` here is not an error to recover from -- the slot is free either way. Treat it as "already released"; most often it means the work outran its lease TTL. *** Read Counter (Status) [#read-counter-status] **GET** /api/v1/guard/{key}/counter Read the current status of every policy on the key without incrementing a counter or leasing a slot. Uses stored policies -- no additional parameters needed. Intended for occasional checks and debugging. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------ | -------- | -------------------- | | `key` | string | Yes | Guard key identifier | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/guard/send-emails/counter" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/guard/send-emails/counter", headers={"Authorization": "Bearer YOUR_API_KEY"} ) status = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/guard/send-emails/counter", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const status = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/guard/send-emails/counter"); var json = await response.Content.ReadAsStringAsync(); ``` **Response `200 OK`** (rate-limit policy only) ```json { "key": "send-emails", "exists": true, "rateLimit": { "current": 42, "max": 100, "window": 3600, "resetsIn": 2145 }, "concurrency": null } ``` **Response `200 OK`** (both policies on one key) ```json { "key": "invoice-api", "exists": true, "rateLimit": { "current": 42, "max": 100, "window": 3600, "resetsIn": 2145 }, "concurrency": { "active": 3, "max": 5, "resetsIn": 412 } } ``` Each block is `null` when its policy is not attached to the key. In the `concurrency` block, `active` is how many slots are held right now and `resetsIn` is the seconds until the earliest held lease frees itself (`0` when no slots are held). **Error Responses** | Status | Description | | ------ | ------------------------------ | | `401` | Missing or invalid API key | | `404` | No policies found for this key | *** Reset Counter [#reset-counter] **DELETE** /api/v1/guard/{key}/counter Clear (reset) the runtime state of all policies on a key: rate-limit counters go back to zero and every held concurrency slot is freed at once. Policies remain intact -- only the counter and slot state is cleared. For debugging purposes only. Resetting a concurrency key can briefly over-admit. Freeing every slot does not stop the workers holding them: those workers keep running while the freed slots go to new callers, so more than `max` executions can be in flight until the original holders finish. Reset idle keys, or accept the overshoot. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------ | -------- | -------------------- | | `key` | string | Yes | Guard key identifier | **Code Samples** ```bash curl -X DELETE "https://api.ainoflow.io/api/v1/guard/send-emails/counter" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.delete( "https://api.ainoflow.io/api/v1/guard/send-emails/counter", headers={"Authorization": "Bearer YOUR_API_KEY"} ) ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/guard/send-emails/counter", { method: "DELETE", headers: { Authorization: "Bearer YOUR_API_KEY" }, } ); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.DeleteAsync( "https://api.ainoflow.io/api/v1/guard/send-emails/counter"); ``` **Response `200 OK`** ```json { "key": "send-emails", "success": true } ``` **Error Responses** | Status | Description | | ------ | ------------------------------ | | `401` | Missing or invalid API key | | `404` | No policies found for this key | *** List Policies [#list-policies] **GET** /api/v1/guard/policies List the Guard policies of the current scope and project, **one page at a time**. **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ | | `page` | integer | No | Page number (1-based). Cannot be used with `offset`. A value below 1 is refused with `400`, not raised to 1. | | `offset` | integer | No | Policies to skip. Cannot be used with `page`. A negative value is refused with `400`, not raised to 0. | | `limit` | integer | No | Policies per page (0–5000, default: 100). `0` answers an empty page carrying the real `X-Total-Count`. | | `aggregate` | boolean | No | If `true`, returns an object with pagination metadata (default: `false`) | **This endpoint used to return every policy; it now returns the first 100 by default.** It did not go through the shared pagination at all, so `page`, `offset` and `limit` were accepted and ignored — a `?limit=-1` was answered `200` with the whole collection, where Data store, Files and Memory answered `400`. If your scope holds more than 100 policies, page through them or raise `limit`. **A pagination value outside its range is refused, not adjusted**, exactly as on every other list: a `limit` above 5000 or below 0, a `page` below 1 and a negative `offset` all answer `400` and nothing is listed. `limit=0` is the one value outside 1–5000 that is accepted — it asks how many policies there are without reading any. **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/guard/policies?page=1&limit=100" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/guard/policies", params={"page": 1, "limit": 100}, headers={"Authorization": "Bearer YOUR_API_KEY"} ) policies = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/guard/policies?page=1&limit=100", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const policies = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/guard/policies?page=1&limit=100"); var json = await response.Content.ReadAsStringAsync(); ``` **Response `200 OK`** One page of policies. `X-Total-Count`, `X-Page`, `X-Limit` and `X-Total-Pages` carry the pagination metadata; send `aggregate=true` to get it in the body instead. ```json [ { "id": "550e8400-e29b-41d4-a716-446655440000", "key": "send-emails", "type": "RateLimit", "configuration": { "max": 100, "window": 3600, "algorithm": "FixedWindow" }, "enabled": true, "failOpen": false, "priority": 0, "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T10:30:00Z" } ] ``` **Error Responses** | Status | Description | | ------ | -------------------------------------------------------------------------- | | `400` | Both `page` and `offset` supplied, or a pagination value outside its range | | `401` | Missing or invalid API key | *** List Policies for Key [#list-policies-for-key] **GET** /api/v1/guard/{key}/policies List all policies attached to a specific key. A key carrying both limits returns two policies -- one of `type` `"RateLimit"` and one of `type` `"Concurrency"`. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------ | -------- | -------------------- | | `key` | string | Yes | Guard key identifier | **Not paginated, deliberately.** A key carries at most two policies — one rate-limit and one concurrency — so the whole set always comes back and `page` / `offset` / `limit` are not accepted here. Pagination belongs to [List Policies](#list-policies), which lists a whole scope. **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/guard/send-emails/policies" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/guard/send-emails/policies", headers={"Authorization": "Bearer YOUR_API_KEY"} ) policies = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/guard/send-emails/policies", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const policies = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/guard/send-emails/policies"); var json = await response.Content.ReadAsStringAsync(); ``` **Response `200 OK`** ```json [ { "id": "550e8400-e29b-41d4-a716-446655440000", "key": "send-emails", "type": "RateLimit", "configuration": { "max": 100, "window": 3600, "algorithm": "FixedWindow" }, "enabled": true, "failOpen": false, "priority": 0, "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T10:30:00Z" } ] ``` *** Get Policy by ID [#get-policy-by-id] **GET** /api/v1/guard/policies/{id} Get a specific policy by its ID. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | GUID | Yes | Policy ID | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000", headers={"Authorization": "Bearer YOUR_API_KEY"} ) policy = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const policy = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000"); var json = await response.Content.ReadAsStringAsync(); ``` **Response `200 OK`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "key": "send-emails", "type": "RateLimit", "configuration": { "max": 100, "window": 3600, "algorithm": "FixedWindow" }, "enabled": true, "failOpen": false, "priority": 0, "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T10:30:00Z" } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `404` | Policy not found | *** Create Policy [#create-policy] **POST** /api/v1/guard/policies Create a new policy for a key. Each key can have at most one policy per type. Policies are normally auto-created via the check endpoint, but this allows explicit creation. **Request Body** | Parameter | Type | Required | Description | | --------------- | ------- | -------- | ---------------------------------------------------------------- | | `key` | string | Yes | Guard key identifier (max 512 characters) | | `type` | string | Yes | Policy type: `"RateLimit"` or `"Concurrency"` (case-insensitive) | | `configuration` | object | Yes | Type-specific configuration (see below) | | `enabled` | boolean | No | Whether the policy is active (default: `true`) | | `failOpen` | boolean | No | Allow execution on infrastructure errors (default: `false`) | **RateLimit configuration:** | Field | Type | Required | Description | | -------- | ------- | -------- | --------------------------------------------------- | | `max` | integer | Yes | Maximum executions allowed in window (1--1,000,000) | | `window` | integer | Yes | Window size in seconds (1--86,400) | **Concurrency configuration:** | Field | Type | Required | Description | | -------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | | `max` | integer | Yes | Maximum slots that may be held at once (1--1,000,000) | | `ttl` | integer | No | Slot lease TTL in seconds (1--86,400, default: 1800). A holder that never releases loses its slot when the lease expires. | | `window` | integer | No | Always stored as `0` -- a concurrency policy is a counting semaphore, not a fixed window. Any supplied value is replaced. | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/guard/policies" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "key": "api-call-user-123", "type": "RateLimit", "configuration": { "max": 50, "window": 3600 }, "enabled": true, "failOpen": false }' # Concurrency policy: 5 slots, 10-minute lease curl -X POST "https://api.ainoflow.io/api/v1/guard/policies" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "key": "report-workers", "type": "Concurrency", "configuration": { "max": 5, "ttl": 600 }, "enabled": true, "failOpen": false }' ``` ```python import requests headers = { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" } response = requests.post( "https://api.ainoflow.io/api/v1/guard/policies", headers=headers, json={ "key": "api-call-user-123", "type": "RateLimit", "configuration": {"max": 50, "window": 3600}, "enabled": True, "failOpen": False } ) policy = response.json() # Concurrency policy: 5 slots, 10-minute lease response = requests.post( "https://api.ainoflow.io/api/v1/guard/policies", headers=headers, json={ "key": "report-workers", "type": "Concurrency", "configuration": {"max": 5, "ttl": 600}, "enabled": True, "failOpen": False } ) concurrency_policy = response.json() ``` ```javascript const headers = { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }; const response = await fetch( "https://api.ainoflow.io/api/v1/guard/policies", { method: "POST", headers, body: JSON.stringify({ key: "api-call-user-123", type: "RateLimit", configuration: { max: 50, window: 3600 }, enabled: true, failOpen: false, }), } ); const policy = await response.json(); // Concurrency policy: 5 slots, 10-minute lease const concurrencyResponse = await fetch( "https://api.ainoflow.io/api/v1/guard/policies", { method: "POST", headers, body: JSON.stringify({ key: "report-workers", type: "Concurrency", configuration: { max: 5, ttl: 600 }, enabled: true, failOpen: false, }), } ); const concurrencyPolicy = await concurrencyResponse.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var body = new StringContent( JsonSerializer.Serialize(new { key = "api-call-user-123", type = "RateLimit", configuration = new { max = 50, window = 3600 }, enabled = true, failOpen = false }), Encoding.UTF8, "application/json"); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/guard/policies", body); // Concurrency policy: 5 slots, 10-minute lease var concurrencyBody = new StringContent( JsonSerializer.Serialize(new { key = "report-workers", type = "Concurrency", configuration = new { max = 5, ttl = 600 }, enabled = true, failOpen = false }), Encoding.UTF8, "application/json"); var concurrencyResponse = await client.PostAsync( "https://api.ainoflow.io/api/v1/guard/policies", concurrencyBody); ``` **Response `201 Created`** ```json { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "key": "api-call-user-123", "type": "RateLimit", "configuration": { "max": 50, "window": 3600, "algorithm": "FixedWindow" }, "enabled": true, "failOpen": false, "priority": 0, "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T10:30:00Z" } ``` **Response `201 Created`** (concurrency policy) A concurrency policy uses the same envelope with its own configuration. Omitting `ttl` stores the default lease TTL explicitly, so the policy always states its own deadline. ```json { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "key": "report-workers", "type": "Concurrency", "configuration": { "max": 5, "window": 0, "ttl": 600 }, "enabled": true, "failOpen": false, "priority": 0, "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T10:30:00Z" } ``` **Error Responses** | Status | Description | | ------ | --------------------------------------------------- | | `400` | Invalid type, invalid configuration, or invalid key | | `401` | Missing or invalid API key | | `409` | A policy of this type already exists for this key | *** Update Policy [#update-policy] **PUT** /api/v1/guard/policies/{id} Update an existing policy's configuration. The policy type and key cannot be changed -- only `configuration`, `enabled`, and `failOpen` are updatable. For a concurrency policy, raising `max` takes effect on the next check. Lowering it does not evict current holders: slots already leased stay leased until they are released or expire, so occupancy can sit above the new cap until the pool drains. A changed `ttl` applies to leases granted after the update, not to leases already in flight. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | GUID | Yes | Policy ID | **Request Body** | Parameter | Type | Required | Description | | --------------- | ------- | -------- | ----------------------------------------------------------- | | `configuration` | object | Yes | Updated type-specific configuration | | `enabled` | boolean | No | Whether the policy is active (default: `true`) | | `failOpen` | boolean | No | Allow execution on infrastructure errors (default: `false`) | **Code Samples** ```bash curl -X PUT "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "configuration": { "max": 200, "window": 7200 }, "enabled": true, "failOpen": false }' ``` ```python import requests response = requests.put( "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "configuration": {"max": 200, "window": 7200}, "enabled": True, "failOpen": False } ) policy = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000", { method: "PUT", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ configuration: { max: 200, window: 7200 }, enabled: true, failOpen: false, }), } ); const policy = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var body = new StringContent( JsonSerializer.Serialize(new { configuration = new { max = 200, window = 7200 }, enabled = true, failOpen = false }), Encoding.UTF8, "application/json"); var response = await client.PutAsync( "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000", body); ``` **Response `200 OK`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "key": "send-emails", "type": "RateLimit", "configuration": { "max": 200, "window": 7200, "algorithm": "FixedWindow" }, "enabled": true, "failOpen": false, "priority": 0, "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T14:20:00Z" } ``` **Error Responses** | Status | Description | | ------ | ------------------------------------------- | | `400` | Invalid configuration for the policy's type | | `401` | Missing or invalid API key | | `404` | Policy not found | *** Delete Policy by ID [#delete-policy-by-id] **DELETE** /api/v1/guard/policies/{id} Delete a single policy by ID. After deletion, the next counter check that supplies that type's parameters (`rateMax`/`rateWindow`, or `concurrencyMax`) auto-creates a new policy. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | GUID | Yes | Policy ID | **Code Samples** ```bash curl -X DELETE "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.delete( "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000", headers={"Authorization": "Bearer YOUR_API_KEY"} ) ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000", { method: "DELETE", headers: { Authorization: "Bearer YOUR_API_KEY" }, } ); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.DeleteAsync( "https://api.ainoflow.io/api/v1/guard/policies/550e8400-e29b-41d4-a716-446655440000"); ``` **Response `200 OK`** ```json { "success": true, "key": "send-emails" } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `404` | Policy not found | *** Delete All Policies for Key [#delete-all-policies-for-key] **DELETE** /api/v1/guard/{key}/policies Delete all policies attached to a specific key. This permanently removes the policies (unlike resetting the counter, which only clears counter and slot state). **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------ | -------- | -------------------- | | `key` | string | Yes | Guard key identifier | **Code Samples** ```bash curl -X DELETE "https://api.ainoflow.io/api/v1/guard/send-emails/policies" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.delete( "https://api.ainoflow.io/api/v1/guard/send-emails/policies", headers={"Authorization": "Bearer YOUR_API_KEY"} ) ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/guard/send-emails/policies", { method: "DELETE", headers: { Authorization: "Bearer YOUR_API_KEY" }, } ); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.DeleteAsync( "https://api.ainoflow.io/api/v1/guard/send-emails/policies"); ``` **Response `200 OK`** ```json { "key": "send-emails", "deleted": 1 } ``` **Error Responses** | Status | Description | | ------ | ------------------------------ | | `401` | Missing or invalid API key | | `404` | No policies found for this key | # Inbox (/docs/api/inbox) Create virtual email inboxes, receive and process emails, manage messages and attachments, and configure IMAP sources to pull from external mailboxes. Each inbox gets a unique hook address for receiving emails, with support for sender whitelisting, TTL-based retention, webhook notifications, and batch processing. **Base URL** ``` https://api.ainoflow.io ``` All endpoints require `Authorization: Bearer YOUR_API_KEY` header. Inbox Management [#inbox-management] *** Create Inbox [#create-inbox] **POST** /api/v1/inbox Create a new virtual email inbox. Returns the full inbox object with a server-generated hook address. **Request Body** `application/json` | Parameter | Type | Required | Description | | --------------------- | ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- | | `id` | string (uuid) | No | Inbox identifier. Omit it and one is generated for you; supply one that is already taken and the call is refused with `409` | | `name` | string | Yes | Human-readable label (max 100 characters) | | `allowedFromEmails` | string | No | Sender whitelist, comma or semicolon delimited. `null` = accept all senders | | `webhookUrl` | string | No | URL to receive push notifications for new messages. Must be a public host | | `ttlDays` | integer | No | Message retention in days (default: 30, must be positive) | | `maxMessageSizeBytes` | integer | No | Max email size in bytes (default: 10485760 = 10 MB) | | `maxAttachments` | integer | No | Max attachments per email (default: 10) | | `enabled` | boolean | No | Whether the inbox accepts incoming emails (default: true) | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/inbox" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Invoice intake", "webhookUrl": "https://myapp.com/webhook/inbox", "allowedFromEmails": "partner@corp.com, alerts@service.com", "ttlDays": 30 }' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/inbox", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={ "name": "Invoice intake", "webhookUrl": "https://myapp.com/webhook/inbox", "allowedFromEmails": "partner@corp.com, alerts@service.com", "ttlDays": 30 } ) inbox = response.json() print(inbox["hookAddress"]) ``` ```javascript const response = await fetch("https://api.ainoflow.io/api/v1/inbox", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ name: "Invoice intake", webhookUrl: "https://myapp.com/webhook/inbox", allowedFromEmails: "partner@corp.com, alerts@service.com", ttlDays: 30, }), }); const inbox = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.PostAsJsonAsync( "https://api.ainoflow.io/api/v1/inbox", new { name = "Invoice intake", webhookUrl = "https://myapp.com/webhook/inbox", allowedFromEmails = "partner@corp.com, alerts@service.com", ttlDays = 30 }); var inbox = await response.Content.ReadAsStringAsync(); ``` **Response** `201 Created` ```json { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "hookAddress": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "name": "Invoice intake", "allowedFromEmails": "partner@corp.com, alerts@service.com", "webhookUrl": "https://myapp.com/webhook/inbox", "ttlDays": 30, "maxMessageSizeBytes": 10485760, "maxAttachments": 10, "enabled": true, "createdAt": "2026-02-20T10:30:00Z", "createdBy": "api-key-name" } ``` **Error Responses** | Status | Description | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Invalid name, invalid parameters, or values out of range | | `401` | Missing or invalid API key | | `409` | The `id` in the request body is already in use by an existing inbox. Nothing was created, and the inbox that holds the identifier is untouched | | `429` | Maximum number of inboxes per scope reached | **`409` only happens when you choose the identifier.** Omit `id` and one is generated for you, so it can never collide. Send your own and you own its uniqueness: a taken one is refused rather than returning the existing inbox, so the response never leaves you unsure whether the inbox you are holding is the one this call created. Repeating the identical request cannot clear it -- read the inbox at that `id` and decide whether it is already the one you wanted, or create a new one under a different `id`. *** List Inboxes [#list-inboxes] **GET** /api/v1/inbox Get all inboxes for the authenticated scope and project. Supports pagination, sorting, and aggregate mode. **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------- | -------- | --------------------------------------------------------------------- | | `page` | integer | No | Page number (1-based, minimum `1`). Cannot be used with `offset`. | | `offset` | integer | No | Items to skip (minimum `0`). Cannot be used with `page`. | | `limit` | integer | No | Items per page (0-5000, default: 50) | | `sortBy` | string | No | Sort field: `Name`, `CreatedAt` (default: `CreatedAt`) | | `sortOrder` | string | No | Sort direction: `Asc`, `Desc` (default: `Desc`) | | `aggregate` | boolean | No | If `true`, returns object with pagination metadata (default: `false`) | **A pagination value outside its range is refused, not adjusted.** `limit=10000` answers `400 Bad Request` rather than being served as 5000, and `page=0` is refused rather than raised to 1 — so a page that comes back short is short because the data ran out. `limit=0` is valid and is the cheap way to ask **how many there are without reading them**: an empty array with the real `X-Total-Count`. **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/inbox?sortBy=Name&sortOrder=Asc&limit=50" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/inbox", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"sortBy": "Name", "sortOrder": "Asc", "limit": 50} ) inboxes = response.json() total = response.headers.get("X-Total-Count") ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/inbox?sortBy=Name&sortOrder=Asc&limit=50", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const inboxes = await response.json(); const total = response.headers.get("X-Total-Count"); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/inbox?sortBy=Name&sortOrder=Asc&limit=50"); var inboxes = await response.Content.ReadAsStringAsync(); var total = response.Headers.GetValues("X-Total-Count").FirstOrDefault(); ``` **Response** `200 OK` ```json [ { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "hookAddress": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "name": "Invoice intake", "enabled": true, "ttlDays": 30, "createdAt": "2026-02-20T10:30:00Z", "createdBy": "api-key-name", "updatedAt": "2026-02-21T14:00:00Z", "messageCount": 42, "unhandledCount": 5, "imapSourceCount": 1 } ] ``` **Headers:** `X-Total-Count`, `X-Page`, `X-Limit`, `X-Total-Pages` *** Get Inbox [#get-inbox] **GET** /api/v1/inbox/{id} Get detailed information about a specific inbox including full configuration and computed counts. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------- | | `id` | string (uuid) | Yes | Inbox identifier | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.get( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}", headers={"Authorization": "Bearer YOUR_API_KEY"} ) inbox = response.json() ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const inbox = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.GetAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}"); var inbox = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "hookAddress": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "name": "Invoice intake", "allowedFromEmails": "partner@corp.com, alerts@service.com", "webhookUrl": "https://myapp.com/webhook/inbox", "ttlDays": 30, "maxMessageSizeBytes": 10485760, "maxAttachments": 10, "enabled": true, "createdAt": "2026-02-20T10:30:00Z", "createdBy": "api-key-name", "updatedAt": "2026-02-21T14:00:00Z", "updatedBy": "api-key-name", "messageCount": 42, "unhandledCount": 5, "imapSourceCount": 1 } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `404` | Inbox not found | *** Update Inbox [#update-inbox] **PUT** /api/v1/inbox/{id} Update inbox configuration. Only provided fields are updated. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------- | | `id` | string (uuid) | Yes | Inbox identifier | **Request Body** `application/json` | Parameter | Type | Required | Description | | --------------------- | ------- | -------- | ------------------------------------------------------------ | | `name` | string | No | Updated label | | `allowedFromEmails` | string | No | Updated sender whitelist. `null` to remove filter | | `webhookUrl` | string | No | Updated webhook URL. `null` to remove. Must be a public host | | `ttlDays` | integer | No | Updated retention period (must be positive) | | `maxMessageSizeBytes` | integer | No | Updated max email size per message | | `maxAttachments` | integer | No | Updated max attachments per message | | `enabled` | boolean | No | Enable or disable the inbox | **Code Samples** ```bash curl -X PUT "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "allowedFromEmails": "partner@corp.com, new-sender@example.com", "ttlDays": 60, "enabled": true }' ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.put( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={ "allowedFromEmails": "partner@corp.com, new-sender@example.com", "ttlDays": 60, "enabled": True } ) result = response.json() ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}`, { method: "PUT", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ allowedFromEmails: "partner@corp.com, new-sender@example.com", ttlDays: 60, enabled: true, }), } ); const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.PutAsJsonAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}", new { allowedFromEmails = "partner@corp.com, new-sender@example.com", ttlDays = 60, enabled = true }); ``` **Response** `200 OK` ```json { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "hookAddress": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "updatedAt": "2026-02-21T14:00:00Z" } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `400` | Invalid parameter values | | `401` | Missing or invalid API key | | `404` | Inbox not found | *** Delete Inbox [#delete-inbox] **DELETE** /api/v1/inbox/{id} Delete an inbox and all its messages and attachments. Cascading delete permanently removes all associated data. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------- | | `id` | string (uuid) | Yes | Inbox identifier | **Code Samples** ```bash # Delete inbox (fails if unhandled messages exist) curl -X DELETE "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6" \ -H "Authorization: Bearer YOUR_API_KEY" # Force delete even with unhandled messages curl -X DELETE "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6?force=true" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.delete( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"force": "true"} ) ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}?force=true`, { method: "DELETE", headers: { Authorization: "Bearer YOUR_API_KEY" }, } ); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.DeleteAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}?force=true"); ``` **Response** `200 OK` ```json { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "hookAddress": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com" } ``` **Error Responses** | Status | Description | | ------ | ------------------------------------------------------ | | `400` | Inbox has unhandled messages and `force` is not `true` | | `401` | Missing or invalid API key | | `404` | Inbox not found | *** Message Management [#message-management] *** List Messages [#list-messages] **GET** /api/v1/inbox/{id}/messages Get a paginated list of messages for an inbox. Returns full message content including body text and attachment download URLs. This is the primary endpoint for polling-based consumption. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------- | | `id` | string (uuid) | Yes | Inbox identifier | **Query Parameters** | Parameter | Type | Required | Description | | ---------------- | ----------------- | -------- | --------------------------------------------------------------------------------------- | | `page` | integer | No | Page number (1-based, minimum `1`). Cannot be used with `offset`. | | `offset` | integer | No | Items to skip (minimum `0`). Cannot be used with `page`. | | `limit` | integer | No | Items per page (0-5000, default: 50) | | `isHandled` | boolean | No | Filter: `true` = handled only, `false` = unhandled only, omit = all | | `sortBy` | string | No | Sort field: `ReceivedAt`, `FromAddress`, `Subject`, `SizeBytes` (default: `ReceivedAt`) | | `sortOrder` | string | No | Sort direction: `Asc`, `Desc` (default: `Desc`) | | `receivedAfter` | string (datetime) | No | Only messages received after this timestamp (ISO 8601) | | `receivedBefore` | string (datetime) | No | Only messages received before this timestamp (ISO 8601) | | `fromAddress` | string | No | Filter by sender email (case-insensitive, partial match) | | `aggregate` | boolean | No | If `true`, returns object with pagination metadata (default: `false`) | **A pagination value outside its range is refused, not adjusted.** `limit=10000` answers `400 Bad Request` rather than being served as 5000, and `page=0` is refused rather than raised to 1 — so a page that comes back short is short because the data ran out. `limit=0` is valid and is the cheap way to ask **how many there are without reading them**: an empty array with the real `X-Total-Count`. On this endpoint that matters more than most, because every item carries its full body text and attachment metadata — a count costs a few bytes, a page costs a page of content. **Code Samples** ```bash # List unhandled messages curl "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages?isHandled=false&limit=10" \ -H "Authorization: Bearer YOUR_API_KEY" # Filter by sender and date range curl "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages?fromAddress=billing@&receivedAfter=2026-03-01T00:00:00Z" \ -H "Authorization: Bearer YOUR_API_KEY" # How many are unhandled, without fetching any of them curl -i "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages?isHandled=false&limit=0" \ -H "Authorization: Bearer YOUR_API_KEY" # -> [] with X-Total-Count: 7 ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.get( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/messages", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"isHandled": False, "limit": 10, "sortOrder": "Desc"} ) messages = response.json() total = response.headers.get("X-Total-Count") # Count only — no rows, real total count = requests.get( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/messages", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"isHandled": False, "limit": 0} ).headers["X-Total-Count"] ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const params = new URLSearchParams({ isHandled: "false", limit: "10", sortOrder: "Desc", }); const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/messages?${params}`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const messages = await response.json(); // Count only — no rows, real total const counted = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/messages?isHandled=false&limit=0`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const total = counted.headers.get("X-Total-Count"); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.GetAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/messages?isHandled=false&limit=10"); var messages = await response.Content.ReadAsStringAsync(); // Count only — no rows, real total var counted = await client.GetAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/messages?isHandled=false&limit=0"); var total = counted.Headers.GetValues("X-Total-Count").FirstOrDefault(); ``` **Response** `200 OK` ```json [ { "id": "7fa85f64-5717-4562-b3fc-2c963f66afa6", "inboxId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "fromAddress": "sender@example.com", "toAddress": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "subject": "Invoice #12345", "bodyText": "Please find attached invoice...", "sizeBytes": 245760, "receivedAt": "2026-02-20T10:30:00Z", "expiresAt": "2026-03-22T10:30:00Z", "isHandled": false, "handledAt": null, "hasAttachments": true, "attachmentCount": 1, "attachments": [ { "id": "9fa85f64-5717-4562-b3fc-2c963f66afa6", "fileName": "invoice.pdf", "sizeBytes": 245760, "contentType": "application/pdf", "downloadUrl": "https://storage.example.com/inbox/...?signature=...", "downloadUrlExpiresAt": "2026-02-20T11:30:00Z" } ] } ] ``` **Headers:** `X-Total-Count`, `X-Page`, `X-Limit`, `X-Total-Pages` **Error Responses** | Status | Description | | ------ | ---------------------------------------------------------------------------------------------------------- | | `400` | `limit` outside `0`-`5000`, `page` below `1`, `offset` below `0`, or `page` and `offset` supplied together | | `401` | Missing or invalid API key | | `404` | Inbox not found | *** Get Message [#get-message] **GET** /api/v1/inbox/{id}/messages/{messageId} Get full message details including HTML body and attachment download URLs. Read-only -- does not mark the message as handled. **Path Parameters** | Parameter | Type | Required | Description | | ----------- | ------------- | -------- | ------------------ | | `id` | string (uuid) | Yes | Inbox identifier | | `messageId` | string (uuid) | Yes | Message identifier | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages/7fa85f64-5717-4562-b3fc-2c963f66afa6" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" message_id = "7fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.get( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/messages/{message_id}", headers={"Authorization": "Bearer YOUR_API_KEY"} ) message = response.json() ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const messageId = "7fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/messages/${messageId}`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const message = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var messageId = "7fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.GetAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/messages/{messageId}"); var message = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "id": "7fa85f64-5717-4562-b3fc-2c963f66afa6", "inboxId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "fromAddress": "sender@example.com", "toAddress": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "subject": "Invoice #12345", "bodyText": "Please find attached invoice...", "bodyHtml": "

Please find attached invoice...

", "sizeBytes": 245760, "receivedAt": "2026-02-20T10:30:00Z", "expiresAt": "2026-03-22T10:30:00Z", "isHandled": false, "handledAt": null, "hasAttachments": true, "attachmentCount": 2, "attachments": [ { "id": "9fa85f64-5717-4562-b3fc-2c963f66afa6", "fileName": "invoice.pdf", "sizeBytes": 245760, "contentType": "application/pdf", "downloadUrl": "https://storage.example.com/inbox/...?signature=...", "downloadUrlExpiresAt": "2026-02-20T11:30:00Z" }, { "id": "afa85f64-5717-4562-b3fc-2c963f66afa6", "fileName": "receipt.jpg", "sizeBytes": 1048576, "contentType": "image/jpeg", "downloadUrl": "https://storage.example.com/inbox/...?signature=...", "downloadUrlExpiresAt": "2026-02-20T11:30:00Z" } ] } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `404` | Inbox or message not found | *** Handle Message [#handle-message] **POST** /api/v1/inbox/{id}/messages/{messageId}/handle Mark a message as processed and return its full content in one atomic operation. This is the primary consumption endpoint: poll for unhandled messages, then handle each one. Idempotent -- calling on an already-handled message returns it successfully with `200 OK`. **Path Parameters** | Parameter | Type | Required | Description | | ----------- | ------------- | -------- | ------------------ | | `id` | string (uuid) | Yes | Inbox identifier | | `messageId` | string (uuid) | Yes | Message identifier | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages/7fa85f64-5717-4562-b3fc-2c963f66afa6/handle" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" message_id = "7fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.post( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/messages/{message_id}/handle", headers={"Authorization": "Bearer YOUR_API_KEY"} ) handled = response.json() ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const messageId = "7fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/messages/${messageId}/handle`, { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, } ); const handled = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var messageId = "7fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.PostAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/messages/{messageId}/handle", null); var handled = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "id": "7fa85f64-5717-4562-b3fc-2c963f66afa6", "fromAddress": "sender@example.com", "toAddress": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "subject": "Invoice #12345", "bodyText": "Please find attached invoice...", "bodyHtml": "...", "sizeBytes": 245760, "receivedAt": "2026-02-20T10:30:00Z", "expiresAt": "2026-03-22T10:30:00Z", "isHandled": true, "handledAt": "2026-02-20T11:00:00Z", "hasAttachments": true, "attachmentCount": 1, "attachments": [ { "id": "9fa85f64-5717-4562-b3fc-2c963f66afa6", "fileName": "invoice.pdf", "sizeBytes": 245760, "contentType": "application/pdf", "downloadUrl": "https://storage.example.com/inbox/...?signature=...", "downloadUrlExpiresAt": "2026-02-20T12:00:00Z" } ] } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `404` | Inbox or message not found | *** Unhandle Message [#unhandle-message] **POST** /api/v1/inbox/{id}/messages/{messageId}/unhandle Revert a message to unhandled (clears `isHandled` and `handledAt`) and return its full content. Use this to re-queue a previously handled message for reprocessing. Idempotent -- calling on an already-unhandled message returns it successfully with `200 OK`. **Path Parameters** | Parameter | Type | Required | Description | | ----------- | ------------- | -------- | ------------------ | | `id` | string (uuid) | Yes | Inbox identifier | | `messageId` | string (uuid) | Yes | Message identifier | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages/7fa85f64-5717-4562-b3fc-2c963f66afa6/unhandle" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" message_id = "7fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.post( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/messages/{message_id}/unhandle", headers={"Authorization": "Bearer YOUR_API_KEY"} ) message = response.json() ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const messageId = "7fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/messages/${messageId}/unhandle`, { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, } ); const message = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var messageId = "7fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.PostAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/messages/{messageId}/unhandle", null); var message = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` Returns the full message (same shape as [Get Message](#get-message)) with `isHandled: false` and `handledAt: null`. **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `404` | Inbox or message not found | *** Batch Handle Messages [#batch-handle-messages] **POST** /api/v1/inbox/{id}/messages/handle Handle multiple messages in one call. Returns full message content for all handled messages. When `messageIds` is not provided, handles the first N unhandled messages. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------- | | `id` | string (uuid) | Yes | Inbox identifier | **Request Body** `application/json` | Parameter | Type | Required | Description | | ------------ | ------------- | -------- | -------------------------------------------------------------------------------- | | `messageIds` | array of uuid | No | Specific message IDs to handle. If omitted, handles first N unhandled messages | | `limit` | integer | No | Max messages to handle when `messageIds` is not provided (default: 10, max: 100) | **Code Samples** ```bash # Handle up to 10 unhandled messages at once curl -X POST "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages/handle" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "limit": 10 }' # Handle specific messages by ID curl -X POST "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages/handle" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messageIds": ["7fa85f64-5717-4562-b3fc-2c963f66afa6"] }' ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" # Handle up to 10 unhandled messages response = requests.post( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/messages/handle", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={"limit": 10} ) result = response.json() print(f"Handled {result['handledCount']} messages") ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/messages/handle`, { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ limit: 10 }), } ); const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.PostAsJsonAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/messages/handle", new { limit = 10 }); var result = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "items": [ { "id": "7fa85f64-5717-4562-b3fc-2c963f66afa6", "fromAddress": "sender@example.com", "subject": "Invoice #12345", "bodyText": "Please find attached invoice...", "bodyHtml": "...", "isHandled": true, "handledAt": "2026-02-20T11:00:00Z", "hasAttachments": true, "attachments": [...] } ], "handledCount": 1 } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `404` | Inbox not found | *** Delete Message [#delete-message] **DELETE** /api/v1/inbox/{id}/messages/{messageId} Permanently delete a message and all its attachments. **Path Parameters** | Parameter | Type | Required | Description | | ----------- | ------------- | -------- | ------------------ | | `id` | string (uuid) | Yes | Inbox identifier | | `messageId` | string (uuid) | Yes | Message identifier | **Code Samples** ```bash curl -X DELETE "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages/7fa85f64-5717-4562-b3fc-2c963f66afa6" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" message_id = "7fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.delete( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/messages/{message_id}", headers={"Authorization": "Bearer YOUR_API_KEY"} ) ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const messageId = "7fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/messages/${messageId}`, { method: "DELETE", headers: { Authorization: "Bearer YOUR_API_KEY" }, } ); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var messageId = "7fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.DeleteAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/messages/{messageId}"); ``` **Response** `200 OK` ```json { "id": "7fa85f64-5717-4562-b3fc-2c963f66afa6" } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `404` | Inbox or message not found | *** Attachments [#attachments] *** Download Attachment (Redirect) [#download-attachment-redirect] **GET** /api/v1/inbox/{id}/messages/{messageId}/attachments/{attachmentId} Download a message attachment. Returns a `302 Found` redirect to a short-lived pre-signed URL for direct file download. The attachment bytes are not sent through the API. The caller must follow the redirect to reach the bytes. Browsers and redirect-following clients (`curl -L`, `requests`, `fetch`, `HttpClient`) do so automatically; a client that does not follow redirects sees the `302` and no file content -- use [Get Attachment URL](#get-attachment-url) instead. **Path Parameters** | Parameter | Type | Required | Description | | -------------- | ------------- | -------- | --------------------- | | `id` | string (uuid) | Yes | Inbox identifier | | `messageId` | string (uuid) | Yes | Message identifier | | `attachmentId` | string (uuid) | Yes | Attachment identifier | **Code Samples** ```bash curl -L "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages/7fa85f64-5717-4562-b3fc-2c963f66afa6/attachments/9fa85f64-5717-4562-b3fc-2c963f66afa6" \ -H "Authorization: Bearer YOUR_API_KEY" \ -o invoice.pdf ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" message_id = "7fa85f64-5717-4562-b3fc-2c963f66afa6" attachment_id = "9fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.get( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/messages/{message_id}/attachments/{attachment_id}", headers={"Authorization": "Bearer YOUR_API_KEY"}, allow_redirects=True ) with open("invoice.pdf", "wb") as f: f.write(response.content) ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const messageId = "7fa85f64-5717-4562-b3fc-2c963f66afa6"; const attachmentId = "9fa85f64-5717-4562-b3fc-2c963f66afa6"; // fetch follows the 302 through to the pre-signed URL by default const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/messages/${messageId}/attachments/${attachmentId}`, { headers: { Authorization: "Bearer YOUR_API_KEY" }, redirect: "follow" } ); const blob = await response.blob(); ``` ```csharp // AllowAutoRedirect is on by default -- the 302 is followed through to the pre-signed URL var handler = new HttpClientHandler { AllowAutoRedirect = true }; var client = new HttpClient(handler); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var url = $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/messages/{messageId}/attachments/{attachmentId}"; var response = await client.GetAsync(url); var fileBytes = await response.Content.ReadAsByteArrayAsync(); ``` **Response** `302 Found` Redirects to a pre-signed download URL via the `Location` header. **Error Responses** | Status | Description | | ------ | --------------------------------------- | | `401` | Missing or invalid API key | | `404` | Inbox, message, or attachment not found | *** Get Attachment URL [#get-attachment-url] **GET** /api/v1/inbox/{id}/messages/{messageId}/attachments/{attachmentId}/url Get a pre-signed download URL for an attachment without following a redirect. Use this when your HTTP client does not follow redirects (common in automation platforms). **Path Parameters** | Parameter | Type | Required | Description | | -------------- | ------------- | -------- | --------------------- | | `id` | string (uuid) | Yes | Inbox identifier | | `messageId` | string (uuid) | Yes | Message identifier | | `attachmentId` | string (uuid) | Yes | Attachment identifier | **Query Parameters** | Parameter | Type | Required | Description | | --------------- | ------- | -------- | -------------------------------------------------------------- | | `expirySeconds` | integer | No | URL expiration in seconds (default: 3600, min: 60, max: 86400) | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages/7fa85f64-5717-4562-b3fc-2c963f66afa6/attachments/9fa85f64-5717-4562-b3fc-2c963f66afa6/url?expirySeconds=7200" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" message_id = "7fa85f64-5717-4562-b3fc-2c963f66afa6" attachment_id = "9fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.get( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/messages/{message_id}/attachments/{attachment_id}/url", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"expirySeconds": 7200} ) url_data = response.json() # Use url_data["downloadUrl"] -- no auth needed ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const messageId = "7fa85f64-5717-4562-b3fc-2c963f66afa6"; const attachmentId = "9fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/messages/${messageId}/attachments/${attachmentId}/url?expirySeconds=7200`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const urlData = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var url = $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/messages/{messageId}/attachments/{attachmentId}/url?expirySeconds=7200"; var response = await client.GetAsync(url); var urlData = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "downloadUrl": "https://storage.example.com/inbox/...?signature=...", "downloadUrlExpiresAt": "2026-02-20T12:30:00Z" } ``` Pre-signed URLs can be shared and used without API authentication until they expire. **Error Responses** | Status | Description | | ------ | --------------------------------------- | | `401` | Missing or invalid API key | | `404` | Inbox, message, or attachment not found | *** IMAP Sources [#imap-sources] IMAP sources allow an inbox to automatically pull emails from external mailboxes (e.g., Gmail, Outlook, corporate IMAP servers). Each inbox can have multiple IMAP sources. Emails fetched via IMAP go through the same processing pipeline as directly received emails -- deduplication, whitelisting, TTL, and webhook notifications all apply. *** List IMAP Sources [#list-imap-sources] **GET** /api/v1/inbox/{inboxId}/imap List all IMAP sources configured for an inbox. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------- | | `inboxId` | string (uuid) | Yes | Inbox identifier | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/imap" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.get( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/imap", headers={"Authorization": "Bearer YOUR_API_KEY"} ) configs = response.json() ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/imap`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const configs = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.GetAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/imap"); var configs = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "items": [ { "id": "4fa85f64-5717-4562-b3fc-2c963f66afa6", "inboxId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "host": "imap.gmail.com", "port": 993, "useSsl": true, "username": "invoices@mycompany.com", "folder": "INBOX", "fetchIntervalMinutes": 5, "afterFetchAction": "none", "lastFetchedAt": "2026-03-09T10:00:00Z", "lastError": null, "lastErrorAt": null, "createdAt": "2026-03-01T09:00:00Z", "updatedAt": null } ] } ``` The `password` field is never returned in any response. **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `404` | Inbox not found | *** Add IMAP Source [#add-imap-source] **POST** /api/v1/inbox/{inboxId}/imap Add an IMAP source to an inbox. The background service begins fetching on the next scheduled cycle. Only one configuration per (host, username, folder) combination is allowed per inbox. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------- | | `inboxId` | string (uuid) | Yes | Inbox identifier | **Request Body** `application/json` | Parameter | Type | Required | Description | | ---------------------- | ------------- | -------- | ----------------------------------------------------------------- | | `id` | string (uuid) | No | IMAP source identifier (auto-generated if omitted) | | `host` | string | Yes | IMAP server hostname (max 255 characters) | | `port` | integer | No | IMAP port (default: 993) | | `useSsl` | boolean | No | Use SSL/TLS (default: true) | | `username` | string | Yes | IMAP account username or email address | | `password` | string | Yes | IMAP account password or app password | | `folder` | string | No | IMAP folder to watch (default: `INBOX`) | | `fetchIntervalMinutes` | integer | No | Polling interval in minutes (min: 1, max: 1440, default: 5) | | `afterFetchAction` | string | No | Action after successful fetch: `none` (default), `read`, `delete` | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/imap" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "host": "imap.gmail.com", "port": 993, "useSsl": true, "username": "invoices@mycompany.com", "password": "app-password-here", "folder": "INBOX", "fetchIntervalMinutes": 5, "afterFetchAction": "none" }' ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.post( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/imap", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={ "host": "imap.gmail.com", "port": 993, "useSsl": True, "username": "invoices@mycompany.com", "password": "app-password-here", "folder": "INBOX", "fetchIntervalMinutes": 5, "afterFetchAction": "none" } ) config = response.json() ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/imap`, { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ host: "imap.gmail.com", port: 993, useSsl: true, username: "invoices@mycompany.com", password: "app-password-here", folder: "INBOX", fetchIntervalMinutes: 5, afterFetchAction: "none", }), } ); const config = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.PostAsJsonAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/imap", new { host = "imap.gmail.com", port = 993, useSsl = true, username = "invoices@mycompany.com", password = "app-password-here", folder = "INBOX", fetchIntervalMinutes = 5, afterFetchAction = "none" }); ``` **Response** `201 Created` ```json { "id": "4fa85f64-5717-4562-b3fc-2c963f66afa6", "inboxId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "host": "imap.gmail.com", "port": 993, "useSsl": true, "username": "invoices@mycompany.com", "folder": "INBOX", "fetchIntervalMinutes": 5, "afterFetchAction": "none", "lastFetchedAt": null, "lastError": null, "lastErrorAt": null, "createdAt": "2026-03-09T10:00:00Z" } ``` **Error Responses** | Status | Description | | ------ | ------------------------------------------------------------------------------------- | | `400` | Invalid parameters | | `401` | Missing or invalid API key | | `404` | Inbox not found | | `409` | An IMAP source with the same host, username, and folder already exists for this inbox | *** Update IMAP Source [#update-imap-source] **PUT** /api/v1/inbox/{inboxId}/imap/{id} Update an IMAP source configuration. Only provided fields are updated. Omit `password` to keep the existing credential. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------------- | | `inboxId` | string (uuid) | Yes | Inbox identifier | | `id` | string (uuid) | Yes | IMAP source identifier | **Request Body** `application/json` | Parameter | Type | Required | Description | | ---------------------- | ------- | -------- | --------------------------------------------------- | | `host` | string | No | Updated IMAP server hostname | | `port` | integer | No | Updated port | | `useSsl` | boolean | No | Updated SSL setting | | `username` | string | No | Updated username | | `password` | string | No | New password (omit to keep existing) | | `folder` | string | No | Updated IMAP folder | | `fetchIntervalMinutes` | integer | No | Updated polling interval (min: 1, max: 1440) | | `afterFetchAction` | string | No | Updated post-fetch action: `none`, `read`, `delete` | **Code Samples** ```bash curl -X PUT "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/imap/4fa85f64-5717-4562-b3fc-2c963f66afa6" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "fetchIntervalMinutes": 10, "afterFetchAction": "read" }' ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" imap_id = "4fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.put( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/imap/{imap_id}", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={"fetchIntervalMinutes": 10, "afterFetchAction": "read"} ) ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const imapId = "4fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/imap/${imapId}`, { method: "PUT", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ fetchIntervalMinutes: 10, afterFetchAction: "read", }), } ); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var imapId = "4fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.PutAsJsonAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/imap/{imapId}", new { fetchIntervalMinutes = 10, afterFetchAction = "read" }); ``` **Response** `200 OK` ```json { "id": "4fa85f64-5717-4562-b3fc-2c963f66afa6", "inboxId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "updatedAt": "2026-03-09T11:00:00Z" } ``` **Error Responses** | Status | Description | | ------ | --------------------------------------------------------------------------- | | `400` | Invalid parameters | | `401` | Missing or invalid API key | | `404` | IMAP source or inbox not found | | `409` | Another IMAP source with the same host, username, and folder already exists | *** Delete IMAP Source [#delete-imap-source] **DELETE** /api/v1/inbox/{inboxId}/imap/{id} Delete an IMAP source. The inbox continues to receive emails via other configured sources. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------------- | | `inboxId` | string (uuid) | Yes | Inbox identifier | | `id` | string (uuid) | Yes | IMAP source identifier | **Code Samples** ```bash curl -X DELETE "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/imap/4fa85f64-5717-4562-b3fc-2c963f66afa6" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" imap_id = "4fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.delete( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/imap/{imap_id}", headers={"Authorization": "Bearer YOUR_API_KEY"} ) ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const imapId = "4fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/imap/${imapId}`, { method: "DELETE", headers: { Authorization: "Bearer YOUR_API_KEY" }, } ); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var imapId = "4fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.DeleteAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/imap/{imapId}"); ``` **Response** `200 OK` ```json { "id": "4fa85f64-5717-4562-b3fc-2c963f66afa6", "inboxId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" } ``` **Error Responses** | Status | Description | | ------ | ------------------------------ | | `401` | Missing or invalid API key | | `404` | IMAP source or inbox not found | *** Trigger IMAP Fetch (All Sources) [#trigger-imap-fetch-all-sources] **POST** /api/v1/inbox/{inboxId}/imap/fetch Trigger an immediate IMAP fetch for all sources attached to the inbox instead of waiting for the next scheduled cycle. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------- | | `inboxId` | string (uuid) | Yes | Inbox identifier | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/imap/fetch" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.post( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/imap/fetch", headers={"Authorization": "Bearer YOUR_API_KEY"} ) result = response.json() ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/imap/fetch`, { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, } ); const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.PostAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/imap/fetch", null); var result = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "results": [ { "imapConfigId": "4fa85f64-5717-4562-b3fc-2c963f66afa6", "success": true, "fetchedCount": 3, "ingestedCount": 2, "errorMessage": null } ] } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `403` | Inbox is disabled | | `404` | Inbox not found | *** Trigger IMAP Fetch (Single Source) [#trigger-imap-fetch-single-source] **POST** /api/v1/inbox/{inboxId}/imap/{id}/fetch Trigger an immediate IMAP fetch for a single IMAP source. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------------- | | `inboxId` | string (uuid) | Yes | Inbox identifier | | `id` | string (uuid) | Yes | IMAP source identifier | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/inbox/3fa85f64-5717-4562-b3fc-2c963f66afa6/imap/4fa85f64-5717-4562-b3fc-2c963f66afa6/fetch" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" ``` ```python import requests inbox_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" imap_id = "4fa85f64-5717-4562-b3fc-2c963f66afa6" response = requests.post( f"https://api.ainoflow.io/api/v1/inbox/{inbox_id}/imap/{imap_id}/fetch", headers={"Authorization": "Bearer YOUR_API_KEY"} ) result = response.json() ``` ```javascript const inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; const imapId = "4fa85f64-5717-4562-b3fc-2c963f66afa6"; const response = await fetch( `https://api.ainoflow.io/api/v1/inbox/${inboxId}/imap/${imapId}/fetch`, { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, } ); const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var inboxId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; var imapId = "4fa85f64-5717-4562-b3fc-2c963f66afa6"; var response = await client.PostAsync( $"https://api.ainoflow.io/api/v1/inbox/{inboxId}/imap/{imapId}/fetch", null); var result = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` Same response shape as fetch all; `results` contains one item for the requested IMAP source. **Error Responses** | Status | Description | | ------ | ------------------------------ | | `401` | Missing or invalid API key | | `403` | Inbox is disabled | | `404` | Inbox or IMAP source not found | # API Endpoints (/docs/api) Base URL: `https://api.ainoflow.io` Authentication [#authentication] Bearer token via `Authorization` header: ``` Authorization: Bearer YOUR_API_KEY ``` **Existing account:** Sign in at [ainoflow.io](https://www.ainoflow.io), choose **Open App**, select the project, open **Keys**, and choose **Issue key**. **First-time account:** [Create an account](https://www.ainoflow.io/signup), then choose **Open App**. See [Account self-onboarding](/docs/api/account#self-onboarding) for the first-tenant API. After a project exists, additional keys are issued from **Keys → Issue key**. Store the key in an environment or secret manager. Do **not** paste long-lived keys into an AI chat. Services [#services] Idempotency keys and execution protection Rate limiting and cost control Document conversion and OCR processing JSON key-value storage Binary file storage (up to 100 MB) Markdown documents with revisions, wiki-links, and ranked recall Email receiving and processing Read-only access to scopes, projects, and API keys for the authenticated caller Response Format [#response-format] Success Responses [#success-responses] Success is indicated by HTTP status codes (`200 OK`, `201 Created`). Response body contains the resource data. List Responses [#list-responses] List endpoints support two modes via `aggregate` query parameter: | Mode | `aggregate` | Response | | -------------- | ----------- | ---------------------------------------------------------------------------------------------- | | **Default** | `false` | Items array only. Pagination in headers: `X-Total-Count`, `X-Page`, `X-Limit`, `X-Total-Pages` | | **Aggregated** | `true` | Object: `{ items, totalCount, page, pageSize, totalPages }` | Error Responses [#error-responses] Errors follow the [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807) Problem Details format. The `type` URI is a stable identifier for the failure and is not promised to resolve. | Failure code | HTTP status | Problem type URI | | -------------------------- | ----------- | ------------------------------------------------------------ | | `SCHEMA_VALIDATION_FAILED` | `400` | `https://docs.ainoflow.io/problems/schema-validation-failed` | | `NOT_FOUND` | `404` | `https://docs.ainoflow.io/problems/not-found` | | `CONFLICT` | `409` | `https://docs.ainoflow.io/problems/conflict` | | `PRECONDITION_FAILED` | `412` | `https://docs.ainoflow.io/problems/precondition-failed` | | `ITEM_TOO_LARGE` | `413` | `https://docs.ainoflow.io/problems/item-too-large` | | `RATE_LIMITED` | `429` | `https://docs.ainoflow.io/problems/rate-limited` | Statuses outside this vocabulary, including `401`, `403`, and `500`, use `about:blank`. A refusal that has a failure code also carries it as data, so a client branches on fields rather than parsing prose. These are the same fields an MCP tool failure returns for the same refusal: | Field | Type | Present | | -------------- | ------- | ---------------------------------------------------------------------------------------------------------- | | `code` | string | Always, for the six codes above | | `retryable` | boolean | Always beside `code`; `true` only for `RATE_LIMITED` — the identical call succeeds once the limit frees up | | `currentBytes` | number | On `ITEM_TOO_LARGE` when the size was measured: the rejected payload's size | | `maximumBytes` | number | On `ITEM_TOO_LARGE` when the size was measured: the effective limit | | `invalidPath` | string | On a JSON shape refusal: the JSON path it failed at (`$` for the root) | Fields with no value are omitted, never sent as `null`. `401`, `403` and `500` carry none of them. ```json { "type": "https://docs.ainoflow.io/problems/item-too-large", "title": "JSON data exceeds the size limit", "status": 413, "instance": "/api/v1/storage/json/state/run-42", "code": "ITEM_TOO_LARGE", "retryable": false, "currentBytes": 10640, "maximumBytes": 10240 } ``` # Memory (/docs/api/memory) Agent memory: markdown documents addressed by **category** (namespace) and **key**, stored verbatim. Every document carries version history, a `[[wiki-link]]` graph, and full-text recall ranked by match strength, inbound references, and freshness. Documents are organized by category; the default category is `main`. Memory is the markdown layer — retrieved by meaning. For addressed JSON records use [Data store](/docs/api/storage); for binary artifacts use [Files](/docs/api/files). **Base URL** ``` https://api.ainoflow.io ``` All endpoints require `Authorization: Bearer YOUR_API_KEY` header. Document model [#document-model] A document body is raw markdown, returned byte-identical to what was written. Four things are derived from it on every write, inside the same transaction: | Derived | Source in the markdown | | --------------- | ----------------------------------------------------------------------------- | | `title` | Frontmatter `title`, otherwise the first `#` heading | | `slug` | Frontmatter `slug` (an alternative link target) | | Heading outline | ATX headings, as `[{ "level", "text", "line" }]` — powers section breadcrumbs | | Links | `[[target]]` and `[[target\|display]]` occurrences | Frontmatter is parsed into a filterable object; `tags` stay filters, never link targets. A link target resolves by exact key, then unique title, then unique frontmatter `slug`, then fuzzy title match. Each link reports one status: | Status | Meaning | | ----------- | ---------------------------------------------------------------------------------------- | | `resolved` | Points at a live document | | `stale` | The last resolution survives, but the target text no longer matches anything | | `ambiguous` | More than one document matches the target text | | `dangling` | Nothing matches yet — the edge is kept, and resolves when a matching document is created | **Conditional writes** `PUT`, `POST .../edit`, and `POST .../move` accept `If-Match: ""` (or `If-Match: *` for "any live document"). A weak validator that carries a tag (`W/""`) works too — the `W/` prefix is stripped — as does a comma-separated list, of which the first tag is used. A mismatch returns `412 Precondition Failed` and leaves the document untouched; the message distinguishes *the document has been modified* from *no active document at this address*. Clients without header control can send `?expectedVersion=` instead. **An `If-Match` that names no entity tag is refused, never ignored.** All three endpoints answer `400 Bad Request` when the header is present but carries no usable tag — `If-Match: ""`, `If-Match: W/""`, a bare `If-Match: W/`, or whitespace. They answered `200` until this refusal was added: an unusable header collapsed to the same "no precondition" an absent one produces, so the conditional write ran unconditionally and the response reported success — the lost-update protection you asked for was gone and nothing said so. **Omitting `If-Match` entirely is unchanged** and still means an unconditional write. Note that `W/""` is still accepted; only `W/""` and a bare `W/` are not. **Every memory write can answer `409 Conflict`.** All five of them — `PUT`, `POST` (create), `POST .../edit`, `POST .../move`, and `DELETE`. Losing a race to a concurrent writer is a refusal all five could always meet and previously reported as `500`; they now name it. Nothing is written when it happens. **`409` and `412` are different refusals, and the difference is whose guard failed.** `412 Precondition Failed` means the `If-Match` / `expectedVersion` **you sent** did not hold — a stale tag, a stale version, or an address that holds nothing. `409 Conflict` means nothing of yours failed. Every write is applied against the exact version it was computed from, guard or no guard, so a concurrent writer landing in between makes your write describe a document that no longer exists, and it is refused rather than overwriting that writer. It is `409` rather than `412` because `412` would name a precondition you never set — which is why a caller that sends no guard still cannot lose an update; it just learns about the collision as a conflict. **`409` carries two different messages, and they want different things from you:** | The message says | What happened | What to do | | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | `Document with category '' and key '' already exists` | The address is occupied — a create against a live key, a move onto a taken destination, or an upsert that raced a concurrent create | Pick another address, or overwrite deliberately with `PUT` | | `The document was modified by another writer while this was in flight...` | You lost the race; the address holds what you expected, it simply moved | Re-read the document and re-apply your change to what you find | Repeating either call unchanged cannot succeed: the body or edit you sent was computed from a version that is gone. **[Delete is the exception](#delete-document)** — its message and its recovery differ, on purpose. **Limits** | Limit | Default | | ------------------------ | -------------------- | | Key length | 1024 characters | | Category length | 100 characters | | Document size | 100 KB (UTF-8 bytes) | | Live documents per scope | 10 000 | A category or key outside its bounds is refused with `400 Bad Request` before anything is written. **The document allowance counts what you hold, not what you wrote this month.** It is evaluated against the live documents your **scope** holds at the moment of the write, summed across every project in it, the same way the Data store and Files allowances are counted. Documents that are deleted or have expired stop counting. Because it measures what is held rather than what was spent, it does not reset on a billing cycle. Deleting documents frees room immediately; until room is freed, retrying the same write answers `429 Too Many Requests` again. Nothing is written when it does. The figure the refusal is measured against is the one `GET /api/v1/capabilities` publishes as `limits.memoryDocumentCount`, so you can read your allowance before meeting it. **Only a write that adds a document is charged.** * `POST /api/v1/memory/{category}/{key}` is charged whenever it creates. A create at an address that is already taken answers `409` and is charged nothing -- it stores nothing, so it is never refused for being out of room. * `PUT /api/v1/memory/{category}/{key}` is charged only on the branch that creates a new document. Replacing the body of a document that already exists is never refused by the allowance. * `POST /api/v1/memory/{category}/{key}/edit` is charged nothing and does not declare `429` at all -- it only ever rewrites a document that already exists, so it cannot add one. The absence of `429` from its error table is the contract, not an omission. **Pagination is refused, not adjusted.** Every list and search endpoint reads the same three parameters: `page` (1 or greater), `offset` (0 or greater), and `limit` (**0-5000**, default 100 — 20 on search). A value outside its range answers `400 Bad Request`, never a clamped or defaulted page. A request for 10 000 rows used to come back as 5000 under a success, which a client cannot tell from there being 5000 rows: it reads the page as complete and stops. **`limit=0` is valid** and is the cheap "how many are there, without the rows": an empty page carrying the real `X-Total-Count` (or `totalCount` under `aggregate=true`). `page` and `offset` stay mutually exclusive — supplying both is also `400`. *** Upsert Document (Create or Replace) [#upsert-document-create-or-replace] **PUT** /api/v1/memory/{category}/{key} Insert or replace the markdown body at an address. Creates the document if it does not exist, or replaces its body if it does. The body is stored verbatim. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping (namespace), max 100 characters | | `key` | string | Yes | Unique identifier within category, max 1024 characters (path-like keys allowed) | **Query Parameters** | Parameter | Type | Required | Description | | ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `expectedVersion` | long | No | Version precondition (alternative to `If-Match`) | | `expiresAt` | string | No | Expiration date (ISO 8601). Cannot be used with `expiresMs`. Must be in the future — an expiry that has already passed is refused with `400` and nothing is written. | | `expiresMs` | long | No | Expiration in milliseconds from now. Cannot be used with `expiresAt`. | **Headers** | Header | Required | Description | | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `If-Match` | No | `""` (or `W/""`) writes only when unchanged; `*` writes only when a live document exists. Present but naming no tag (`""`, `W/""`, a bare `W/`, whitespace) is refused with `400`; omit the header for an unconditional write | **Request Body** `text/markdown` Raw markdown. An empty body is rejected. **Code Samples** ```bash curl -X PUT "https://api.ainoflow.io/api/v1/memory/main/projects/apollo" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: text/markdown" \ --data-binary $'---\ntitle: Apollo\ntags: [project, active]\n---\n\n# Apollo\n\nPipeline migration owned by [[people/dana]].\n\n## Status\n\nPhase 2 of 3 complete.\n' # Conditional write: only if unchanged since the ETag you read curl -X PUT "https://api.ainoflow.io/api/v1/memory/main/projects/apollo" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: text/markdown" \ -H 'If-Match: "7f8e9d0c1b2a3f4e5d6c7b8a9f0e1d2c"' \ --data-binary @apollo.md # 400 -- the header is present but names no entity tag, so the precondition # cannot be evaluated. Omit If-Match entirely for an unconditional write. curl -X PUT "https://api.ainoflow.io/api/v1/memory/main/projects/apollo" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: text/markdown" \ -H 'If-Match: ""' \ --data-binary @apollo.md ``` ```python import requests markdown = """--- title: Apollo tags: [project, active] --- # Apollo Pipeline migration owned by [[people/dana]]. ## Status Phase 2 of 3 complete. """ response = requests.put( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "text/markdown" }, data=markdown.encode("utf-8") ) # response.status_code == 200 (replaced) or 201 (created) # 409 -- a concurrent writer landed first and nothing was stored. No precondition of # yours failed, which is why it is not 412. Re-read, then reissue the write. if response.status_code == 409: raise RuntimeError(response.json()["title"]) result = response.json() ``` ```javascript const markdown = `--- title: Apollo tags: [project, active] --- # Apollo Pipeline migration owned by [[people/dana]]. `; const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo", { method: "PUT", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "text/markdown", }, body: markdown, } ); // response.status === 200 (replaced) or 201 (created) // 409 -- a concurrent writer landed first and nothing was stored. Nothing of yours // failed, which is why it is not 412. Re-read, then reissue the write. if (response.status === 409) { const problem = await response.json(); throw new Error(`Conflict — re-read and reissue: ${problem.title}`); } const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var markdown = "# Apollo\n\nPipeline migration owned by [[people/dana]].\n"; var response = await client.PutAsync( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo", new StringContent(markdown, Encoding.UTF8, "text/markdown")); // response.StatusCode == 200 (replaced) or 201 (created) // 409 Conflict -- a concurrent writer landed first and nothing was stored. Re-read // the document and reissue the write; repeating this call unchanged cannot succeed. if (response.StatusCode == HttpStatusCode.Conflict) { throw new InvalidOperationException(await response.Content.ReadAsStringAsync()); } ``` **Response** * **`200 OK`** -- body replaced (existing document) * **`201 Created`** -- new document created ```json { "category": "main", "key": "projects/apollo", "version": 2, "etag": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "size": 148, "expiresAt": null } ``` **Headers:** `Location`, `ETag`, `X-Category`, `X-Key`, `X-Version` **Error Responses** | Status | Description | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Empty body, key or category rejected, an `expiresAt` that is already past, or an `If-Match` header that is present but names no entity tag | | `401` | Missing or invalid API key | | `409` | A concurrent writer landed first and nothing was written -- the document moved, or the address was taken by a concurrent create. No precondition of yours failed, which is why this is not `412` -- re-read the document and reissue the write | | `412` | `If-Match` / `expectedVersion` precondition failed -- the document has been modified, or no active document exists at the address | | `413` | The body exceeds the document size limit in force for your scope | | `429` | The scope already holds as many live documents as its allowance permits, **and this write would add one**. Replacing the body of an existing document is never refused here. Nothing is written; free room by deleting documents | *** Create Document [#create-document] **POST** /api/v1/memory/{category}/{key} Create a markdown document at an address that is currently free. Returns `409 Conflict` if a live document already occupies it -- use `PUT` to overwrite. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------------ | | `category` | string | Yes | Logical grouping (namespace), max 100 characters | | `key` | string | Yes | Unique identifier within category, max 1024 characters | **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `expiresAt` | string | No | Expiration date (ISO 8601). Cannot be used with `expiresMs`. Must be in the future — an expiry that has already passed is refused with `400` and nothing is written. | | `expiresMs` | long | No | Expiration in milliseconds from now. Cannot be used with `expiresAt`. | **Request Body** `text/markdown` Raw markdown. An empty body is rejected. **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/memory/main/people/dana" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: text/markdown" \ --data-binary $'# Dana\n\nRuns the pipeline work on [[projects/apollo]].\n' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/memory/main/people/dana", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "text/markdown" }, data="# Dana\n\nRuns the pipeline work on [[projects/apollo]].\n".encode("utf-8") ) result = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/people/dana", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "text/markdown", }, body: "# Dana\n\nRuns the pipeline work on [[projects/apollo]].\n", } ); const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/memory/main/people/dana", new StringContent("# Dana\n", Encoding.UTF8, "text/markdown")); ``` **Response** `201 Created` ```json { "category": "main", "key": "people/dana", "version": 1, "etag": "d41d8cd98f00b204e9800998ecf8427e", "size": 52, "expiresAt": null } ``` **Headers:** `Location`, `ETag`, `X-Category`, `X-Key`, `X-Version` **Error Responses** | Status | Description | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Empty body, key or category rejected, or an `expiresAt` that is already past | | `401` | Missing or invalid API key | | `409` | Two refusals share this code: a live document already exists at this category/key, or the create lost a race with a concurrent writer and stored nothing. Only the first is about the address being taken -- the message says which | | `413` | The body exceeds the document size limit in force for your scope | | `429` | The scope already holds as many live documents as its allowance permits, and this create would add one. A create at a taken address answers `409` instead, because it stores nothing. Nothing is written; free room by deleting documents | *** Read Document [#read-document] **GET** /api/v1/memory/{category}/{key} Retrieve the raw markdown body, byte-identical to what was written. The response is `text/markdown`, not a JSON envelope; the concurrency metadata travels in headers. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Code Samples** ```bash curl -i "https://api.ainoflow.io/api/v1/memory/main/projects/apollo" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo", headers={"Authorization": "Bearer YOUR_API_KEY"} ) markdown = response.text etag = response.headers["ETag"] # feed back as If-Match on the next write version = response.headers["X-Version"] ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const markdown = await response.text(); const etag = response.headers.get("ETag"); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo"); var markdown = await response.Content.ReadAsStringAsync(); var etag = response.Headers.ETag?.Tag; ``` **Response** `200 OK` `text/markdown` ```markdown --- title: Apollo tags: [project, active] --- # Apollo Pipeline migration owned by [[people/dana]]. ## Status Phase 2 of 3 complete. ``` **Headers:** `ETag`, `Last-Modified`, `X-Version`, `X-Category`, `X-Key` **Error Responses** | Status | Description | | ------ | --------------------------------------- | | `401` | Missing or invalid API key | | `404` | Document not found, deleted, or expired | *** Get Document Metadata [#get-document-metadata] **GET** /api/v1/memory/{category}/{key}/meta Get addressing, concurrency, authorship, recall signals, and the heading outline. Never returns the markdown body. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/meta" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/meta", headers={"Authorization": "Bearer YOUR_API_KEY"} ) metadata = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/meta", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const metadata = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/meta"); var metadata = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "category": "main", "key": "projects/apollo", "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-16T09:12:00Z", "expiresAt": null, "version": 3, "etag": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "size": 148, "createdBy": "kid_01HXXXXX", "updatedBy": "kid_01HXXXXX", "title": "Apollo", "slug": "apollo", "referenceCount": 4, "lastReferencedAt": "2026-01-16T08:40:00Z", "headings": [ { "level": 1, "text": "Apollo", "line": 6 }, { "level": 2, "text": "Status", "line": 10 } ] } ``` `referenceCount` is the number of inbound wiki-links (the recall weight signal); `lastReferencedAt` is when an inbound link was last created. **Headers:** `X-Category`, `X-Key`, `ETag`, `X-Version` **Error Responses** | Status | Description | | ------ | --------------------------------------- | | `401` | Missing or invalid API key | | `404` | Document not found, deleted, or expired | *** Edit Document [#edit-document] **POST** /api/v1/memory/{category}/{key}/edit Apply a partial edit without resending the whole body. | Operation | Behavior | | ------------- | ------------------------------------------------------------------------------------- | | `str_replace` | Replaces `oldText` with `newText`. Rejected unless `oldText` occurs **exactly once**. | | `insert` | Inserts `newText` as a new line at the 1-based `line`, shifting the rest down. | | `append` | Appends `newText` to the end of the document. | `str_replace` is compare-and-swap: if the old text is missing (`409`) or occurs more than once (`409`), nothing is written. Read the document, or narrow the anchor text, and retry. **`409` also means the edit lost a race.** An edit is always applied against the version it was computed from -- whether or not you sent `If-Match` -- so a concurrent writer landing in between gets the edit refused rather than applied to a body it was not computed from. That is `409` and not `412` because no precondition of yours failed. The message separates the two: an anchor refusal names the match count, a lost race says the document was modified by another writer. Both leave the document untouched. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Query Parameters** | Parameter | Type | Required | Description | | ----------------- | ---- | -------- | ------------------------------------------------ | | `expectedVersion` | long | No | Version precondition (alternative to `If-Match`) | **Headers** | Header | Required | Description | | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `If-Match` | No | ETag precondition, exactly as on `PUT` -- including the refusal: present but naming no tag (`""`, `W/""`, a bare `W/`, whitespace) returns `400` | **Request Body** `application/json` | Field | Type | Required | Description | | ----------- | ------- | ----------------- | ---------------------------------------- | | `operation` | string | Yes | `str_replace`, `insert`, or `append` | | `oldText` | string | For `str_replace` | Text to replace; must occur exactly once | | `newText` | string | Yes | Replacement, inserted, or appended text | | `line` | integer | For `insert` | 1-based line the inserted text becomes | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/edit" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"operation":"str_replace","oldText":"Phase 2 of 3 complete.","newText":"Phase 3 of 3 complete."}' # Append a line curl -X POST "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/edit" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"operation":"append","newText":"\nShipped 2026-01-20.\n"}' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/edit", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "operation": "str_replace", "oldText": "Phase 2 of 3 complete.", "newText": "Phase 3 of 3 complete." } ) result = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/edit", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ operation: "insert", line: 8, newText: "Blocked on the storage migration.", }), } ); const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var body = JsonSerializer.Serialize(new { operation = "str_replace", oldText = "Phase 2 of 3 complete.", newText = "Phase 3 of 3 complete." }); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/edit", new StringContent(body, Encoding.UTF8, "application/json")); ``` **Response** `200 OK` ```json { "category": "main", "key": "projects/apollo", "version": 4, "etag": "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7", "size": 148, "expiresAt": null } ``` **Headers:** `ETag`, `X-Category`, `X-Key`, `X-Version` **Error Responses** | Status | Description | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Malformed edit request, or an `If-Match` header that is present but names no entity tag | | `401` | Missing or invalid API key | | `404` | Document not found, deleted, or expired | | `409` | `str_replace` rejected: the old text does not occur, or occurs more than once -- **or** the document moved while the edit was in flight and nothing was written. An anchor refusal names the match count; a lost race says the document was modified by another writer | | `412` | `If-Match` / `expectedVersion` precondition failed | | `413` | The edited body exceeds the document size limit in force for your scope | **There is no `429` here, and that is the contract.** An edit only ever rewrites a document that already exists, so it can never add one and is never charged against the live-document allowance. A scope sitting exactly at its limit can still edit every document it holds. Do not read the missing row as an omission to be filled in. *** Rename Document [#rename-document] **POST** /api/v1/memory/{category}/{key}/move Move a document to a new category/key. The document identity, its revisions, and its reference metrics are preserved, a `Move` revision is recorded, and inbound links are re-resolved against the new address. A rename never rewrites `[[link]]` text inside other documents. A link whose target text no longer matches anything keeps its last resolution, flagged `stale`, so the edge and its weight survive. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ----------------------------------------- | | `category` | string | Yes | Current logical grouping (namespace) | | `key` | string | Yes | Current unique identifier within category | **Query Parameters** | Parameter | Type | Required | Description | | ----------------- | ---- | -------- | ------------------------------------------------ | | `expectedVersion` | long | No | Version precondition (alternative to `If-Match`) | **Headers** | Header | Required | Description | | ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `If-Match` | No | ETag precondition on the **source** document, exactly as on `PUT` -- including the refusal: present but naming no tag (`""`, `W/""`, a bare `W/`, whitespace) returns `400` | **Request Body** `application/json` | Field | Type | Required | Description | | ---------- | ------ | -------- | -------------------- | | `category` | string | Yes | Destination category | | `key` | string | Yes | Destination key | **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/move" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"category":"main","key":"archive/apollo"}' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/move", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={"category": "main", "key": "archive/apollo"} ) result = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/move", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ category: "main", key: "archive/apollo" }), } ); const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var body = JsonSerializer.Serialize(new { category = "main", key = "archive/apollo" }); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/move", new StringContent(body, Encoding.UTF8, "application/json")); ``` **Response** `200 OK` ```json { "category": "main", "key": "archive/apollo", "sourceCategory": "main", "sourceKey": "projects/apollo", "version": 5, "etag": "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7", "size": 148, "expiresAt": null } ``` **Headers:** `ETag`, `X-Category`, `X-Key`, `X-Version` **Error Responses** | Status | Description | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Destination category or key rejected, or an `If-Match` header that is present but names no entity tag | | `401` | Missing or invalid API key | | `404` | No live document at the source address | | `409` | The destination address is already taken, **or** the source document moved while the rename was in flight and nothing was renamed. The message says which | | `412` | `If-Match` / `expectedVersion` precondition failed | *** Delete Document [#delete-document] **DELETE** /api/v1/memory/{category}/{key} Soft-delete a document. It stops appearing in reads, lists, and search; its revisions remain readable. **A delete can lose a race too, and its `409` reads differently on purpose.** ``` The document was modified by another writer while this delete was in flight. Re-read the document - the address may now hold another writer's document, or nothing at all - and delete again only if that is still what you intend. ``` Every other write carries a body or an edit computed from a version that has gone, so repeating it unchanged cannot succeed. A delete carries no payload and re-reads the address itself, so an unchanged retry **would** succeed -- which is exactly why it should not be issued blindly. What the race cost you is not the write; it is the knowledge of *what you are deleting*. Look first, then delete again only if that is still what you meant. A `409` here also means the document is still there and still live -- that is what separates it from the `404`. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Code Samples** ```bash curl -X DELETE "https://api.ainoflow.io/api/v1/memory/main/projects/apollo" \ -H "Authorization: Bearer YOUR_API_KEY" # 409 -- another writer changed the document while this delete was in flight. # Read it back before deleting again: the address may now hold a different document. curl "https://api.ainoflow.io/api/v1/memory/main/projects/apollo" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.delete( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo", headers={"Authorization": "Bearer YOUR_API_KEY"} ) # 409: the document moved while the delete was in flight. An unchanged retry would # succeed, which is why it must not be issued blindly - read the address first. if response.status_code == 409: current = requests.get( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo", headers={"Authorization": "Bearer YOUR_API_KEY"} ) if current.status_code == 404: pass # somebody else deleted it; you got what you wanted else: raise RuntimeError("Document changed - confirm this is still the one to delete") ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo", { method: "DELETE", headers: { Authorization: "Bearer YOUR_API_KEY" } } ); // 409: the document moved while the delete was in flight. Nothing was deleted. // Re-read before deciding - the address may hold someone else's document now. if (response.status === 409) { const current = await fetch( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); if (current.status !== 404) { throw new Error("Document changed — confirm this is still the one to delete"); } } ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.DeleteAsync( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo"); // 409: the document moved while the delete was in flight and nothing was deleted. // Re-read the address before deleting again. if (response.StatusCode == HttpStatusCode.Conflict) { var current = await client.GetAsync( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo"); if (current.StatusCode != HttpStatusCode.NotFound) { throw new InvalidOperationException( "Document changed - confirm this is still the one to delete"); } } ``` **Response** `200 OK` ```json { "category": "main", "key": "projects/apollo" } ``` **Error Responses** | Status | Description | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` | Missing or invalid API key | | `404` | Document not found, deleted, or expired | | `409` | Another writer changed the document while the delete was in flight, and nothing was deleted. The document is still there and still live -- re-read it, then delete again only if that is still what you intend | *** List Documents [#list-documents] **GET** /api/v1/memory/{category} List documents in a category with prefix filtering, recency filtering, sorting, and pagination. Metadata only -- never the markdown body. Excludes deleted and expired documents. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ---------------------------- | | `category` | string | Yes | Logical grouping (namespace) | **Query Parameters** | Parameter | Type | Required | Description | | -------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------ | | `page` | integer | No | Page number (1-based, minimum 1). Cannot be used with `offset`. | | `offset` | integer | No | Documents to skip (minimum 0). Cannot be used with `page`. | | `limit` | integer | No | Documents per page (0-5000, default: 100). `0` answers an empty page carrying the real `X-Total-Count` | | `prefix` | string | No | Filter keys by prefix (e.g. `projects/`) | | `updatedAfter` | string | No | Only documents updated after this instant (ISO 8601) | | `sortBy` | string | No | `key`, `createdAt`, `updatedAt`, `size`, `weight`, `lastReferencedAt` (default: `updatedAt`) | | `sortOrder` | string | No | `asc`, `desc` (default: `desc`) | | `aggregate` | boolean | No | If `true`, returns object with pagination metadata (default: `false`) | `sortBy=weight` orders by inbound reference count -- the most-linked documents of the category first. **Code Samples** ```bash # Most-referenced documents under projects/ curl "https://api.ainoflow.io/api/v1/memory/main?prefix=projects/&sortBy=weight&sortOrder=desc" \ -H "Authorization: Bearer YOUR_API_KEY" # Recent activity curl "https://api.ainoflow.io/api/v1/memory/main?updatedAfter=2026-01-15T00:00:00Z&sortBy=updatedAt" \ -H "Authorization: Bearer YOUR_API_KEY" # How many documents are under projects/, without reading any of them curl -i "https://api.ainoflow.io/api/v1/memory/main?prefix=projects/&limit=0" \ -H "Authorization: Bearer YOUR_API_KEY" # [] with X-Total-Count: 128 ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/memory/main", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"prefix": "projects/", "sortBy": "weight", "sortOrder": "desc", "limit": 50} ) documents = response.json() total = response.headers.get("X-Total-Count") ``` ```javascript const params = new URLSearchParams({ prefix: "projects/", sortBy: "weight", sortOrder: "desc", limit: "50", }); const response = await fetch( `https://api.ainoflow.io/api/v1/memory/main?${params}`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const documents = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/memory/main?prefix=projects/&sortBy=weight&sortOrder=desc"); var documents = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` **Default mode** (`aggregate=false`): ```json [ { "category": "main", "key": "projects/apollo", "title": "Apollo", "size": 148, "version": 3, "etag": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "referenceCount": 4, "lastReferencedAt": "2026-01-16T08:40:00Z", "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-16T09:12:00Z", "expiresAt": null } ] ``` **Headers:** `X-Total-Count`, `X-Page`, `X-Limit`, `X-Total-Pages`, `X-Category` **Aggregated mode** (`aggregate=true`): ```json { "items": [...], "totalCount": 128, "page": 1, "pageSize": 50, "totalPages": 3, "category": "main" } ``` **Error Responses** | Status | Description | | ------ | --------------------------------------------------------------------------------------------------------- | | `400` | Both `page` and `offset` were specified, or `page`, `offset` or `limit` carried a value outside its range | | `401` | Missing or invalid API key | *** Category Manifest [#category-manifest] **GET** /api/v1/memory/{category}/manifest Return `key`, `etag`, `version`, `size`, and `updatedAt` for **every** live document in the category, in one unpaginated response. This is the diff primitive: compare it against a local copy to decide what to pull or push, without reading any document body. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ---------------------------- | | `category` | string | Yes | Logical grouping (namespace) | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/memory/main/manifest" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/memory/main/manifest", headers={"Authorization": "Bearer YOUR_API_KEY"} ) manifest = response.json() remote = {item["key"]: item["etag"] for item in manifest["items"]} stale = [key for key, etag in remote.items() if local.get(key) != etag] ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/manifest", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const manifest = await response.json(); const remote = new Map(manifest.items.map((i) => [i.key, i.etag])); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/memory/main/manifest"); var manifest = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "category": "main", "items": [ { "key": "projects/apollo", "etag": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "version": 3, "size": 148, "updatedAt": "2026-01-16T09:12:00Z" }, { "key": "people/dana", "etag": "d41d8cd98f00b204e9800998ecf8427e", "version": 1, "size": 52, "updatedAt": "2026-01-15T11:02:00Z" } ], "totalCount": 2 } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | *** Search Documents [#search-documents] **POST** /api/v1/memory/{category}/search Weighted full-text recall over a category. Matching is zone-weighted -- title strongest, then headings, then prose, then code and link text -- and the result can be re-ordered by inbound reference weight, freshness, or a blend of all three. A search hit never returns the document body. It returns the address, the title, the section breadcrumb of the best match, and a marked snippet. Read the body with `GET /api/v1/memory/{category}/{key}` once you know which document you want. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ----------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping (namespace) — takes precedence over any category in the body | **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------- | -------- | --------------------------------------------------------------------- | | `aggregate` | boolean | No | If `true`, returns object with pagination metadata (default: `false`) | **Request Body** `application/json` | Field | Type | Required | Description | | -------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `q` | string | Yes | Search text, max 1024 characters. Quoted phrases and `or` / `-not` are supported. | | `mode` | string | No | `text` (whole document), `title` (title zone only), `fuzzy` (trigram similarity on the title). Default `text`. | | `prefix` | string | No | Filter keys by prefix (e.g. `projects/`) | | `tags` | string\[] | No | Frontmatter tags a document must **all** carry | | `frontmatter` | object | No | Frontmatter the document must contain, e.g. `{"status":"active"}` | | `updatedAfter` | string | No | Only documents updated after this instant (ISO 8601) | | `sort` | string | No | `relevance`, `weight`, `recency`, `blended`. Default `relevance`. | | `weights` | object | No | Blended weight overrides `{ relevance, weight, recency }`; each omitted component keeps its server default | | `halfLifeDays` | number | No | Days after which the freshness signal halves (server default: 30) | | `page` | integer | No | Page number (1-based, minimum 1). Cannot be used with `offset`. | | `offset` | integer | No | Hits to skip (minimum 0). Cannot be used with `page`. | | `limit` | integer | No | Hits per page (0-5000, default: 20). `0` answers an empty page carrying the real `X-Total-Count` — how many documents match, without reading a hit | **Ranking** `sort=blended` combines the three normalized signals: ``` score = w_rel · relevance + w_weight · ln(1 + referenceCount)/ln(1 + max) + w_rec · 2^(−age/halfLife) ``` Server defaults are `relevance 0.6`, `weight 0.25`, `recency 0.15`, half-life 30 days. Every hit carries the full per-signal breakdown regardless of the sort, so a client can re-rank a page locally without a second query. **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/memory/main/search" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"q":"pipeline migration","sort":"blended","limit":10}' # Filtered and re-weighted toward well-linked documents curl -X POST "https://api.ainoflow.io/api/v1/memory/main/search" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"q":"migration","prefix":"projects/","tags":["active"],"sort":"blended","weights":{"weight":0.6},"halfLifeDays":7}' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/memory/main/search", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "q": "pipeline migration", "mode": "text", "prefix": "projects/", "tags": ["active"], "sort": "blended", "limit": 10 } ) hits = response.json() for hit in hits: print(hit["key"], hit["section"], hit["score"]["blended"]) ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/search", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ q: "pipeline migration", sort: "blended", weights: { relevance: 0.5, weight: 0.3, recency: 0.2 }, limit: 10, }), } ); const hits = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var body = JsonSerializer.Serialize(new { q = "pipeline migration", sort = "blended", limit = 10 }); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/memory/main/search", new StringContent(body, Encoding.UTF8, "application/json")); var hits = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` **Default mode** (`aggregate=false`): ```json [ { "category": "main", "key": "projects/apollo", "title": "Apollo", "section": "Apollo > Status", "snippet": "Pipeline migration owned by dana.", "score": { "relevance": 1.0, "weight": 0.82, "recency": 0.61, "blended": 0.89 }, "referenceCount": 4, "updatedAt": "2026-01-16T09:12:00Z" } ] ``` Matched terms in `snippet` are wrapped in `` tags. `section` is the heading breadcrumb of the best match (`>` separated), falling back to the document title. **Headers:** `X-Total-Count`, `X-Page`, `X-Limit`, `X-Total-Pages`, `X-Category` **Aggregated mode** (`aggregate=true`): ```json { "items": [...], "totalCount": 7, "page": 1, "pageSize": 10, "totalPages": 1, "category": "main" } ``` **Error Responses** | Status | Description | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `q` missing or over 1024 characters, `halfLifeDays` not greater than 0, negative or all-zero `weights`, unsupported `mode` / `sort`, both `page` and `offset` specified, or a pagination value outside its range | | `401` | Missing or invalid API key | *** Get Outgoing Links [#get-outgoing-links] **GET** /api/v1/memory/{category}/{key}/links The `[[wiki-links]]` written in this document, each with its resolution status. A `dangling` or `stale` link carries no target address. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/links" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/links", headers={"Authorization": "Bearer YOUR_API_KEY"} ) links = response.json()["items"] broken = [l for l in links if l["status"] in ("dangling", "ambiguous")] ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/links", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const { items } = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/links"); var links = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "category": "main", "key": "projects/apollo", "items": [ { "targetCategory": "main", "targetKey": "people/dana", "targetTitle": "Dana", "targetText": "people/dana", "displayText": null, "status": "resolved" }, { "targetCategory": null, "targetKey": null, "targetTitle": null, "targetText": "projects/artemis", "displayText": "Artemis", "status": "dangling" } ], "totalCount": 2 } ``` `targetText` is the raw text inside the brackets; `displayText` is the right-hand side of the piped form `[[target|display]]`. **Error Responses** | Status | Description | | ------ | --------------------------------------- | | `401` | Missing or invalid API key | | `404` | Document not found, deleted, or expired | *** Get Backlinks [#get-backlinks] **GET** /api/v1/memory/{category}/{key}/backlinks The links pointing **at** this document, each with the address of the document it was written in. A dangling link points at nothing, so it is never a backlink. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/memory/main/people/dana/backlinks" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/memory/main/people/dana/backlinks", headers={"Authorization": "Bearer YOUR_API_KEY"} ) backlinks = response.json()["items"] ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/people/dana/backlinks", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const { items } = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/memory/main/people/dana/backlinks"); var backlinks = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "category": "main", "key": "people/dana", "items": [ { "sourceCategory": "main", "sourceKey": "projects/apollo", "sourceTitle": "Apollo", "targetText": "people/dana", "displayText": null, "status": "resolved" } ], "totalCount": 1 } ``` **Error Responses** | Status | Description | | ------ | --------------------------------------- | | `401` | Missing or invalid API key | | `404` | Document not found, deleted, or expired | *** Get Document Context [#get-document-context] **GET** /api/v1/memory/{category}/{key}/context Walk the link neighborhood of a document: the documents reachable over wiki-links in either direction, with their distance and the status of the connecting link. Metadata only -- no markdown body is returned. Results are deduplicated and ordered by distance, then by reference weight descending, then by key, so the same neighborhood always reads back in the same order. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Query Parameters** | Parameter | Type | Required | Description | | ------------ | ------- | -------- | -------------------------------------------------------- | | `depth` | integer | No | Hops to walk (1-3, default: 1) | | `maxRelated` | integer | No | Maximum related documents to return (1-500, default: 50) | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/context?depth=2&maxRelated=25" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/context", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"depth": 2, "maxRelated": 25} ) context = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/context?depth=2&maxRelated=25", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const context = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/context?depth=2&maxRelated=25"); var context = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "category": "main", "key": "projects/apollo", "title": "Apollo", "depth": 2, "items": [ { "category": "main", "key": "people/dana", "title": "Dana", "distance": 1, "status": "resolved", "referenceCount": 9 }, { "category": "main", "key": "teams/platform", "title": "Platform team", "distance": 2, "status": "resolved", "referenceCount": 3 } ], "totalCount": 2 } ``` **Error Responses** | Status | Description | | ------ | -------------------------------------------------- | | `400` | `depth` outside 1-3, or `maxRelated` outside 1-500 | | `401` | Missing or invalid API key | | `404` | Document not found, deleted, or expired | *** List Revisions [#list-revisions] **GET** /api/v1/memory/{category}/{key}/revisions List the revisions of a document, newest version first. Every mutation -- `Create`, `Update`, `Edit`, `Move`, `Delete` -- writes one revision in the same transaction, and revisions stay readable after the document is deleted. Revisions are retained for 30 days, and the last 10 per document are always kept. Revision content is never indexed by search. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `page` | integer | No | Page number (1-based, minimum 1). Cannot be used with `offset`. | | `offset` | integer | No | Revisions to skip (minimum 0). Cannot be used with `page`. | | `limit` | integer | No | Revisions per page (0-5000, default: 100). `0` answers an empty page carrying the real `X-Total-Count` — how many revisions exist, without reading any | | `aggregate` | boolean | No | If `true`, returns object with pagination metadata (default: `false`) | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/revisions?limit=20" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/revisions", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"limit": 20} ) revisions = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/revisions?limit=20", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const revisions = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/revisions?limit=20"); var revisions = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` **Default mode** (`aggregate=false`): ```json [ { "id": "9a2f1c4e-7b21-4a3d-9f5c-1e8b0d6a2c33", "category": "main", "key": "projects/apollo", "version": 3, "operation": "Edit", "contentHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "actorKeyId": "kid_01HXXXXX", "createdAt": "2026-01-16T09:12:00Z" } ] ``` The category and key are the ones recorded at the time of the mutation, so a rename is visible in the history as an addressing change. **Headers:** `X-Total-Count`, `X-Page`, `X-Limit`, `X-Total-Pages` **Error Responses** | Status | Description | | ------ | --------------------------------------------------------------------------------------------------------- | | `400` | Both `page` and `offset` were specified, or `page`, `offset` or `limit` carried a value outside its range | | `401` | Missing or invalid API key | *** Get Revision [#get-revision] **GET** /api/v1/memory/{category}/{key}/revisions/{version} Read one revision including its full markdown snapshot. Readable after the document has been deleted. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | | `version` | long | Yes | Document version of the revision | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/revisions/2" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/revisions/2", headers={"Authorization": "Bearer YOUR_API_KEY"} ) revision = response.json() previous_markdown = revision["content"] ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/revisions/2", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const revision = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/memory/main/projects/apollo/revisions/2"); var revision = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "id": "3c7d4b2a-55f1-4c8e-9b62-77aa1d0e4f12", "category": "main", "key": "projects/apollo", "version": 2, "operation": "Update", "content": "# Apollo\n\nPipeline migration owned by [[people/dana]].\n", "contentHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "actorKeyId": "kid_01HXXXXX", "createdAt": "2026-01-15T14:20:00Z" } ``` `content` is `null` when the snapshot has been redacted. Restoring a revision is a client-side operation: read the snapshot and `PUT` it back. **Error Responses** | Status | Description | | ------ | --------------------------- | | `401` | Missing or invalid API key | | `404` | No revision at this version | # Shield (/docs/api/shield) Shield provides distributed locking and idempotency protection for workflows. It prevents duplicate executions caused by webhook retries, user double-clicks, and race conditions in automation platforms. Lock operations are atomic and isolated per tenant. **Base URL** ``` https://api.ainoflow.io ``` All endpoints require `Authorization: Bearer YOUR_API_KEY` header. Core Concepts [#core-concepts] | Concept | Description | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Lock Key** | Unique identifier (up to 512 characters). URL-encode special characters in the path. | | **Token** | 128-bit cryptographically random token returned on acquire. Required for `complete` and `renew`. Returned in `X-Shield-Token` header and response body. | | **Lock Lifecycle** | **Locked** (active, workflow in progress) then **Completed** (finished, TTL extended to 30 days). Locks are removed automatically when TTL expires. | | **TTL System** | Default lock TTL: 30 minutes. Max TTL: 24 hours. Max lifetime deadline: 24 hours from acquire. Completed TTL: 30 days. Recommended renew interval: every 15 minutes. | | **Monthly lock allowance** | Your scope may acquire a bounded number of locks per calendar month. **Only acquire is charged** -- complete, renew, status and delete act on a lock already counted. An acquire past the allowance answers `429`. `GET /api/v1/capabilities` reports the number in force as `limits.shieldLocksMonthly`; `0` means unlimited. | *** Acquire Lock [#acquire-lock] **POST** /api/v1/shield/{key} Acquire a distributed lock for the given key. Returns `201` if lock acquired, `409` if key already exists (duplicate), and `429` once the scope's monthly lock allowance is spent. The `409` on acquire is a business response indicating duplicate detection, not an error; the `429` is a genuine refusal. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------ | -------- | -------------------------------------------------------------------------- | | `key` | string | Yes | Unique lock identifier. Max 512 characters. URL-encode special characters. | **Query Parameters** | Parameter | Type | Required | Description | | --------------- | ------- | -------- | ----------------------------------------------------------------------------------------- | | `ttl` | integer | No | Lock TTL in seconds (default: 1800, max: 86400). Capped to max\_lifetime deadline. | | `reference` | string | No | Reference identifier for audit purposes. Max 512 characters -- the same bound as the key. | | `returnSuccess` | boolean | No | If `true`, return `200 OK` for duplicates instead of `409 Conflict` (default: `false`) | **A reference that does not fit is refused before the lock is taken.** A `reference` over 512 characters answers `400 Bad Request` and acquires nothing, so the key stays free for the next caller. It used to be bounded by nothing but the column it is written to -- and it is written by the audit step *after* acquire succeeds, so an over-long one took the lock, then failed the audit insert: the call answered `500` while still holding the lock it had just taken. **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/shield/order-12345?ttl=3600&reference=workflow-1" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/shield/order-12345", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"ttl": 3600, "reference": "workflow-1"} ) if response.status_code == 429: # Monthly lock allowance spent - no lock was taken, and a retry inside # the same month answers 429 again. raise RuntimeError(response.json()["title"]) result = response.json() if result["acquired"]: token = result["token"] # proceed with workflow else: print("Duplicate detected, skipping") ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/shield/order-12345?ttl=3600&reference=workflow-1", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY" }, } ); const result = await response.json(); if (response.status === 429) { // Monthly lock allowance spent - no lock was taken, and a retry inside // the same month answers 429 again. throw new Error(result.title); } if (result.acquired) { const token = result.token; // proceed with workflow } else { console.log("Duplicate detected, skipping"); } ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/shield/order-12345?ttl=3600&reference=workflow-1", null); var result = await response.Content.ReadFromJsonAsync(); if (response.StatusCode == HttpStatusCode.TooManyRequests) { // Monthly lock allowance spent - no lock was taken, and a retry inside // the same month answers 429 again. throw new InvalidOperationException(result.GetProperty("title").GetString()); } if (result.GetProperty("acquired").GetBoolean()) { var token = result.GetProperty("token").GetString(); // proceed with workflow } ``` **Response `201 Created` - Lock Acquired** **Headers:** `X-Shield-Token: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4` ```json { "key": "order-12345", "acquired": true, "status": "locked", "token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "expiresIn": 3600 } ``` **Response `409 Conflict` - Duplicate** ```json { "key": "order-12345", "acquired": false, "status": "duplicate", "token": null, "expiresIn": 1800 } ``` When `returnSuccess=true`, duplicates return `200 OK` with the same body instead of `409`. **Response `429 Too Many Requests` - Monthly Allowance Spent** Unlike `409`, the body is a Problem Details document rather than a lock result -- there is no `acquired` field to branch on. Branch on the status code. ```json { "type": "https://docs.ainoflow.io/problems/rate-limited", "title": "Shield API locks limit exceeded. Limit=500.", "status": 429, "code": "RATE_LIMITED", "retryable": true, "instance": "/api/v1/shield/order-12345" } ``` **`429` means the monthly allowance is spent, not that you are going too fast.** Once the scope's monthly lock allowance is exhausted, acquire answers `429 Too Many Requests` and **takes no lock**. A retry inside the same month answers the same way, however long you wait between attempts: what has to elapse is the billing cycle, not a backoff window. The allowance frees when the calendar month turns over, and not before. It is the one acquire response `returnSuccess` does not touch. That switch converts the duplicate `409` into `200` because a duplicate is a business outcome; `429` is a refusal, so it stays `429` either way. **Acquire is also the only Shield operation that charges the allowance** -- complete, renew, status and delete work on a lock that was already counted, and none of them can answer `429`. **Error Responses** Problem Details `type` values follow the shared [failure vocabulary](/docs/api). They are stable identifiers and are not promised to resolve. | Status | Description | | ------ | ------------------------------------------------------------------------------------------------- | | `400` | Invalid key format (exceeds 512 characters or empty), or a `reference` over 512 characters | | `401` | Missing or invalid API key | | `409` | Duplicate detected (business response when `returnSuccess=false`) | | `429` | The scope's monthly lock allowance is spent. No lock is taken, and `returnSuccess` does not apply | *** Complete Lock [#complete-lock] **PUT** /api/v1/shield/{key}/complete Mark a lock as completed. Sets TTL to 30 days for ongoing duplicate detection. Idempotent -- repeated calls with the same token return success. Requires the `X-Shield-Token` header. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------ | -------- | ------------------- | | `key` | string | Yes | Lock key identifier | **Request Headers** | Header | Type | Required | Description | | ---------------- | ------ | -------- | ---------------------------------------- | | `X-Shield-Token` | string | Yes | Token received from the acquire response | **Code Samples** ```bash curl -X PUT "https://api.ainoflow.io/api/v1/shield/order-12345/complete" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "X-Shield-Token: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" ``` ```python import requests response = requests.put( "https://api.ainoflow.io/api/v1/shield/order-12345/complete", headers={ "Authorization": "Bearer YOUR_API_KEY", "X-Shield-Token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" } ) if response.status_code == 409: # This token does not own the lock - somebody else holds the key now. # The lock is untouched and the same token will never succeed. Stop; do not retry. raise RuntimeError(response.json()["title"]) result = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/shield/order-12345/complete", { method: "PUT", headers: { Authorization: "Bearer YOUR_API_KEY", "X-Shield-Token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", }, } ); const result = await response.json(); if (response.status === 409) { // This token does not own the lock - somebody else holds the key now. // The lock is untouched and the same token will never succeed. Stop; do not retry. throw new Error(result.title); } ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var request = new HttpRequestMessage(HttpMethod.Put, "https://api.ainoflow.io/api/v1/shield/order-12345/complete"); request.Headers.Add("X-Shield-Token", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"); var response = await client.SendAsync(request); ``` **Response `200 OK`** ```json { "key": "order-12345", "success": true, "status": "completed", "expiresIn": 2592000 } ``` **Error Responses** | Status | Description | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Missing `X-Shield-Token` header | | `401` | Missing or invalid API key | | `404` | Lock not found or expired | | `409` | The token presented does not own this lock. Nothing was completed and the lock is left exactly as it was -- the worker that does own it can still complete or renew it | **`409` here means "not your lock", and it is the one refusal a second worker is guaranteed to meet.** Completing does not fail because the work went wrong; it fails because the token you sent is not the one the lock was issued to -- most often a worker still holding a token from a lease that expired and was re-acquired by somebody else. **The lock is untouched by the refusal.** Its TTL is not extended, its owner does not change, and the real holder is unaffected. Do not retry: the same token produces the same `409` forever. Treat it as a signal that this worker no longer owns the work -- stop, and let the current holder finish. If you need to know who holds it, `GET /api/v1/shield/{key}` reports the lock's state without touching it. Note that `409` on **acquire** is a completely different thing: there it is the expected duplicate response, not an error. On `complete` and `renew` it is always a refusal. **MCP uses the same term for this refusal.** Over REST the response is `409 Conflict`, and the `shield_complete` tool reports the same condition as `CONFLICT` -- see [Shield MCP](/docs/mcp/shield). *** Renew Lock [#renew-lock] **PUT** /api/v1/shield/{key}/renew Set a new lock TTL (heartbeat). The new TTL replaces the current TTL but is capped to the max\_lifetime deadline (24 hours from the initial acquire). Recommended interval: every 15 minutes for the default 30-minute TTL. Requires the `X-Shield-Token` header. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------ | -------- | ------------------- | | `key` | string | Yes | Lock key identifier | **Query Parameters** | Parameter | Type | Required | Description | | --------- | ------- | -------- | ---------------------------------------------------------------------------- | | `ttl` | integer | No | New TTL in seconds (default: 1800). Capped to remaining time until deadline. | **Request Headers** | Header | Type | Required | Description | | ---------------- | ------ | -------- | ---------------------------------------- | | `X-Shield-Token` | string | Yes | Token received from the acquire response | **Code Samples** ```bash curl -X PUT "https://api.ainoflow.io/api/v1/shield/order-12345/renew?ttl=3600" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "X-Shield-Token: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" ``` ```python import requests response = requests.put( "https://api.ainoflow.io/api/v1/shield/order-12345/renew", headers={ "Authorization": "Bearer YOUR_API_KEY", "X-Shield-Token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" }, params={"ttl": 3600} ) if response.status_code == 409: # Two refusals share this code and the title tells them apart. Neither is # retryable: break the heartbeat loop rather than sleeping and trying again. title = response.json()["title"] if "maximum lifetime" in title: # 24 hours from the original acquire - no extension is left to grant. raise RuntimeError("Lock deadline reached; finish or split the work") raise RuntimeError(f"Lost the lock: {title}") result = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/shield/order-12345/renew?ttl=3600", { method: "PUT", headers: { Authorization: "Bearer YOUR_API_KEY", "X-Shield-Token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", }, } ); const result = await response.json(); if (response.status === 409) { // Two refusals share this code and the title tells them apart. Neither is // retryable: break the heartbeat loop rather than sleeping and trying again. if (result.title.includes("maximum lifetime")) { // 24 hours from the original acquire - no extension is left to grant. throw new Error("Lock deadline reached; finish or split the work"); } throw new Error(`Lost the lock: ${result.title}`); } ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var request = new HttpRequestMessage(HttpMethod.Put, "https://api.ainoflow.io/api/v1/shield/order-12345/renew?ttl=3600"); request.Headers.Add("X-Shield-Token", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"); var response = await client.SendAsync(request); ``` **Response `200 OK`** ```json { "key": "order-12345", "success": true, "expiresIn": 3600 } ``` When the requested TTL exceeds the remaining max\_lifetime, it is capped: ```json { "key": "order-12345", "success": true, "expiresIn": 1800, "capped": true } ``` **Error Responses** | Status | Description | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Missing `X-Shield-Token` header | | `401` | Missing or invalid API key | | `404` | Lock not found or expired | | `409` | Either the token presented does not own this lock, or the lock has already reached the maximum lifetime a renew may extend it to. The lease is unchanged in both cases; read the `title` to tell them apart | **Two different refusals share `409` on renew, and only the message tells them apart.** * **The token does not own the lock** -- the same refusal as on `complete`. Somebody else holds the key now; stop and let them finish. * **The maximum lifetime is reached** -- 24 hours measured from the original acquire, not from the last renew. The token is still valid and still yours; there is simply no extension left to grant. Both leave the lease **exactly as it was** -- no TTL was extended and no ownership changed -- and neither is clearable by retrying, so a heartbeat loop that treats `409` as transient will spin until its lock expires underneath it. Branch on the problem document's `title`: the token refusal reads `Provided token does not match lock owner`, the deadline refusal names the maximum lifetime. The deadline is the one a long job actually hits. A heartbeat cannot outrun it -- it exists so a crashed holder cannot keep a key forever. When work legitimately runs past 24 hours, split it into stages under their own keys rather than renewing indefinitely; when it does not, `complete` the lock and let the next run acquire fresh. **MCP names both of these refusals `CONFLICT`, matching REST's `409 Conflict`.** The REST `title` distinguishes their causes; an MCP client can use the message for that same distinction -- see [Shield MCP](/docs/mcp/shield). **Example `409 Conflict`** (Maximum lifetime reached) ```json { "type": "https://docs.ainoflow.io/problems/conflict", "title": "Lock has exceeded maximum lifetime", "status": 409, "code": "CONFLICT", "retryable": false, "instance": "/api/v1/shield/order-12345/renew" } ``` *** Check Lock Status [#check-lock-status] **GET** /api/v1/shield/{key} Check if a lock exists and get remaining TTL. Intended for occasional checks and debugging, not for high-frequency polling. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------ | -------- | ------------------- | | `key` | string | Yes | Lock key identifier | **Query Parameters** | Parameter | Type | Required | Description | | ------------ | ------- | -------- | ------------------------------------------------------------------------------- | | `allowEmpty` | boolean | No | If `true`, returns `{}` instead of `404` when lock not found (default: `false`) | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/shield/order-12345" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/shield/order-12345", headers={"Authorization": "Bearer YOUR_API_KEY"} ) if response.status_code == 200: result = response.json() print(f"Expires in: {result['expiresIn']}s") elif response.status_code == 404: print("Lock not found") ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/shield/order-12345", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); if (response.ok) { const result = await response.json(); console.log(`Expires in: ${result.expiresIn}s`); } ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/shield/order-12345"); if (response.IsSuccessStatusCode) { var result = await response.Content.ReadFromJsonAsync(); Console.WriteLine($"Expires in: {result.GetProperty("expiresIn")}s"); } ``` **Response `200 OK`** ```json { "key": "order-12345", "exists": true, "expiresIn": 2400 } ``` With `allowEmpty=true` when lock does not exist, returns `200 OK` with empty body: ```json {} ``` **Error Responses** | Status | Description | | ------ | --------------------------------------------------- | | `400` | Invalid key format | | `401` | Missing or invalid API key | | `404` | Lock not found or expired (when `allowEmpty=false`) | *** Delete Lock [#delete-lock] **DELETE** /api/v1/shield/{key} Delete a lock. For debugging purposes only. Locks are expected to expire automatically. The `X-Shield-Token` header is not required -- the token is read server-side from storage for audit purposes. **Path Parameters** | Parameter | Type | Required | Description | | --------- | ------ | -------- | ------------------- | | `key` | string | Yes | Lock key identifier | **Code Samples** ```bash curl -X DELETE "https://api.ainoflow.io/api/v1/shield/order-12345" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.delete( "https://api.ainoflow.io/api/v1/shield/order-12345", headers={"Authorization": "Bearer YOUR_API_KEY"} ) ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/shield/order-12345", { method: "DELETE", headers: { Authorization: "Bearer YOUR_API_KEY" }, } ); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.DeleteAsync( "https://api.ainoflow.io/api/v1/shield/order-12345"); ``` **Response `200 OK`** ```json { "key": "order-12345", "success": true } ``` **Error Responses** | Status | Description | | ------ | -------------------------- | | `401` | Missing or invalid API key | | `404` | Lock not found or expired | # Data store (/docs/api/storage) The Data store keeps schema-free JSON documents for configuration, workflow state, user preferences, and structured records. Documents are organized by **category** (namespace) and identified by a unique **key** within each category. Supports versioning, ETags, optional TTL expiration, and JSON Merge Patch (RFC 7386) for partial updates. Use the existing `/api/v1/storage/json` endpoints to access the Data store. **Base URL** ``` https://api.ainoflow.io ``` All endpoints require `Authorization: Bearer YOUR_API_KEY` header. Limits [#limits] | Constraint | Value | | ---------------- | ------------------------------------------------------------------------------ | | Document size | 10 MB (default, resolved per scope) | | Stored documents | 5,000 (default, resolved per scope) | | Key length | 1 to 1024 characters | | Category length | 1 to 100 characters | | Root JSON type | Object `{...}` or array `[...]` — never a bare string, number, boolean or null | A write above the effective document size limit is rejected with `413` before it is stored, and nothing is partially written. The limit applies to the **document you end up with**, not to the payload you send: a `PATCH` whose merge result would cross the limit is rejected on the merged size, however small the patch itself is. **Ask instead of assuming.** The numbers above are defaults. `GET /api/v1/capabilities` returns `limits` resolved for your key, and `storage_guide` reports the same values over MCP — both already have any per-scope override applied. A size refusal also carries `currentBytes` and `maximumBytes`, so a client never has to guess what it is allowed to send. **An address that does not fit is refused as your mistake, not ours.** A `category` outside 1-100 characters, or a `key` outside 1-1024, answers `400 Bad Request` on every write endpoint (`POST`, `PUT`, `PATCH`) and stores nothing. Unlike the document size limit these two bounds are the same for every project -- they are properties of the stored column, not a per-scope setting. Data store bounded an address nowhere but in that column until this check was added, so an over-long key travelled the whole write path and came back as `500 Internal Server Error` from the store refusing it -- telling you the server had broken and that retrying unchanged was reasonable, when neither was true. A long hierarchical key (`recall/profile/user-42`) is exactly the shape these pages encourage, so it was reachable by following the documentation. **The document allowance is a stored count, not a monthly one.** It counts the JSON documents your scope is keeping right now, across every category. Once it already holds as many as the allowance permits, a write that would add one more answers `429 Too Many Requests` and **stores nothing** -- the check runs before anything reaches storage. It does not reset on a billing cycle: retrying the same write answers `429` again for as long as the scope stays full, so waiting for a reset is waiting for something that never arrives. Delete documents -- or let a TTL expire them -- and the room is free again immediately. Check that the delete actually succeeded: one refused with `409 Conflict` frees nothing (see [Delete Item](#delete-item)). **Only a write that adds a document is charged**, which is what makes the four write endpoints differ: | Endpoint | When the allowance applies | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST /api/v1/storage/json/{category}` | Always -- an auto-generated key always adds a document | | `POST /api/v1/storage/json/{category}/{key}` | Only once the write would add. The address is checked first, so an occupied key still answers `409 Conflict`, never `429` | | `PUT /api/v1/storage/json/{category}/{key}` | Only when it creates. Replacing an existing key is never refused by the allowance | | `PATCH /api/v1/storage/json/{category}/{key}` | Only on the upsert branch -- an **unconditional** patch against an address that holds nothing. Patching an item that already exists is never refused by the allowance, and a conditional patch never reaches the branch: it answers `412` instead of creating | `429` and `413` are different refusals: `413` says this one document is over the size limit, `429` says the scope has no room for another however small it is. The number above is the default -- `GET /api/v1/capabilities` returns `limits.storageDocumentCount` resolved for your key, and `storage_guide` reports the same value over MCP. List Categories [#list-categories] **GET** /api/v1/storage/json Get all distinct categories in JSON storage with item counts. **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------- | | `page` | integer | No | Page number (1-based). Cannot be used with `offset`. | | `offset` | integer | No | Items to skip. Cannot be used with `page`. | | `limit` | integer | No | Items per page (0-5000, default: 100). `0` answers an empty page carrying the real total count. | | `aggregate` | boolean | No | If `true`, returns object with pagination metadata (default: `false`) | **A pagination value outside its range is refused, never adjusted.** `limit` below `0` or above `5000`, `page` below `1`, and `offset` below `0` each answer `400 Bad Request` and list nothing. `limit=0` is the one value outside `1-5000` that is accepted, because it is a capability rather than a mistake: it answers an empty page carrying the real `X-Total-Count`, which is how you ask how many items there are without reading them. An out-of-range `limit` used to be substituted -- negative values silently became `100`, and anything above `5000` was clamped to `5000` -- so a caller asking for `10000` rows received `5000` under a `200` and could not tell that from there being `5000` rows: the page read as complete and the scan stopped early. **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/storage/json" \ -H "Authorization: Bearer YOUR_API_KEY" # 400 -- 10000 is above the maximum of 5000, and is refused rather than # clamped. Ask for 5000 and page, or send limit=0 to learn the total first. curl "https://api.ainoflow.io/api/v1/storage/json?limit=10000" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/storage/json", headers={"Authorization": "Bearer YOUR_API_KEY"} ) categories = response.json() ``` ```javascript const response = await fetch("https://api.ainoflow.io/api/v1/storage/json", { headers: { Authorization: "Bearer YOUR_API_KEY" }, }); const categories = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync("https://api.ainoflow.io/api/v1/storage/json"); var categories = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json [ { "category": "config", "count": 5 }, { "category": "workflows", "count": 42 }, { "category": "user-prefs", "count": 18 } ] ``` **Headers:** `X-Total-Count`, `X-Page`, `X-Limit`, `X-Total-Pages` **Error Responses** | Status | Description | | ------ | ---------------------------------------------------------------------------------------------- | | `400` | Both `page` and `offset` parameters were specified, or a pagination value is outside its range | | `401` | Missing or invalid API key | *** Create Item (Auto-generated Key) [#create-item-auto-generated-key] **POST** /api/v1/storage/json/{category} Create a JSON document with an auto-generated UUID key (32 hex characters without dashes). **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------ | | `category` | string | Yes | Logical grouping (namespace), max 100 characters | **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `expiresAt` | string | No | Expiration date (ISO 8601). Cannot be used with `expiresMs`. Must be in the future — an expiry that has already passed is refused with `400` and nothing is written. | | `expiresMs` | long | No | Expiration in milliseconds from now. Cannot be used with `expiresAt`. | **Request Body** `application/json` Raw JSON object or array. Primitive values (strings, numbers, booleans, null) are not allowed at root level. **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/storage/json/events" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event":"user_login","timestamp":"2026-01-15T10:30:00Z","userId":"user-456"}' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/storage/json/events", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={"event": "user_login", "timestamp": "2026-01-15T10:30:00Z", "userId": "user-456"} ) result = response.json() print(result["key"]) # auto-generated UUID ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/storage/json/events", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ event: "user_login", timestamp: "2026-01-15T10:30:00Z", userId: "user-456", }), } ); const result = await response.json(); console.log(result.key); // auto-generated UUID ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var json = JsonSerializer.Serialize(new { @event = "user_login", timestamp = "2026-01-15T10:30:00Z", userId = "user-456" }); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/storage/json/events", new StringContent(json, Encoding.UTF8, "application/json")); var result = await response.Content.ReadAsStringAsync(); ``` **Response** `201 Created` ```json { "category": "events", "key": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", "version": 1, "etag": "7f8e9d0c1b2a3f4e5d6c7b8a9f0e1d2c", "expiresAt": null } ``` **Headers:** `Location`, `ETag`, `X-Category`, `X-Key`, `X-Version` **Error Responses** | Status | Description | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Invalid JSON, empty body, a `category` outside 1-100 characters, or an `expiresAt` that is already past | | `401` | Missing or invalid API key | | `409` | A concurrent writer landed first and nothing was stored -- re-read and reissue. Vanishingly rare here, since the key is freshly generated, but it is the same refusal every Data store write can now report instead of a `500` | | `413` | The document exceeds the size limit in force for your scope | | `429` | The scope already stores as many documents as its allowance permits, and nothing was written. This endpoint always adds a document, so it is always subject to the allowance. A stored count, not a monthly one -- free room by deleting | *** Create Item (Explicit Key) [#create-item-explicit-key] **POST** /api/v1/storage/json/{category}/{key} Create a JSON document with a specified key. Returns `409 Conflict` if the item already exists -- use PUT to overwrite. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------------ | | `category` | string | Yes | Logical grouping (namespace), max 100 characters | | `key` | string | Yes | Unique identifier within category, max 1024 characters | **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `expiresAt` | string | No | Expiration date (ISO 8601). Cannot be used with `expiresMs`. Must be in the future — an expiry that has already passed is refused with `400` and nothing is written. | | `expiresMs` | long | No | Expiration in milliseconds from now. Cannot be used with `expiresAt`. | **Request Body** `application/json` Raw JSON object or array. Primitive values are not allowed at root level. **Code Samples** ```bash curl -X POST "https://api.ainoflow.io/api/v1/storage/json/config/app-settings" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"theme":"dark","language":"en","notifications":{"email":true,"push":true}}' ``` ```python import requests response = requests.post( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "theme": "dark", "language": "en", "notifications": {"email": True, "push": True} } ) result = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ theme: "dark", language: "en", notifications: { email: true, push: true }, }), } ); const result = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var json = JsonSerializer.Serialize(new { theme = "dark", language = "en", notifications = new { email = true, push = true } }); var response = await client.PostAsync( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings", new StringContent(json, Encoding.UTF8, "application/json")); ``` **Response** `201 Created` ```json { "category": "config", "key": "app-settings", "version": 1, "etag": "d41d8cd98f00b204e9800998ecf8427e", "expiresAt": null } ``` **Headers:** `Location`, `ETag`, `X-Category`, `X-Key`, `X-Version` **Error Responses** | Status | Description | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | Invalid JSON, empty body, an address outside its length bounds (`category` 1-100, `key` 1-1024), or an `expiresAt` that is already past | | `401` | Missing or invalid API key | | `409` | Item already exists at this category/key -- or a concurrent writer landed first and nothing was stored. Both are the same code because both mean "somebody else got there"; in either case re-read before deciding what to write | | `413` | The document exceeds the size limit in force for your scope | | `429` | The scope already stores as many documents as its allowance permits, and nothing was written. The address is checked first, so an occupied key answers `409` rather than `429`. A stored count, not a monthly one -- free room by deleting | *** Upsert Item (Create or Replace) [#upsert-item-create-or-replace] **PUT** /api/v1/storage/json/{category}/{key} Insert or replace a JSON document. Creates the item if it does not exist, or fully replaces it if it does. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------------ | | `category` | string | Yes | Logical grouping (namespace), max 100 characters | | `key` | string | Yes | Unique identifier within category, max 1024 characters | **Query Parameters** | Parameter | Type | Required | Description | | ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `expiresAt` | string | No | Expiration date (ISO 8601). Cannot be used with `expiresMs`. Must be in the future — an expiry that has already passed is refused with `400` and nothing is written. | | `expiresMs` | long | No | Expiration in milliseconds from now. Cannot be used with `expiresAt`. | | `expectedVersion` | long | No | Version precondition for clients without header control (alternative to `If-Match`) | **Headers** | Header | Required | Description | | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `If-Match` | No | `""` (or `W/""`) writes only when the item is unchanged; `*` writes only when the item exists. A comma-separated list is accepted -- the first tag is used. Omit the header for an unconditional write | **Conditional writes** Send back the `ETag` you read (from `GET`, `GET .../meta`, or the previous write) as `If-Match` and the update lands only if nobody changed the item in between; a stale tag -- or a missing item -- returns `412 Precondition Failed` and stores nothing. `?expectedVersion=` is the same precondition for clients that cannot set headers, and the two may be combined -- when both are sent, **both must hold**. `PATCH` takes the same two preconditions, in the same vocabulary — see [Patch Item](#patch-item-json-merge-patch). **Every Data store write can now answer `409 Conflict`**, including `PATCH`, `PUT`, `POST` and `DELETE`. It means a concurrent writer landed first and nothing of yours was stored. No precondition of yours failed, which is why it is not `412`. It is a refusal these verbs could always suffer and previously reported as `500`; they now name it. `409` is not retryable as-is: re-read the item and reissue the write against what you find. On `DELETE` the re-read matters more than anywhere else, because there an unchanged retry *would* go through — see [Delete Item](#delete-item). **An `If-Match` that names no entity tag is refused, never ignored.** `PUT` answers `400 Bad Request` when the header is present but carries no usable tag -- `If-Match: ""`, `If-Match: W/""`, a bare `If-Match: W/`, or whitespace. It answered `200` until this refusal was added: an unusable header collapsed to the same "no precondition" an absent one produces, so the conditional write ran unconditionally and the response reported success -- the lost-update protection you asked for was gone and nothing said so. **Omitting `If-Match` entirely is unchanged** and still means an unconditional write. `W/""`, `*`, and the first tag of a comma-separated list all still work exactly as before. `PATCH` applies the same refusal to the same header, and so do the memory writes (`PUT /api/v1/memory/{category}/{key}` and its `edit` / `move` endpoints). **Request Body** `application/json` Raw JSON object or array. Primitive values are not allowed at root level. **Code Samples** ```bash curl -X PUT "https://api.ainoflow.io/api/v1/storage/json/workflows/pipeline-state" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status":"running","step":3,"totalSteps":5,"startedAt":"2026-01-15T10:30:00Z"}' # Conditional write: only if unchanged since the ETag you read curl -X PUT "https://api.ainoflow.io/api/v1/storage/json/workflows/pipeline-state" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H 'If-Match: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"' \ -d '{"status":"running","step":4,"totalSteps":5}' # 400 -- the header is present but names no entity tag, so the precondition # cannot be evaluated. Omit If-Match entirely for an unconditional write. curl -X PUT "https://api.ainoflow.io/api/v1/storage/json/workflows/pipeline-state" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H 'If-Match: ""' \ -d '{"status":"running","step":4,"totalSteps":5}' # 400 -- the key is longer than 1024 characters. This used to reach the store # and come back as 500; it is now refused up front and nothing is written. curl -X PUT "https://api.ainoflow.io/api/v1/storage/json/workflows/$(printf 'k%.0s' {1..1025})" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status":"running"}' ``` ```python import requests response = requests.put( "https://api.ainoflow.io/api/v1/storage/json/workflows/pipeline-state", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "status": "running", "step": 3, "totalSteps": 5, "startedAt": "2026-01-15T10:30:00Z" } ) # response.status_code == 200 (replaced) or 201 (created) result = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/storage/json/workflows/pipeline-state", { method: "PUT", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ status: "running", step: 3, totalSteps: 5, startedAt: "2026-01-15T10:30:00Z", }), } ); // response.status === 200 (replaced) or 201 (created) ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var json = JsonSerializer.Serialize(new { status = "running", step = 3, totalSteps = 5, startedAt = "2026-01-15T10:30:00Z" }); var response = await client.PutAsync( "https://api.ainoflow.io/api/v1/storage/json/workflows/pipeline-state", new StringContent(json, Encoding.UTF8, "application/json")); // response.StatusCode == 200 (replaced) or 201 (created) ``` **Response** * **`200 OK`** -- document replaced (existing key) * **`201 Created`** -- new document created ```json { "category": "workflows", "key": "pipeline-state", "version": 2, "etag": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", "expiresAt": null } ``` **Headers:** `Location`, `ETag`, `X-Category`, `X-Key`, `X-Version` **Error Responses** | Status | Description | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | Invalid JSON, empty body, an address outside its length bounds (`category` 1-100, `key` 1-1024), an `expiresAt` that is already past, or an `If-Match` header that is present but names no entity tag | | `401` | Missing or invalid API key | | `409` | A concurrent writer landed first and nothing was stored. No precondition of yours failed, which is why this is not `412` -- re-read the item and reissue the write | | `412` | `If-Match` / `expectedVersion` precondition failed -- the item changed, or does not exist | | `413` | The document exceeds the size limit in force for your scope | | `429` | The scope already stores as many documents as its allowance permits, and nothing was written. Only a write that **creates** is charged, so replacing an existing key is never refused here. A stored count, not a monthly one -- free room by deleting | *** Patch Item (JSON Merge Patch) [#patch-item-json-merge-patch] **PATCH** /api/v1/storage/json/{category}/{key} Partially update a JSON document using [JSON Merge Patch (RFC 7386)](https://tools.ietf.org/html/rfc7386). Only the specified fields are modified; all other fields remain unchanged. If the item does not exist, it is created with the patch data (upsert behavior) -- **unless the request carries a precondition**, which makes the patch a conditional write that never creates. **Merge Patch semantics:** * Null values **remove** fields * Objects are **recursively merged** * Arrays and primitives **replace** the existing value * New item creation filters out null values from the patch **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------------ | | `category` | string | Yes | Logical grouping (namespace), max 100 characters | | `key` | string | Yes | Unique identifier within category, max 1024 characters | **Query Parameters** | Parameter | Type | Required | Description | | ----------------- | ---- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `expectedVersion` | long | No | Version precondition for clients without header control (alternative to `If-Match`). The patch lands only if the current version matches | **Headers** | Header | Required | Description | | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `If-Match` | No | `""` (or `W/""`) patches only when the item is unchanged; `*` patches only when the item exists. A comma-separated list is accepted -- the first tag is used. Present but naming no tag (`""`, `W/""`, a bare `W/`, whitespace) is refused with `400`; omit the header for an unconditional patch | **Conditional patches** `PATCH` takes the same two preconditions as [Upsert Item](#upsert-item-create-or-replace) -- `If-Match` and `?expectedVersion=` -- with the same meanings. Send either, or both: **when both are sent, both must hold.** A precondition that does not hold returns `412 Precondition Failed` and nothing is written -- not the merge, not a version bump, not a new ETag. `PATCH` still takes **no TTL**. `expiresAt` and `expiresMs` are `PUT` / `POST` parameters, and a patch never changes an item's expiry. **A conditional patch does not create.** An unconditional `PATCH` against an address that holds nothing still creates the item from the patch body and answers `201` -- that is unchanged. Send `If-Match` or `expectedVersion` and that branch is gone: there is nothing to match, so the call is refused with `412 Precondition Failed` instead of creating. This is what `If-Match` means in [RFC 7232](https://tools.ietf.org/html/rfc7232#section-3.1) and how `PUT` already behaves. When you want creation, patch unconditionally or use `POST /api/v1/storage/json/{category}/{key}`. **`409` and `412` are different refusals, and the difference is whose guard failed.** `412 Precondition Failed` means the precondition **you sent** did not hold -- a stale ETag, a stale version, or an address that holds nothing. `409 Conflict` means nothing of yours failed. Every patch -- conditional or not -- is written against the exact version its merge was computed from, so a concurrent writer that lands in between makes the merge describe a document that no longer exists, and the patch is refused rather than overwriting that writer. It answers `409` rather than `412` because `412` would name a precondition you never set. `409` is **not retryable**: repeating the identical call recomputes the same merge from the same stale read. Read the item again and re-apply the patch. On either code the stored body, `version`, `etag`, `expiresAt` and every metadata field are left byte-identical. **Request Body** `application/json` JSON object containing only the fields to update. **Code Samples** ```bash # Change theme and disable email notifications, leave everything else untouched curl -X PATCH "https://api.ainoflow.io/api/v1/storage/json/config/app-settings" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"theme":"light","notifications":{"email":false}}' # Conditional patch: only if unchanged since the ETag you read. # A stale tag -- or an address holding nothing -- answers 412 and writes nothing. curl -X PATCH "https://api.ainoflow.io/api/v1/storage/json/config/app-settings" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H 'If-Match: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"' \ -d '{"theme":"light"}' # The version precondition, for clients that cannot set headers curl -X PATCH "https://api.ainoflow.io/api/v1/storage/json/config/app-settings?expectedVersion=7" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"theme":"light"}' # Both guards, ANDed: the version must be 7 *and* the ETag must still match curl -X PATCH "https://api.ainoflow.io/api/v1/storage/json/config/app-settings?expectedVersion=7" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H 'If-Match: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"' \ -d '{"theme":"light"}' ``` ```python import requests response = requests.patch( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "theme": "light", "notifications": {"email": False} } ) result = response.json() # Conditional patch -- read, merge, then write only against the version you read current = requests.get( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings", headers={"Authorization": "Bearer YOUR_API_KEY"} ) etag = current.headers["ETag"] conditional = requests.patch( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json", "If-Match": etag }, json={"theme": "light"} ) # 412 -- your ETag is stale, or nothing is stored at that address # 409 -- another writer landed between the read and the write; re-read and re-apply ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings", { method: "PATCH", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ theme: "light", notifications: { email: false }, }), } ); // response.status === 200 (patched) or 201 (created) // Conditional patch -- lands only on the version you read const conditional = await fetch( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings", { method: "PATCH", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", "If-Match": '"a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"', }, body: JSON.stringify({ theme: "light" }), } ); // 412 -- your ETag is stale, or nothing is stored at that address // 409 -- another writer landed first; re-read and re-apply, do not simply retry ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var patch = JsonSerializer.Serialize(new { theme = "light", notifications = new { email = false } }); var request = new HttpRequestMessage(HttpMethod.Patch, "https://api.ainoflow.io/api/v1/storage/json/config/app-settings") { Content = new StringContent(patch, Encoding.UTF8, "application/json") }; var response = await client.SendAsync(request); // Conditional patch -- lands only on the version you read var conditional = new HttpRequestMessage(HttpMethod.Patch, "https://api.ainoflow.io/api/v1/storage/json/config/app-settings") { Content = new StringContent("{\"theme\":\"light\"}", Encoding.UTF8, "application/json") }; conditional.Headers.IfMatch.Add( new EntityTagHeaderValue("\"a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6\"")); var conditionalResponse = await client.SendAsync(conditional); // HttpStatusCode.PreconditionFailed -- your ETag is stale, or nothing is stored there // HttpStatusCode.Conflict -- another writer landed first; re-read and re-apply ``` **Merge Patch Example** **Existing document:** ```json { "theme": "dark", "language": "en", "notifications": { "email": true, "push": true } } ``` **Patch sent:** `{"theme":"light","notifications":{"email":false}}` **Result after patch:** ```json { "theme": "light", "language": "en", "notifications": { "email": false, "push": true } } ``` **Response** * **`200 OK`** -- document patched * **`201 Created`** -- new document created (item did not exist, and the request carried no precondition) ```json { "category": "config", "key": "app-settings", "version": 2, "etag": "xyz789abc123def456ghi789jkl012mn", "expiresAt": null } ``` **Headers:** `Location`, `ETag`, `X-Category`, `X-Key`, `X-Version` **Error Responses** | Status | Description | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Invalid JSON, an address outside its length bounds (`category` 1-100, `key` 1-1024), or an `If-Match` header that is present but names no entity tag | | `401` | Missing or invalid API key | | `409` | The patch lost a race with a concurrent writer: the item moved between the read its merge was computed from and the write, so nothing was written. Not retryable -- re-read and re-apply | | `412` | The `If-Match` / `expectedVersion` you sent did not hold -- the item changed, or the address holds nothing and a precondition cannot create | | `413` | The merged document exceeds the size limit in force for your scope | | `429` | The scope already stores as many documents as its allowance permits, and nothing was written. Only the upsert branch -- an unconditional patch against an address that holds nothing -- is charged, so patching an item that already exists is never refused here, and a conditional patch answers `412` before reaching the branch. A stored count, not a monthly one -- free room by deleting | *** Get Item [#get-item] **GET** /api/v1/storage/json/{category}/{key} Retrieve a JSON document by category and key. Returns the raw stored JSON data. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Query Parameters** | Parameter | Type | Required | Description | | ------------ | ------- | -------- | ------------------------------------------------------------------------ | | `allowEmpty` | boolean | No | If `true`, returns `{}` instead of 404 when not found (default: `false`) | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/storage/json/config/app-settings" \ -H "Authorization: Bearer YOUR_API_KEY" # With allowEmpty to avoid 404 curl "https://api.ainoflow.io/api/v1/storage/json/config/app-settings?allowEmpty=true" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings", headers={"Authorization": "Bearer YOUR_API_KEY"} ) data = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const data = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings"); var data = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` Returns the stored JSON content directly: ```json { "theme": "dark", "language": "en", "notifications": { "email": true, "push": true } } ``` **Headers:** `ETag`, `Last-Modified` **Error Responses** | Status | Description | | ------ | ----------------------------------- | | `401` | Missing or invalid API key | | `404` | Item not found, deleted, or expired | *** Delete Item [#delete-item] **DELETE** /api/v1/storage/json/{category}/{key} Delete a JSON document by category and key. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **A delete can lose a race, and then it answers `409 Conflict`.** A delete is a write like any other: it is applied to the exact version it read, so a concurrent writer landing in between makes it refuse rather than remove a document it never saw. **Nothing was deleted** — the item is still stored as that writer left it — and **nothing was credited back**, so a delete made to free room under the stored document allowance freed none. `404` and `409` answer different questions. `404` means nothing live is at the address (never stored, already deleted, or expired) — including an item that vanished while your delete was in flight, because then the address is empty, which is what you asked for. `409` means something **was** there and it moved underneath you. **This is the one `409` a blind retry would "fix", and that is exactly the danger.** `POST`, `PUT` and `PATCH` tell you a repeat cannot succeed, because their payload was computed from a version that no longer exists. A delete carries no payload and re-reads the address itself, so repeating it will remove whatever is there now — possibly the other writer's document. Re-read the item, and delete again only if what you find is still what you meant to remove. **Code Samples** ```bash curl -i -X DELETE "https://api.ainoflow.io/api/v1/storage/json/config/app-settings" \ -H "Authorization: Bearer YOUR_API_KEY" # 404 -- nothing live is at that address; it is already gone # 409 -- another writer landed while the delete was in flight and nothing was deleted. # Read the item back before deleting again: repeating this call unchanged # would remove whatever is there now, which may be their document. ``` ```python import requests url = "https://api.ainoflow.io/api/v1/storage/json/config/app-settings" headers = {"Authorization": "Bearer YOUR_API_KEY"} response = requests.delete(url, headers=headers) if response.status_code == 409: # Nothing was deleted. Look at what is stored now before deciding again -- # a blind retry would delete the other writer's document. current = requests.get(url, headers=headers) # ...delete again only if `current` is still what you meant to remove elif response.status_code == 404: # Already gone -- the outcome you wanted pass ``` ```javascript const url = "https://api.ainoflow.io/api/v1/storage/json/config/app-settings"; const headers = { Authorization: "Bearer YOUR_API_KEY" }; const response = await fetch(url, { method: "DELETE", headers }); if (response.status === 409) { // Nothing was deleted -- another writer landed first. Re-read before deleting // again; repeating this call unchanged would remove their document. const current = await fetch(url, { headers }); } else if (response.status === 404) { // Already gone -- the outcome you wanted } ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); const string url = "https://api.ainoflow.io/api/v1/storage/json/config/app-settings"; var response = await client.DeleteAsync(url); if (response.StatusCode == HttpStatusCode.Conflict) { // Nothing was deleted -- another writer landed first. Re-read before deleting // again; repeating this call unchanged would remove their document. var current = await client.GetAsync(url); } else if (response.StatusCode == HttpStatusCode.NotFound) { // Already gone -- the outcome you wanted } ``` **Response** `200 OK` ```json { "category": "config", "key": "app-settings" } ``` **Error Responses** | Status | Description | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` | Missing or invalid API key | | `404` | Nothing live is at that address -- never stored, already deleted, or expired. Including an item that vanished while the delete was in flight: the address is empty, which is what you asked for | | `409` | The delete lost a race with a concurrent writer. Nothing was deleted, the item is still stored as that writer left it, and no room was freed under the document allowance. Re-read before deleting again -- unlike the other Data store writes, repeating this call unchanged **would** succeed, and would remove their document | *** List Items in Category [#list-items-in-category] **GET** /api/v1/storage/json/{category} List all items in a category with pagination and sorting. Excludes deleted and expired items. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ---------------------------- | | `category` | string | Yes | Logical grouping (namespace) | **Query Parameters** | Parameter | Type | Required | Description | | ----------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `page` | integer | No | Page number (1-based). Cannot be used with `offset`. | | `offset` | integer | No | Items to skip. Cannot be used with `page`. | | `limit` | integer | No | Items per page (0-5000, default: 100). `0` answers an empty page carrying the real total count. | | `prefix` | string | No | Return only keys starting with this prefix (e.g. `recall/profile/`) | | `where` | string | No | Repeatable top-level field equality filter in `field:value` form (e.g. `where=status:done`). Repeat to combine with AND. Values are compared against the stored JSON type; only top-level fields are supported | | `sortBy` | string | No | Sort field: `key`, `createdAt`, `updatedAt`, `size` (default: `createdAt`) | | `sortOrder` | string | No | Sort direction: `asc`, `desc` (default: `asc`) | | `aggregate` | boolean | No | If `true`, returns object with pagination metadata (default: `false`) | **A malformed `where` entry refuses the whole request.** Every entry must be a `field:value` pair; an entry with no colon (`?where=status`) or an empty field name (`?where=:done`) returns `400 Bad Request` -- and it does so even when the other entries are valid, so you never receive a page filtered by half of what you asked for. It used to be skipped instead, which meant `?where=status` applied **no filter at all** and answered `200` with every key in the category: a full page that reads as a filtered one. Only the first colon separates field from value, so `?where=url:https://example.com` filters `url` on `https://example.com`. Omitting `where` applies no filter, unchanged. **A pagination value outside its range is refused, never adjusted.** `limit` below `0` or above `5000`, `page` below `1`, and `offset` below `0` each answer `400 Bad Request` and list nothing -- so a short page is short because the data ran out, never because the request was quietly rewritten. `limit=0` is the one value outside `1-5000` that is accepted: it answers an empty page carrying the real `X-Total-Count`. **Code Samples** ```bash # List items sorted by newest first curl "https://api.ainoflow.io/api/v1/storage/json/config?sortBy=createdAt&sortOrder=desc&limit=50" \ -H "Authorization: Bearer YOUR_API_KEY" # Filter on a top-level field (repeat ?where= to combine with AND) curl "https://api.ainoflow.io/api/v1/storage/json/invoices?where=status:done&where=type:invoice" \ -H "Authorization: Bearer YOUR_API_KEY" # 400 -- 'type' has no value, so it is not a field:value pair. The valid # 'status:done' entry does not save the request. curl "https://api.ainoflow.io/api/v1/storage/json/invoices?where=status:done&where=type" \ -H "Authorization: Bearer YOUR_API_KEY" # Filter by key prefix curl "https://api.ainoflow.io/api/v1/storage/json/memory?prefix=recall/profile/" \ -H "Authorization: Bearer YOUR_API_KEY" # With aggregated pagination metadata curl "https://api.ainoflow.io/api/v1/storage/json/config?aggregate=true" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/storage/json/config", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"sortBy": "createdAt", "sortOrder": "desc", "limit": 50} ) items = response.json() total = response.headers.get("X-Total-Count") # Repeatable where filter: pass a list, each entry a "field:value" pair filtered = requests.get( "https://api.ainoflow.io/api/v1/storage/json/invoices", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={"where": ["status:done", "type:invoice"]} ) ``` ```javascript const params = new URLSearchParams({ sortBy: "createdAt", sortOrder: "desc", limit: "50", }); const response = await fetch( `https://api.ainoflow.io/api/v1/storage/json/config?${params}`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const items = await response.json(); const total = response.headers.get("X-Total-Count"); // Repeatable where filter: append one entry per filter, each a "field:value" pair const filters = new URLSearchParams(); filters.append("where", "status:done"); filters.append("where", "type:invoice"); const filtered = await fetch( `https://api.ainoflow.io/api/v1/storage/json/invoices?${filters}`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/storage/json/config?sortBy=createdAt&sortOrder=desc&limit=50"); var items = await response.Content.ReadAsStringAsync(); var total = response.Headers.GetValues("X-Total-Count").FirstOrDefault(); ``` **Response** `200 OK` **Default mode** (`aggregate=false`): ```json [ { "category": "config", "key": "app-settings", "value": { "theme": "dark", "language": "en" }, "size": 45, "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T14:20:00Z", "expiresAt": null }, { "category": "config", "key": "feature-flags", "value": { "darkMode": true, "betaFeatures": false }, "size": 52, "createdAt": "2026-01-16T09:15:00Z", "updatedAt": "2026-01-16T09:15:00Z", "expiresAt": null } ] ``` **Headers:** `X-Total-Count`, `X-Page`, `X-Limit`, `X-Total-Pages`, `X-Category` **Aggregated mode** (`aggregate=true`): ```json { "items": [...], "totalCount": 5, "page": 1, "pageSize": 50, "totalPages": 1, "category": "config" } ``` **Error Responses** | Status | Description | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Both `page` and `offset` parameters were specified, a pagination value is outside its range, or a `where` entry is not in `field:value` form | | `401` | Missing or invalid API key | *** Get Item Metadata [#get-item-metadata] **GET** /api/v1/storage/json/{category}/{key}/meta Get metadata for a JSON document without retrieving the stored content. **Path Parameters** | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------- | | `category` | string | Yes | Logical grouping (namespace) | | `key` | string | Yes | Unique identifier within category | **Query Parameters** | Parameter | Type | Required | Description | | ------------ | ------- | -------- | ------------------------------------------------------------------------ | | `allowEmpty` | boolean | No | If `true`, returns `{}` instead of 404 when not found (default: `false`) | **Code Samples** ```bash curl "https://api.ainoflow.io/api/v1/storage/json/config/app-settings/meta" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python import requests response = requests.get( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings/meta", headers={"Authorization": "Bearer YOUR_API_KEY"} ) metadata = response.json() ``` ```javascript const response = await fetch( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings/meta", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const metadata = await response.json(); ``` ```csharp var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY"); var response = await client.GetAsync( "https://api.ainoflow.io/api/v1/storage/json/config/app-settings/meta"); var metadata = await response.Content.ReadAsStringAsync(); ``` **Response** `200 OK` ```json { "category": "config", "key": "app-settings", "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T14:20:00Z", "expiresAt": null, "version": 3, "etag": "d41d8cd98f00b204e9800998ecf8427e", "size": 256, "createdBy": "api_key_123", "updatedBy": "api_key_456" } ``` **Headers:** `X-Category`, `X-Key`, `ETag`, `X-Version`, `X-Updated-At` **Error Responses** | Status | Description | | ------ | ----------------------------------- | | `401` | Missing or invalid API key | | `404` | Item not found, deleted, or expired | # Convert (/docs/mcp/convert) Document OCR, text extraction, and audio transcription via Model Context Protocol. Extract text from PDFs, images, Office documents, and transcribe audio files in 60+ languages. **One tool** submits a file by URL and waits for the job to finish; the outputs are then read as **MCP resources, not through a tool** — `convert://jobs/{id}` for the job's status, and `convert://jobs/{id}/text` and `convert://jobs/{id}/pdf` for the results themselves. Uploading bytes, choosing a model, polling instead of waiting, and webhook delivery are all REST-only — see [Beyond the tools](#beyond-the-tools). **Entry contract** ```http POST https://mcp.ainoflow.io/mcp/v1/convert Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` The MCP host is **`mcp.ainoflow.io`**. It is not `api.ainoflow.io` — that host serves the REST API and does not route `/mcp/v1/*`, so an MCP client pointed at it is rejected before it reaches this surface. Both headers are required on every request. Connection [#connection] Claude Desktop [#claude-desktop] ```json { "mcpServers": { "ainoflow-convert": { "url": "https://mcp.ainoflow.io/mcp/v1/convert", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Cursor IDE [#cursor-ide] ```json { "mcpServers": { "ainoflow-convert": { "url": "https://mcp.ainoflow.io/mcp/v1/convert", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Supported Formats [#supported-formats] | Category | Formats | | ------------- | ---------------------------------------------- | | Images (OCR) | PDF, JPEG, PNG, TIFF, BMP, WebP, GIF | | Documents | Word (.doc, .docx), RTF, ODT, TXT | | Spreadsheets | Excel (.xls, .xlsx), ODS | | Presentations | PowerPoint (.ppt, .pptx), ODP | | Audio | WAV, MP3, M4A, MP4, WebM, OGG, FLAC, AAC, Opus | Maximum file size: **100 MB**. That is the default; the limit is resolved per key, so a custom arrangement can set a different one. `convert_guide` reports the effective value as `convertFileSizeBytes`, already resolved for your key. A larger file is refused with a message — this surface carries no machine-readable failure code. *** Start here: `convert_guide` [#start-here-convert_guide] Call `convert_guide` before anything else. It takes no arguments and returns the contract of this surface as data: every tool with its input schema, any response-shape exception, how this surface reports failure (an **empty** code list — see [Failures](#failures)), **the limits in force for your key**, and the capabilities of this area that no tool reaches. ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "convert_guide", "arguments": {} } } ``` The guide's `limits` array reports the two allowances this area enforces: | `name` | `unit` | What it bounds | | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `convertFileSizeBytes` | `bytes` | Largest file this surface accepts for processing. A larger one is refused with a message — no code, no `currentBytes`/`maximumBytes`. | | `convertJobsMonthly` | `jobs` | Jobs this scope may submit per calendar month. `0` or less means unlimited. | Both are resolved for your key from the readers that enforce them, not published defaults. A token with no resolvable scope has no monthly allowance to report and is told the file size limit alone. **An exhausted `convertJobsMonthly` arrives as `-32603`, not as a tool error.** It is the one refusal on this surface that wears the envelope of a server fault, so the shared advice for `-32603` — "retrying unchanged is reasonable" — is exactly wrong for it: nothing changes until the month turns over. Read the message on any `-32603` from `submit_file_url` before retrying, and check this limit. `initialize` also returns an `instructions` string naming it, so a client learns the guide exists without reading anything. **This page and the guide describe the same surface — the guide is the copy that cannot go stale.** Its tool inventory is generated from the same definitions `tools/list` returns, so a tool cannot exist without appearing in it. Limits it reports are resolved for your key, not the published default. Where this page and the guide disagree, the guide is right and this page is a bug. Tools [#tools] submit_file_url [#submit_file_url] Submit a file from an external URL for OCR processing or audio transcription: PDF, images, Office documents and audio. **This API downloads the URL itself, so it must be reachable without credentials.** The call **waits for the job to finish** and answers with its status and pre-signed URLs for the outputs -- it does **not** return the text itself. Read `convert://jobs/{id}/text` or fetch the URL. **Parameters:** | Name | Type | Required | Description | | ----------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------- | | `sourceUrl` | string | Yes | HTTP/HTTPS URL to download the file | | `outputs` | string | Yes | Output formats: `text`, `pdf`, or `text,pdf`. Pass an empty string to take the `text` default. PDF not available for audio. | | `languages` | string | No | Comma-separated ISO 639-1 codes; omitted or empty means `en`. Examples: `en`, `en,de,fr`, `zh-cn,ja` | **`outputs` is required — a call that omits it is refused.** The submit command declares `outputs` as a required member, so the call is rejected with `-32602` before the handler is reached, and nothing is submitted. The value still has a default *once supplied*: an **empty string** means `text`. `languages` is genuinely optional, and omitted or empty means `en`. **Example request** (the minimal call — `outputs` is empty, which means `text`): ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "submit_file_url", "arguments": { "sourceUrl": "https://example.com/document.pdf", "outputs": "" } } } ``` **Example with options:** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "submit_file_url", "arguments": { "sourceUrl": "https://example.com/invoice.pdf", "languages": "en,de,fr", "outputs": "text,pdf" } } } ``` **Success response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"id\":\"550e8400-e29b-41d4-a716-446655440000\",\"status\":\"completed\",...}" } ], "structuredContent": { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "completed", "models": "tesseract", "processingTimeInSeconds": 3.5, "responseMode": "persisted", "files": [ { "text": { "url": "https://storage.example.com/results/job_text.txt?signature=...", "expiration": "2026-01-15T12:00:00Z" }, "pdf": { "url": "https://storage.example.com/results/job_pdf.pdf?signature=...", "expiration": "2026-01-15T12:00:00Z" } } ], "createdAt": "2026-01-15T10:30:00Z", "completedAt": "2026-01-15T10:30:04.5Z", "expiryAt": "2026-01-16T10:30:00Z" }, "isError": false } } ``` **Error response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "Command failed: Unable to download file from URL" } ], "isError": true } } ``` *** Resources [#resources] convert://jobs/{id} [#convertjobsid] Access job status and results. | Property | Value | | --------------- | ----------------------------------------------------- | | **URI Pattern** | `convert://jobs/{id}` | | **MIME Type** | `application/json` | | **Description** | Returns full job status with pre-signed download URLs | **Example request:** ```json { "jsonrpc": "2.0", "id": 10, "method": "resources/read", "params": { "uri": "convert://jobs/550e8400-e29b-41d4-a716-446655440000" } } ``` **Example response:** ```json { "jsonrpc": "2.0", "id": 10, "result": { "contents": [ { "uri": "convert://jobs/550e8400-e29b-41d4-a716-446655440000", "mimeType": "application/json", "text": "{\"id\":\"550e8400-...\",\"status\":\"completed\",\"files\":[...]}" } ] } } ``` *** convert://jobs/{id}/text [#convertjobsidtext] Access plain text extraction result or audio transcription directly. | Property | Value | | --------------- | --------------------------------------------------- | | **URI Pattern** | `convert://jobs/{id}/text` | | **MIME Type** | `text/plain` | | **Description** | Returns extracted text content directly (not a URL) | **Example request:** ```json { "jsonrpc": "2.0", "id": 11, "method": "resources/read", "params": { "uri": "convert://jobs/550e8400-e29b-41d4-a716-446655440000/text" } } ``` **Example response:** ```json { "jsonrpc": "2.0", "id": 11, "result": { "contents": [ { "uri": "convert://jobs/550e8400-e29b-41d4-a716-446655440000/text", "mimeType": "text/plain", "text": "Extracted text content from the document..." } ] } } ``` *** convert://jobs/{id}/pdf [#convertjobsidpdf] Access PDF with searchable text layer. Not available for audio files. | Property | Value | | --------------- | ------------------------------------------- | | **URI Pattern** | `convert://jobs/{id}/pdf` | | **MIME Type** | `application/pdf` | | **Description** | Returns base64-encoded PDF blob (not a URL) | **Example request:** ```json { "jsonrpc": "2.0", "id": 12, "method": "resources/read", "params": { "uri": "convert://jobs/550e8400-e29b-41d4-a716-446655440000/pdf" } } ``` **Example response:** ```json { "jsonrpc": "2.0", "id": 12, "result": { "contents": [ { "uri": "convert://jobs/550e8400-e29b-41d4-a716-446655440000/pdf", "mimeType": "application/pdf", "blob": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9GaWx0ZXIvRmxhdGVEZWNvZGU..." } ] } } ``` **How a resource read fails** | Situation | Answer | | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | The request carries no `id` | `-32600` — `resources/read` is a request, not a notification | | `params` is absent, carries no `uri`, or a `uri` that is not a string | `-32602` (`Missing params` / `Missing uri` / `Invalid params format`) | | An `{id}` that is not a GUID | `-32602` (`Invalid job ID: ...`) | | A URI that is not `convert://jobs/…`, an unknown job, an unfinished job, a format the job did not produce, or an output that could not be fetched | `-32001` | | The server itself failed | `-32603` | **This changed: an absent or wrong-typed `uri` now answers `-32602`, not `-32603`.** It used to reach the outermost handler and come back as "the server itself failed" — for a request that never named a readable address. All six Ainoflow MCP surfaces that serve resources now answer `-32602` for a `uri` they cannot read. Only the failure *code* changed; Convert refused the same calls before and refuses them now, so this sits inside the OCR/Convert freeze. Beyond the tools [#beyond-the-tools] The tool set is deliberately smaller than the Convert REST surface, so a client is not handed a menu it has to scroll. The tool is the simplest path — a URL in, finished outputs out — so everything the submit contract offers beyond that shape is REST-only. Reach these over REST on `https://api.ainoflow.io`, with the same API key. | Capability | REST | What it gives you | | ------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Submit an uploaded file | `POST /api/v1/convert/submit-file` | `multipart/form-data` upload for a file that has no URL to fetch it from. `submit_file_url` can only reach a file this API can download. | | Submit base64 content | `POST /api/v1/convert/submit-base64` | The same submission with the bytes inline as base64, for a caller that cannot host the file either. | | The full submit contract | `POST /api/v1/convert/submit-url` | The same URL submission with everything `submit_file_url` fixes: `response` (direct, polling, webhook, persisted), `webhookUrl`, `reference` and `models`. The tool always uses `persisted` — it waits for completion and answers with pre-signed URLs. | | Job status | `GET /api/v1/convert/jobs/{jobId}` | Poll a job outside MCP. Over MCP the same answer is the `convert://jobs/{id}` resource. | | Download an output | `GET /api/v1/convert/jobs/{jobId}/text` | The finished text, and `/pdf` for the searchable PDF. Over MCP these are the `convert://jobs/{id}/text` and `/pdf` resources. | `convert_guide` returns this same list as data, each entry with its method, path and description. Failures [#failures] Two different things can go wrong, and they arrive differently. **A refusal** — your call reached the handler and was declined. It answers `200` with a tool result carrying `isError: true` and a message describing the refusal. There is no `structuredContent` and no `retryable` flag: `convert_guide` declares an **empty** `errors.codes`, which is this surface stating plainly that it speaks none of the shared vocabulary. Read the text block. **One refusal is the exception** and leaves as a JSON-RPC `-32603` instead: an exhausted monthly job allowance (`convertJobsMonthly`). Every other Ainoflow MCP surface maps that refusal to a tool result with a code; doing it here would flip the envelope a live caller receives, and this surface is frozen, so the shape is reported rather than changed. **A protocol or server error** — your call never ran, or the service failed. It arrives as a JSON-RPC error: | Code | Meaning | | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `-32600` | Not a valid JSON-RPC 2.0 request, or a `tools/call` without an `id` | | `-32601` | Unknown method, unknown tool, or a capability this surface does not offer | | `-32602` | An argument is absent, blank, or of the wrong JSON type — your call never reached the handler | | `-32603` | The server itself failed — **with one exception here**: an exhausted monthly job allowance arrives as `-32603` too. Retrying unchanged is reasonable for the first and useless for the second, so read the message before retrying | | `-32001` | Unauthorized, or a resource URI that resolves to nothing | The allowance refusal, in full: ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32603, "message": "Convert API jobs limit exceeded. Limit=1000." } } ``` **Convert reports refusals as text, not as codes.** The other Ainoflow MCP surfaces carry a machine-readable `code` in `structuredContent`, each declaring the subset it can produce; Convert is frozen and declares none — `convert_guide` returns `errors.codes` as an empty list rather than claiming a vocabulary this surface does not speak. Read the text block. An oversized file is refused this way too — the message names the limit, and there is no `ITEM_TOO_LARGE`, no `currentBytes` and no `maximumBytes` to branch on here. # Files (/docs/mcp/files) Binary file storage for AI assistants via Model Context Protocol. Upload, download, and manage files of any format using category/key addressing with base64-encoded content over JSON-RPC 2.0. **Entry contract** ```http POST https://mcp.ainoflow.io/mcp/v1/files Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` The MCP host is **`mcp.ainoflow.io`**. It is not `api.ainoflow.io` — that host serves the REST API and does not route `/mcp/v1/*`, so an MCP client pointed at it is rejected before it reaches this surface. Both headers are required on every request. The surface is also mounted **bound to one category**, on the same host and with the same headers: ```http POST https://mcp.ainoflow.io/mcp/v1/files/{category} Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` There, the category comes from the route: no tool takes a `category` argument, no result repeats it, and the tools are named `{category}_files_*` — `documents_files_upload`, not `files_upload` (`my-app-docs` normalises to `my_app_docs`). `files_list_categories` does not exist on that endpoint. **`files_guide` keeps its name on both**, because the area is the route segment after `/mcp/v1/`, which is `files` either way. Connection [#connection] Claude Desktop [#claude-desktop] ```json { "mcpServers": { "ainoflow-files": { "url": "https://mcp.ainoflow.io/mcp/v1/files", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Cursor IDE [#cursor-ide] ```json { "mcpServers": { "ainoflow-files": { "url": "https://mcp.ainoflow.io/mcp/v1/files", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Start here: `files_guide` [#start-here-files_guide] Call `files_guide` before anything else. It takes no arguments and returns the contract of this surface as data: every tool with its input schema, any response-shape exception, the failure codes below, **the limits in force for your key**, and the capabilities of this area that no tool reaches. ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_guide", "arguments": {} } } ``` `initialize` also returns an `instructions` string naming it, so a client learns the guide exists without reading anything. **This page and the guide describe the same surface — the guide is the copy that cannot go stale.** Its tool inventory is generated from the same definitions `tools/list` returns, so a tool cannot exist without appearing in it. Limits it reports are resolved for your key, not the published default. Where this page and the guide disagree, the guide is right and this page is a bug. Tools [#tools] files_list_categories [#files_list_categories] List all distinct categories in files storage with file counts for navigation. Supports pagination. **Parameters:** | Name | Type | Required | Description | | -------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------- | | `page` | integer | No | Page number (1-based, default: 1). Cannot be used with `offset`. A value below 1 is **refused**, not raised to 1. | | `offset` | integer | No | Number of items to skip. Cannot be used with `page`. A negative value is **refused**, not raised to 0. | | `limit` | integer | No | Items per page, **0–5000** (default: 100). `0` answers an empty page carrying the real `total`. | **A pagination argument that is not a whole number is refused, not ignored.** `page`, `offset` and `limit` are read as integers: `{"page": "2"}` and `{"page": 1.5}` both answer `-32602` and nothing is listed. They used to be dropped in silence, so a mistyped `page` handed back page 1 under the caller's own argument, and a fractional one arrived as `-32603` — our fault, for a caller mistake. **A value out of range is refused too — never clamped, never defaulted.** `{"limit": 10000}` answers `-32602` (`Argument 'limit' must be between 0 and 5000; 0 answers an empty page carrying the real total count`) rather than quietly returning 5000 rows, so a short page is short because the data ran out. `limit: 0` is the one value outside 1–5000 that is accepted: it asks how many there are without reading any. The schemas used to declare `minimum: 1` and forbid it. **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_list_categories", "arguments": {} } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/files" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_list_categories", "arguments": {} } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"categories\":[{\"category\":\"documents\",\"count\":87},{\"category\":\"images\",\"count\":245},{\"category\":\"temp\",\"count\":12}],\"total\":3,\"page\":1,\"limit\":100,\"totalPages\":1}" } ] } } ``` *** files_upload [#files_upload] Upload a binary file to storage with category/key addressing. Key is optional -- if not provided, a UUID will be auto-generated. The result carries a pre-signed `downloadUrl` already, so a fresh `files_get_url` is not needed straight after an upload. **`content` is base64, which is about a third larger than the bytes it encodes.** The size limit applies to the **decoded** size — `files_guide` reports it as `filesFileSizeBytes`, resolved for your key. A file above it is refused with `ITEM_TOO_LARGE` and nothing is stored. **The published limit counts the content, not the request.** A format that inflates the content — base64, at four bytes for every three — spends more of the request allowance on the same file, so the transport ceiling is derived from the published limit and sits above it: about 134 MB of request for a 100 MB file limit. Send a file up to the published size and the encoding is our problem, not yours. A 100 MB upload is genuinely reachable here; it previously died as a bare transport `413` carrying none of the numbers this surface promises. **Parameters:** | Name | Type | Required | Description | | ------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping/namespace for files, **1–100 characters** and **at most 255 UTF-8 bytes per `/`-separated segment**. Every segment has to name something, and only control characters are refused from it — the same shape and character rules as `key`. Past any of them: `SCHEMA_VALIDATION_FAILED`. | | `content` | string | Yes | Base64 encoded file content | | `key` | string | No | Unique identifier within category, **at most 255 bytes per `/`-separated segment, at most `425 − length of the category` characters in total, and at most `944 − the category's bytes − 1` bytes in total** (see the callouts below). A UUID is auto-generated when the argument is **absent, and only when it is absent** — a value that is not a string is **refused**, not ignored. | | `fileName` | string | No | Original filename, **at most 500 characters**, in any script. Defaults to the key when **absent, and only when absent**; a value that is not a string is **refused**, not ignored. | | `contentType` | string | No | MIME type. It has to parse as `type/subtype` (optionally with parameters), **then** be printable ASCII, **then** be at most 255 characters — *which* media types are accepted is not restricted. Defaults to `application/octet-stream` when **absent or empty**; a value that is not a string is **refused**, not ignored. | | `expiresAt` | string | No | Expiration date (ISO 8601). Cannot be used with `expiresMs`. A value that is not an ISO 8601 date-time string is **refused**, not ignored, and so is one that is not in the future — an expiry already past returns `SCHEMA_VALIDATION_FAILED` and stores nothing. | | `expiresMs` | integer | No | TTL in milliseconds from now. Cannot be used with `expiresAt`. A value that is not a whole number is **refused**, not ignored. | **`key` selects the write mode.** Supplying `key` stores the file at exactly that address and **fails with `CONFLICT` if a file already exists** there — use it when an accidental overwrite would be a bug. Omitting `key` auto-generates a UUID, which cannot be taken. That is also why a mistyped `key` is refused rather than dropped: dropping it would silently switch the write mode from create-only to auto-generated, so a caller that meant "fail if this address is taken" would get a new file at an address it never chose. **`CONFLICT` covers two refusals, and the message names which.** | What happened | Where | What to do | | ----------------------------------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------- | | **The key is already taken** | `files_upload` with an explicit `key` | Choose another key, or replace in place over REST (`PUT /api/v1/files/{category}/{key}`) | | **The write lost a race** — another writer changed the file while your call was in flight | Any write, including an upload with a generated key | Read the file again and decide again against what is there | A generated key cannot be taken, but it does not rule the race out: whichever way the address was chosen, **nothing of yours is recorded or charged**, and the file at the address is the other writer's with its version, ETag and metadata exactly as they left them. The message reads *"The file was modified by another writer while this upload was in flight. Re-read the file and apply the upload again - repeating this call unchanged cannot succeed."* `CONFLICT` carries **`retryable: false`**, and that is not a formality for either meaning: repeating the identical call replays a decision made against a file that has moved on, or asks again for a key that is still taken. A lost race used to arrive as `-32603` — a server fault reported for an ordinary collision between two writers. `expiresAt` and `expiresMs` are mutually exclusive — supplying both returns `-32602` ("Cannot specify both expiresAt and expiresMs parameters") and stores nothing, the same rule the REST API applies. An `expiresAt` must be in the future — one that is already past is refused with `SCHEMA_VALIDATION_FAILED` and stores nothing. An `expiresMs` of `0` or less means **no expiration**. **Three bounds apply to a key, and each governs something different.** All three are this API's own limits on the address you choose, and not one of them has moved: where a file's bytes are held is composed from system-generated identifiers alone and carries **none of your text**, so nothing you write competes with it for room. | Bound | Value | What it governs | How you get past it | | ----------------------- | -------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------- | | Per segment, in bytes | **255 bytes** | each `/`-separated segment on its own | split the address with `/` | | In total, in characters | **`425 − length of the category`** | the key's length as a whole | shorten the key, or the category | | In total, in bytes | **`944 − the category's bytes − 1`** (**643** whatever the category) | the address's weight as a whole | shorten the key or the category — splitting does **not** help | * **255 bytes per `/`-separated segment.** An address is held as a path, and every `/`-separated segment of it is bounded on its own at 255 bytes. A `/` in your key starts another segment, so this one is measured segment by segment, never against the whole value — splitting a long address with `/` is how you get past it. It applies to `category` too, since both contribute segments to the same address. * **`425 − length of the category` characters in total.** The address the category and key compose has to fit 500 characters, of which the scope and project identifiers take 74 and the category's separator one. **A shorter category genuinely leaves room for a longer key, one character for one**: `documents` (9 characters) allows 416, a 100-character category allows 325. * **`944 − the category's bytes − 1` bytes in total.** The category and the key have 944 bytes between them, their separator included. `documents` leaves a key 934 of them; **643 bytes** is the number that holds whatever the category is, which is what the category-agnostic endpoint publishes. **This is the bound splitting cannot escape** — the per-segment rule is spent once per segment, so a key of several perfectly legal segments still totals up to this one. A CJK key of 343 characters across legal segments is 1023 bytes: inside every character bound, and past this one. `files_guide` reports the same numbers, and where this page and the guide ever disagree the guide is right. * **A hierarchical key may total more than 255 and still be stored** — `2026/08/` plus 252 characters is three segments of 4, 2 and 252, and it is stored. Only a key with no separator is one segment, and only then is 255 its total. * **The character bound and the byte bounds count different things**, which is why there are three numbers. One ASCII character is one byte, so an ASCII key meets the character bound first and never sees the byte ones. Outside ASCII a character costs up to three bytes: a 200-character Cyrillic key with no separator is one segment of 400 bytes and is refused, and a long non-ASCII *hierarchical* key can sit inside every character bound and still be past the total-byte one. * 1024 characters is the column's own width. **No schema publishes it, and no category reaches it.** **Both endpoints publish the same `maxLength`, and it is not the whole rule:** | Endpoint | `key` `maxLength` | | -------------------------- | ---------------------------------------------------------------------------------------- | | `/mcp/v1/files` | `255` — the length accepted whatever category the call names | | `/mcp/v1/files/{category}` | `255` — `min(425 − length of the category, 255)` for its route category, the same number | `maxLength` counts the whole string, and JSON Schema can say neither "per segment" nor "in bytes", so both declare what a **single-segment** key may be and the argument's description carries the other two bounds. A hierarchical key is accepted past it. Under-promising is the safe direction: **neither endpoint ever advertises a length it would then refuse**, so a client that validates against the schema is never told an address is acceptable and then refused it. (The number is floored at `0`, so a bound endpoint always emits a schema a validator will load.) **`fileName` (500) is a plain per-field bound; `contentType` (255) carries two rules beyond its length.** Both are applied to the **resolved** values rather than to what you sent: the name is your `fileName`, or the key, or — for a REST `sourceUrl` upload — the name the **remote server** supplies. `contentType` has to parse as a media type, so `notatype` is refused while any well-formed `type/subtype` — including an invented one — is stored; and it has to be **printable ASCII**, so `text/plain; charset="é"` is refused even though it parses. Shape before length, because shortening something that is not a media type only earns the other refusal; the message quotes at most 60 characters of the value. Any of these past its bound answers `SCHEMA_VALIDATION_FAILED` and **stores nothing**: no partial write, no orphaned object. They used to reach the database and come back as `-32603`, reporting a caller mistake as a server fault. **`fileName` is not held to ASCII** — `résumé.pdf` is stored, and the REST download encodes it into `Content-Disposition` per RFC 6266 rather than refusing it. `contentType` is the one field where a non-ASCII value is genuinely refused, because a media type is ASCII by definition and a percent-encoded `Content-Type` is a type no client understands. **An address is more than a length: it has a shape and a character set.** **Every `/`-separated segment has to name something** — in `key` and `category` alike. No empty segment, so no leading or trailing `/` and no `//`, and no `.` or `..` segment. | Accepted | Refused | | ----------------------------------------------- | -------------------------------------------------- | | `2026/08/report`, `a/b/c`, `...`, `a/.hidden/b` | `a//b`, `/leading`, `trailing/`, `a/./b`, `a/../b` | `.` and `..` go because they are not stored where they are written: the composed name travels as a URI path and URI resolution removes them, so `a/./b` would be stored at `a/b` while the record says `a/./b`, and `..` walks the address out of the prefix that separates one tenant from another. **Any script is accepted.** `café`, `документы`, `2026/08/報告書` and emoji are stored, listed and answered exactly as you sent them — object names are kept as UTF-8, and the address is yours to choose. **Only a control character is refused**, with `SCHEMA_VALIDATION_FAILED` before anything is written: `"\u0000"` is a legal JSON string but not a legal address, and neither is a tab. **Two addresses that used to work are refused on a write now.** * **A key or `category` ending in `/`.** Storage treats a name ending in `/` as a directory marker, so such a key was accepted, stored, listed and downloadable — while `a//b` and `/leading` came back as `-32603` from the backend. The rule was whatever the backend happened to do, which is not a rule you can be told; it is a stated rule now, and `trailing/` is what that costs. * **A key or `category` containing a tab**, under the control-character rule. **Files already stored that way stay reachable.** `files_get_metadata`, `files_get_url`, `files_list` and `files_delete` do not validate the address — only a new write to such an address is refused. **A non-ASCII address is the opposite of a narrowing — it was never meant to fail.** A key of `café` used to answer `500` over the *REST* surface, and storage was never the reason: the write, the record and the object were all fine, but a REST answer echoes your address into the `X-Category` and `X-Key` headers, and an HTTP header value is US-ASCII. That is fixed by encoding those headers, not by refusing the address. This surface was never affected — JSON carries any script — and it still returns your address exactly as you sent it. **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_upload", "arguments": { "category": "documents", "key": "report-2024", "content": "JVBERi0xLjQKJeLjz9MK...", "fileName": "report.pdf", "contentType": "application/pdf" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/files" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_upload", "arguments": { "category": "documents", "key": "report-2024", "content": "JVBERi0xLjQKJeLjz9MK...", "fileName": "report.pdf", "contentType": "application/pdf" } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"category\":\"documents\",\"key\":\"report-2024\",\"fileName\":\"report.pdf\",\"contentType\":\"application/pdf\",\"size\":1048576,\"version\":1,\"etag\":\"d41d8cd98f00b204e9800998ecf8427e\",\"createdAt\":\"2026-01-15T10:30:00Z\",\"expiresAt\":null,\"downloadUrl\":\"https://storage.example.com/...\",\"downloadUrlExpiresAt\":\"2026-01-15T11:30:00Z\"}" } ] } } ``` *** files_get_metadata [#files_get_metadata] Get file metadata without downloading the file content. Returns error if not found unless `allowEmpty=true`. **Parameters:** | Name | Type | Required | Description | | ------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `category` | string | Yes | Logical grouping/namespace, 1–100 characters and at most 255 UTF-8 bytes per `/`-separated segment, held to the same shape and character rules as `key` | | `key` | string | Yes | Unique identifier, at most 255 bytes per `/`-separated segment, at most `425 − length of the category` characters in total, and at most `944 − the category's bytes − 1` bytes in total | | `allowEmpty` | boolean | No | If `true`, an absent file answers a success carrying `{}` instead of `NOT_FOUND`. `false` applies when the argument is **absent, and only when it is absent** — a value that is not a boolean is **refused**, never read as `false`. | **`allowEmpty` is refused when it is not a boolean.** Read as "the JSON literal `true`, or else false", `{"allowEmpty": "true"}` opted the caller *out* of the behaviour it had just asked for, and the call was then refused with `NOT_FOUND` under its own argument. It is now checked before the query runs, so the refusal never depends on whether the file happened to exist. **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_get_metadata", "arguments": { "category": "documents", "key": "report-2024" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/files" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_get_metadata", "arguments": { "category": "documents", "key": "report-2024" } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"category\":\"documents\",\"key\":\"report-2024\",\"fileName\":\"report.pdf\",\"contentType\":\"application/pdf\",\"size\":1048576,\"etag\":\"d41d8cd98f00b204e9800998ecf8427e\",\"createdAt\":\"2026-01-15T10:30:00Z\",\"updatedAt\":\"2026-01-15T14:20:00Z\",\"expiresAt\":null,\"version\":1,\"createdBy\":\"api_key_123\",\"updatedBy\":\"api_key_123\"}" } ] } } ``` *** files_get_url [#files_get_url] Get a pre-signed URL for direct file download (bypasses the API, time-limited). This is how bytes leave the surface -- no tool returns file content, so hand the URL on rather than fetching it into context. Returns `NOT_FOUND` unless `allowEmpty=true`. **Parameters:** | Name | Type | Required | Description | | --------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `category` | string | Yes | Logical grouping/namespace, 1–100 characters and at most 255 UTF-8 bytes per `/`-separated segment, held to the same shape and character rules as `key` | | `key` | string | Yes | Unique identifier, at most 255 bytes per `/`-separated segment, at most `425 − length of the category` characters in total, and at most `944 − the category's bytes − 1` bytes in total | | `expirySeconds` | integer | No | URL lifetime in seconds, 1–86400. `3600` applies when the argument is **absent, and only when it is absent**. Out of range is **refused**, not adjusted; a value that is not a whole number is **refused**, not ignored. | | `allowEmpty` | boolean | No | If `true`, an absent file answers a success carrying `{}` instead of `NOT_FOUND`. `false` applies when the argument is **absent, and only when it is absent** — a value that is not a boolean is **refused**, never read as `false`. | **A pre-signed URL links to the content it was issued for, not to the address — so it can stop working before the lifetime you asked for is up.** Replace that file or delete it and the URL stops serving within the hour, answering the storage backend's own not-found response rather than an MCP failure. It never serves a *different* file: the bytes behind a link are the bytes it was issued for, and a replacement's content is reachable only through a freshly issued URL. The old behaviour was the worse one: a URL issued before a replacement kept working and **silently served whatever happened to be at the address** — content its holder never asked for, under a link they were given for something else. **The window is about an hour, and it is not a guarantee of an hour.** Superseded content is held for an hour after the write that replaced it, then removed on the next cleanup pass. `expirySeconds` reaches `86400`, so a link can comfortably outlive the content it was issued for. **For an agent handing a URL on, that means three things.** Fetch the URL soon after you get it rather than parking it in a plan for later; ask for the lifetime the recipient actually needs instead of the maximum; and call `files_get_url` again after a replacement rather than reusing the link you already had. The *address* is stable — `category` and `key` still name the current file, as always. **An out-of-range `expirySeconds` is refused, not clamped.** A value below `1` or above `86400` returns `-32602` (`Argument 'expirySeconds' must be between 1 and 86400 seconds`) and no URL is issued, so the lifetime you get is always the one you asked for. It was previously silently adjusted — a request for one second was answered with an hour, and a request for a week with a day, both under a response that looked like it had been honoured. `expirySeconds` is an integer: a quoted `"3600"` or a fractional `3600.5` returns `-32602` as well. **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_get_url", "arguments": { "category": "documents", "key": "report-2024", "expirySeconds": 7200 } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/files" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_get_url", "arguments": { "category": "documents", "key": "report-2024", "expirySeconds": 7200 } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"downloadUrl\":\"https://storage.example.com/signed-url?...\",\"downloadUrlExpiresAt\":\"2026-01-15T12:30:00Z\"}" } ] } } ``` *** files_list [#files_list] List a category's files with name, content type, size and timestamps, never the bytes. Always ordered `createdAt` ascending — **sorting is REST-only** (see [Beyond the tools](#beyond-the-tools)). **Parameters:** | Name | Type | Required | Description | | ---------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping/namespace, 1–100 characters and at most 255 UTF-8 bytes per `/`-separated segment, held to the same shape and character rules as `key` | | `page` | integer | No | Page number (1-based, default: 1). Cannot be used with `offset`. A value below 1 is **refused**, not raised to 1. | | `offset` | integer | No | Number of items to skip. Cannot be used with `page`. A negative value is **refused**, not raised to 0. | | `limit` | integer | No | Items per page, **0–5000** (default: 100). `0` answers an empty page carrying the real `totalCount`. | | `prefix` | string | No | Filter files by key prefix. The listing is unfiltered when the argument is **absent, and only when it is absent** — a value that is not a string is **refused**, not ignored. | `page` only reaches offsets that are multiples of `limit`. Use `offset` to resume a scan at an arbitrary position, or to change the batch size between calls. **A pagination argument that is not a whole number is refused, not ignored.** `{"limit": "10"}` and `{"offset": 2.5}` both answer `-32602` and nothing is listed. Supplying both `page` and `offset` answers `-32602` too (`Cannot specify both 'page' and 'offset' parameters`). **Out of range is refused as well — never clamped, never defaulted.** `{"limit": 10000}` and `{"page": 0}` answer `-32602` and list nothing, so a page that comes back short is short because the data ran out. `limit: 0` stays valid and asks how many files the category holds without reading any of them. **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_list", "arguments": { "category": "documents", "prefix": "report-", "limit": 10 } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/files" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_list", "arguments": { "category": "documents", "prefix": "report-", "limit": 10 } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"category\":\"documents\",\"items\":[{\"category\":\"documents\",\"key\":\"report-2024\",\"fileName\":\"report.pdf\",\"contentType\":\"application/pdf\",\"size\":1048576,\"createdAt\":\"2026-01-15T10:30:00Z\",\"updatedAt\":\"2026-01-15T14:20:00Z\",\"expiresAt\":null}],\"page\":1,\"limit\":10,\"totalCount\":1,\"totalPages\":1}" } ] } } ``` *** files_delete [#files_delete] Delete a file from storage. **`NOT_FOUND` and `CONFLICT` are different answers, and the difference is whether anything is there.** `NOT_FOUND` means nothing live is at the address — no such file, or it was already deleted or has expired. There is nothing to remove, so stop. `CONFLICT` means something **is** there and it changed while your delete was in flight. **Nothing was deleted**: the file is still readable — its content intact and still matching the `etag` `files_get_metadata` publishes for it, not merely its record — and its bytes still count against the scope's stored-bytes allowance, which was **not** credited back. Read the address again and decide against what the other writer left. It carries `retryable: false` — repeating the identical call cannot succeed. Both used to arrive as `NOT_FOUND`, which told a caller that a file it could still read did not exist. **On a success, the room is free immediately and the content leaves within the hour.** The file stops being readable the moment the call answers, and its bytes are credited back to the allowance in the same moment; the content itself is removed from storage within the hour, which is also when any pre-signed URL issued for that file stops working. **Parameters:** | Name | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping/namespace, 1–100 characters and at most 255 UTF-8 bytes per `/`-separated segment, held to the same shape and character rules as `key` | | `key` | string | Yes | Unique identifier, at most 255 bytes per `/`-separated segment, at most `425 − length of the category` characters in total, and at most `944 − the category's bytes − 1` bytes in total | **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_delete", "arguments": { "category": "temp", "key": "upload-123" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/files" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "files_delete", "arguments": { "category": "temp", "key": "upload-123" } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"category\":\"temp\",\"key\":\"upload-123\"}" } ] } } ``` Resources [#resources] `files://{category}/{key}` [#filescategorykey] Access stored file content as a base64-encoded blob. * **MIME Type:** Varies based on the file's content type * **URI Pattern:** `files://{category}/{key}` **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": { "uri": "files://documents/report-2024" } } ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "contents": [ { "uri": "files://documents/report-2024", "mimeType": "application/pdf", "blob": "JVBERi0xLjQKJeLjz9MK..." } ] } } ``` *** `files://{category}/{key}/meta` [#filescategorykeymeta] Access file metadata as a JSON resource without downloading the file content. * **MIME Type:** `application/json` * **URI Pattern:** `files://{category}/{key}/meta` **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": { "uri": "files://documents/report-2024/meta" } } ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "contents": [ { "uri": "files://documents/report-2024/meta", "mimeType": "application/json", "text": "{\"category\":\"documents\",\"key\":\"report-2024\",\"fileName\":\"report.pdf\",\"contentType\":\"application/pdf\",\"size\":1048576,\"etag\":\"d41d8cd98f00b204e9800998ecf8427e\",\"createdAt\":\"2026-01-15T10:30:00Z\",\"updatedAt\":\"2026-01-15T14:20:00Z\",\"version\":1}" } ] } } ``` *** **How a resource read fails** | Situation | Answer | | -------------------------------------------------------------------- | ------------------------------------------------------------ | | The request carries no `id` | `-32600` — `resources/read` is a request, not a notification | | `params` carries no `uri`, or a `uri` that is not a string | `-32602` (`Missing uri` / `Invalid params format`) | | A URI this surface does not serve, or a category/key holding nothing | `-32001` | | The backing store failed | `-32603` | **This changed: `-32001` and `-32603` are now distinct on a resource read.** This surface answered `-32001` to *everything*, so object storage being unreachable was reported as "that file does not exist" — and a client that trusted the code deleted its local copy of a file that was still there. `-32001` now means only that the URI resolves to nothing; a genuine backing-store fault is `-32603`. On the category-bound endpoint (`/mcp/v1/files/{category}`) a URI naming a different category resolves to nothing as well. **A `uri` of the wrong JSON type is `-32602`, not `-32603`.** It used to escape to the outermost handler and arrive as "the server itself failed", for a request that never named a readable address. All six Ainoflow MCP surfaces that serve resources now answer `-32602` for a `uri` they cannot read. Beyond the tools [#beyond-the-tools] The tool set is deliberately smaller than the Files REST surface, so a client is not handed a menu it has to scroll. These capabilities exist but no tool reaches them — reach them over REST on `https://api.ainoflow.io`, with the same API key. | Capability | REST | What it gives you | | -------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Direct download | `GET /api/v1/files/{category}/{key}` | Stream the file bytes straight from the API. `files_get_url` hands back a pre-signed URL instead, which is the better choice for anything large. | | Create-if-absent at a chosen key | `POST /api/v1/files/{category}/{key}` | Upload only when nothing is there, answering `409` if the key is taken. `files_upload` with an explicit key behaves this way; without one it generates a key that cannot collide. A `409` also answers a write that lost a race with a concurrent writer, which no key can rule out. | | Replace in place | `PUT /api/v1/files/{category}/{key}` | Overwrite an existing file, keeping its address and bumping its version. Answers `409` when a concurrent writer moved the file first, leaving it untouched. | | Sorted listing | `GET /api/v1/files/{category}` | `sortBy` (`key`, `createdAt`, `updatedAt`, `size`) with `sortOrder` (`asc`, `desc`). `files_list` takes neither and always answers `createdAt` ascending; it filters by `prefix` exactly as this endpoint does. | `files_guide` returns this same list as data, each entry with its method, path and description. Failures [#failures] Two different things can go wrong, and they arrive differently. A refusal [#a-refusal] Your call reached the handler and was declined. It answers `200` with a tool result carrying `isError: true`, and `structuredContent` is machine-readable: ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "NOT_FOUND: File not found (documents/report.pdf)" }], "structuredContent": { "code": "NOT_FOUND", "message": "File not found", "retryable": false, "category": "documents", "key": "report.pdf" }, "isError": true } } ``` | `code` | When | Extra fields | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `ITEM_TOO_LARGE` | The decoded upload exceeds the per-file size limit in force for your key | `currentBytes`, `maximumBytes` — always, on every size failure | | `SCHEMA_VALIDATION_FAILED` | The domain refused the request — a `category` or `key` outside its character bound, carrying a `/`-separated segment past 255 UTF-8 bytes, past the 1024-byte total for the whole stored name, carrying a segment that names nothing (`//`, a leading or trailing `/`, `.`, `..`), or carrying a control character; a `fileName` past 500 characters; a `contentType` that is not parseable as a media type, is not printable ASCII, or is past 255 characters; an expiry already past | `invalidPath`, when the refusal has a position to point at (a Files shape refusal usually has none) | | `CONFLICT` | The write collided with the current state: an explicit `key` is already taken, or **any** write — upload or delete, chosen key or generated one — lost a race with a concurrent writer and stored or removed nothing. The file at the address stays the winning writer's, **content included**. The message names which. A lost race previously arrived as `-32603` | — | | `NOT_FOUND` | Nothing live exists at that address. On `files_delete` this is distinct from `CONFLICT`: `NOT_FOUND` means there is nothing to remove, `CONFLICT` means the file is still there | — | | `RATE_LIMITED` | The scope's stored-bytes allowance has no room for these bytes — `files_guide` publishes it as `filesStorageBytes`, and a file well under the size limit is still refused once the scope is full. A **stored** amount, not a monthly one: it never resets on a billing cycle, so retrying unchanged answers the same way until files are deleted. Nothing is stored on the refusal | — | **This is Files' own list, and it is shorter than the shared vocabulary.** Each Ainoflow MCP surface declares only the codes it can actually produce, and `files_guide` returns Files' set as `errors.codes`. There is **no `PRECONDITION_FAILED` here**: replace-in-place is REST-only, so no tool on this surface takes an ETag and no precondition can fail. The page used to print the same six codes as every other surface, which was an upper bound wearing the words of a description. **A refusal never leaves a half-written file, whatever the code.** `CONFLICT`, `SCHEMA_VALIDATION_FAILED`, `ITEM_TOO_LARGE`, `RATE_LIMITED`, a cancelled call, an interruption mid-upload — every one of them ends the same way: **either the write is recorded whole, or the file that was already there is untouched, record and content alike.** So after any refusal, `files_get_url` hands back a URL for content whose length matches the `size` reported by `files_get_metadata` and whose MD5 matches the `etag` beside it. There is nothing to reconcile and no repair to run. Bytes a refused upload had already transferred cost the scope nothing: they are unreachable, **not** counted against the stored-bytes allowance `files_guide` publishes as `filesStorageBytes`, and gone from storage within the hour. The one thing a *successful* write does not preserve is a pre-signed URL — see [`files_get_url`](#files_get_url). **This changed: an oversized upload is `ITEM_TOO_LARGE`, not `SCHEMA_VALIDATION_FAILED`.** While the size check refused with a plain `400` an over-limit upload arrived as `SCHEMA_VALIDATION_FAILED` — telling a caller its request shape was wrong about a file that was merely too big. It now carries `ITEM_TOO_LARGE` **with `currentBytes` and `maximumBytes`**, so resize from the numbers rather than parsing the message. ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "ITEM_TOO_LARGE: File is 128MB and exceeds the 100MB size limit (documents/scan.pdf)" }], "structuredContent": { "code": "ITEM_TOO_LARGE", "message": "File is 128MB and exceeds the 100MB size limit", "retryable": false, "category": "documents", "key": "scan.pdf", "currentBytes": 134217728, "maximumBytes": 104857600 }, "isError": true } } ``` `retryable` is `true` only for `RATE_LIMITED`; every other code needs a changed request — a smaller file, a different key, a decision made against a fresh read. It means the *same* request can succeed later, and here "later" is once stored bytes are freed, not once a billing period rolls over — nothing about this allowance is time-based. A protocol or server error [#a-protocol-or-server-error] Your call never ran, or the service failed. It arrives as a JSON-RPC error: | Code | Meaning | | -------- | --------------------------------------------------------------------------------------------- | | `-32600` | Not a valid JSON-RPC 2.0 request, or a `tools/call` without an `id` | | `-32601` | Unknown method, unknown tool, or a capability this surface does not offer | | `-32602` | An argument is absent, blank, or of the wrong JSON type — your call never reached the handler | | `-32603` | The server itself failed. Not your request; retrying it unchanged is reasonable | | `-32001` | Unauthorized, or a resource URI that resolves to nothing | **Branch on `code`, never on the message text.** Messages are written for humans and may be reworded; `code` is part of the contract. **A wrong argument type is your error, not ours — for every argument, without exception.** Arguments are type-checked before anything is stored or read, and a mismatch returns `-32602` with a message naming the argument — never `-32603` and never a tool result. `{"key": 123}` returns `Argument 'key' must be a string`; `{"expirySeconds": "3600"}` returns `Argument 'expirySeconds' must be an integer number`; an `expiresAt` that is not an ISO 8601 date-time string returns `Argument 'expiresAt' must be an ISO 8601 date-time string`. Pagination is no exception: `{"page": "2"}` and `{"limit": 1.5}` are refused the same way rather than dropped, and neither are `fileName`, `contentType`, `prefix` (strings) or `allowEmpty` (boolean). **A default applies when its argument is *absent*, and only when it is absent.** JSON `null` counts as absent; a value that was sent and could not be read is never replaced by a default. **A value out of range is refused too — nothing on this surface is clamped to a bound.** `page` below 1, `offset` below 0, a `limit` outside 0–5000 and an `expirySeconds` outside 1–86400 all answer `-32602`, and the call never reaches the handler. Clamping made a shrunk request indistinguishable from a finished one: a caller asking for 10000 rows was handed 5000 with no way to tell that from there being 5000 rows. `limit: 0` is the one value outside 1–5000 that is accepted, because it is a capability rather than a mistake — an empty page carrying the real `totalCount`. This governs the arguments each tool **declares**. An argument a tool declares no property for — `sortBy` on `files_list`, say — is not read at all and has no effect, whatever its type. **This changed.** A refusal used to come back as JSON-RPC `-32603` — the same code as a crashed server — so "too large" and "we fell over" were indistinguishable to a client. Business failures now answer `200` with `isError: true` and a code; `-32603` means the server, and only the server. A wrong argument type is still `-32602`, unchanged. # Guard (/docs/mcp/guard) Policy-based execution control via the Model Context Protocol. Guard enforces two kinds of limit -- a rate limit ("how often may this run?") and a concurrency cap ("how many may run at once?") -- to prevent uncontrolled workflow runs, email flooding, API overuse, and contention on a resource that only tolerates a few parallel jobs. **Entry contract** ```http POST https://mcp.ainoflow.io/mcp/v1/guard Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` The MCP host is **`mcp.ainoflow.io`**. It is not `api.ainoflow.io` — that host serves the REST API and does not route `/mcp/v1/*`, so an MCP client pointed at it is rejected before it reaches this surface. Both headers are required on every request. Connection [#connection] Claude Desktop [#claude-desktop] ```json { "mcpServers": { "ainoflow-guard": { "url": "https://mcp.ainoflow.io/mcp/v1/guard", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Cursor IDE [#cursor-ide] ```json { "mcpServers": { "ainoflow-guard": { "url": "https://mcp.ainoflow.io/mcp/v1/guard", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Start here: `guard_guide` [#start-here-guard_guide] Call `guard_guide` before anything else. It takes no arguments and returns the contract of this surface as data: every tool with its input schema, any response-shape exception, the failure codes below, and the capabilities of this area that no tool reaches. The guide's `limits` array reports the allowance Guard enforces on you: | `name` | `unit` | What it bounds | | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `guardChecksMonthly` | `checks` | `guard_check` calls in this scope per calendar month, counted across every key. A check past it answers `RATE_LIMITED` with `retryable: true`; the allowance frees when the month turns over. `0` means unlimited. | The number is resolved for your key from the same reader the check is measured against, so it is the budget actually in force rather than a published default. A token with no resolvable scope has no allowance to report and the array comes back empty. Do not confuse it with the policies you define: a policy is the budget *you* set per key, `guardChecksMonthly` is the budget your scope has for checking them. Guard's **deployment bounds** — the maximum key length, the largest `rateMax`, `rateWindow` and `slotTtl` it accepts, and the default slot lease — are a third thing, read from this deployment's configuration and published in the tool input schemas that `guard_guide` and `tools/list` return. Read them there rather than from the numbers on this page, which are the values this deployment runs today. ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "guard_guide", "arguments": {} } } ``` `initialize` also returns an `instructions` string naming it, so a client learns the guide exists without reading anything. **This page and the guide describe the same surface — the guide is the copy that cannot go stale.** Its tool inventory is generated from the same definitions `tools/list` returns, so a tool cannot exist without appearing in it. Limits it reports are resolved for your key, not the published default. Where this page and the guide disagree, the guide is right and this page is a bug. Tools [#tools] guard_check [#guard_check] Check every policy on the key and increment the execution counter. On first call with `rateMax`/`rateWindow` a rate-limit policy is auto-created; on first call with `concurrencyMax` a concurrency policy is auto-created. Subsequent calls use the stored policies automatically. Every check returns a `verdict`: `allow`, `deny` (a rate limit is exhausted) or `queued` (the concurrency cap is full). A refusal is a normal tool result (`isError: false`) carrying `allowed: false` and a `retryAfter` hint -- not a protocol error -- and it costs nothing: a refused check spends no execution and holds no slot, so retrying is safe. An `allow` that granted a concurrency slot returns `concurrency.slotToken`; hand it back with `guard_release_slot`. **Parameters:** | Name | Type | Required | Description | | ---------------------- | ------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `key` | string | Yes | Guard key identifier (e.g., "send-emails", "api-call-user-123"). Maximum length is deployment-configured — **512 characters** on this deployment, and the tool schema states the number in force. A longer key answers `-32602`. | | `rateMax` | integer | Conditional | Maximum executions allowed in window (1--1,000,000). Required on the first call that uses rate limiting, optional after the policy exists. Must be provided together with `rateWindow`. A value that is not a whole number is **refused**, not ignored. | | `rateWindow` | integer | Conditional | Window size in seconds (1--86,400). Required on the first call that uses rate limiting, optional after the policy exists. Must be provided together with `rateMax`. A value that is not a whole number is **refused**, not ignored. | | `concurrencyMax` | integer | Conditional | Maximum slots that may be held at once (1--1,000,000). Required on the first call that uses concurrency, optional after the policy exists. A value that is not a whole number is **refused**, not dropped — dropping it would run the check with no cap in force. | | `slotTtl` | integer | No | Slot lease TTL in seconds (1--86,400). `1800` applies when the argument is **absent, and only when it is absent**; a value that is not a whole number is refused, never replaced by the default. A crashed holder's slot frees itself when the lease expires, so the cap never wedges. | | `failOpen` | boolean | No | Posture when Guard cannot reach its store: `true` allows the execution, `false` refuses it. `false` applies when the argument is **absent, and only when it is absent** — a value that is not a boolean is refused, never read as `false`. | | `allowPolicyOverwrite` | boolean | No | If true, update the stored policy when the supplied parameters differ from the stored ones instead of refusing the check with `CONFLICT`. `false` applies when the argument is **absent, and only when it is absent**; a value that is not a boolean is refused, never read as `false`. | `concurrencyMax` is the parameter that creates a concurrency policy -- `slotTtl` alone never does. `rateWindow: 0` is rejected; it is not a way to request one either. **A wrong-typed argument is refused with `-32602`, never dropped.** This holds for every argument of every Guard tool — `key`, `slotToken`, the four integers and the two flags — and a default applies only when its argument is *absent*, never as a stand-in for a value that was sent and could not be read. `{"concurrencyMax": "4"}` used to be discarded and the check then ran with no concurrency cap at all, while the caller had every reason to believe one was in force; `{"failOpen": "true"}` used to revert the check to fail-closed, swapping the posture you chose for its opposite at the one moment it decides anything. **`guard_check` has three refusals, not one.** Each arrives as a tool result with `isError: true` and a machine-readable `code`, never as a JSON-RPC error — a caller that branched only on `CONFLICT` fell through to a default arm for the other two. * **`SCHEMA_VALIDATION_FAILED`** — the key has no policy yet and the call carries nothing to create one. Supply `rateMax` with `rateWindow`, or `concurrencyMax`. * **`CONFLICT`** — the parameters differ from the stored policy and `allowPolicyOverwrite` is not set. * **`RATE_LIMITED`** — the scope's monthly check allowance is spent. `retryable: true`, and the only one of the three a bare retry can ever clear. ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "SCHEMA_VALIDATION_FAILED: No policy exists for key 'invoice-run'. Supply rateMax with rateWindow, or concurrencyMax, to create one (invoice-run)" }], "structuredContent": { "code": "SCHEMA_VALIDATION_FAILED", "message": "No policy exists for key 'invoice-run'. Supply rateMax with rateWindow, or concurrencyMax, to create one", "retryable": false, "key": "invoice-run" }, "isError": true } } ``` **Parameters that differ from the stored policy are refused with `CONFLICT`.** Passing `rateMax`/`rateWindow` or `concurrencyMax`/`slotTtl` that do not match the policy already on the key answers a tool result with `isError: true`, `code: "CONFLICT"` and `retryable: false` — **not** JSON-RPC `-32603`, which used to tell every caller that retrying the identical call was reasonable when it is the one thing that can never work. Change the parameters, use a different key, pass `allowPolicyOverwrite: true`, or retune the policy over REST. ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "CONFLICT: Policy for key 'send-emails' already configured (max=100, window=3600s). Use a different key, update the policy via PUT /api/v1/guard/policies/{id}, or pass allowPolicyOverwrite=true to update inline (send-emails)" }], "structuredContent": { "code": "CONFLICT", "message": "Policy for key 'send-emails' already configured (max=100, window=3600s). Use a different key, update the policy via PUT /api/v1/guard/policies/{id}, or pass allowPolicyOverwrite=true to update inline", "retryable": false, "key": "send-emails" }, "isError": true } } ``` **An exhausted monthly check allowance is `RATE_LIMITED`, and it is the one refusal worth retrying.** `guard_check` counts against the scope's monthly Guard check limit; once that is spent, the call answers a tool result with `isError: true`, `code: "RATE_LIMITED"` and `retryable: true`. Do not confuse it with a `deny` verdict: a `deny` is a **successful** call reporting that your own rate-limit policy is exhausted, while `RATE_LIMITED` means the check itself was not performed — no policy was consulted and no counter moved. **Over REST these two collide; here they do not.** `POST /api/v1/guard/{key}/counter` answers `429` for both, and a caller has to read the response's media type to tell a verdict body from a problem document. On this surface the envelope already separates them: the allowance refusal is a tool error (`isError: true` with a `code`), and a `deny` or `queued` is a normal tool result carrying the verdict. Branch on `isError`, then on `verdict`. **Code Samples:** The second sample in each tab acquires a concurrency slot instead: cap parallel runs at 5, with a 10-minute lease. ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "guard_check", "arguments": { "key": "send-emails", "rateMax": 100, "rateWindow": 3600 } } } ``` ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "guard_check", "arguments": { "key": "report-workers", "concurrencyMax": 5, "slotTtl": 600 } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/guard" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "guard_check", "arguments": { "key": "send-emails", "rateMax": 100, "rateWindow": 3600 } } }' # Concurrency - cap parallel runs at 5 with a 10-minute lease curl -X POST "https://mcp.ainoflow.io/mcp/v1/guard" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "guard_check", "arguments": { "key": "report-workers", "concurrencyMax": 5, "slotTtl": 600 } } }' ``` **Response (allowed):** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "key": "send-emails", "allowed": true, "verdict": "allow", "remaining": 58, "resetsIn": 2145, "retryAfter": null, "rateLimit": { "current": 42, "max": 100, "window": 3600 }, "concurrency": null }, "isError": false } } ``` **Response (allowed -- concurrency slot granted):** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "key": "report-workers", "allowed": true, "verdict": "allow", "remaining": 0, "resetsIn": 0, "retryAfter": null, "rateLimit": null, "concurrency": { "active": 3, "max": 5, "slotToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "expiresIn": 600 } }, "isError": false } } ``` Keep `concurrency.slotToken` -- `guard_release_slot` needs it. `concurrency.expiresIn` is how long the lease lasts: after that many seconds the slot returns to the pool by itself, so an agent that dies mid-task never shrinks the pool. A concurrency-only key reports no rate limit at all (`"rateLimit": null`, with `remaining`/`resetsIn` at `0`). **Response (queued -- concurrency cap full):** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "key": "report-workers", "allowed": false, "verdict": "queued", "remaining": 0, "resetsIn": 17, "retryAfter": 17, "rateLimit": null, "concurrency": { "active": 5, "max": 5, "slotToken": null, "expiresIn": null } }, "isError": false } } ``` No token is issued on a refusal, so there is nothing to release -- wait `retryAfter` seconds and check again. On a key that also carries a rate limit, a full pool short-circuits before that limit's counter is touched, so an agent waiting for capacity spends none of its execution budget. It does not hide an exhausted rate limit, though: the limits that never got their turn are probed read-only, and if the rate limit is also spent the answer is `deny` carrying *its* `retryAfter`, because a deny outranks a queue. So `queued` means the pool is full and the rate limit still has room. **Response (denied):** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "key": "send-emails", "allowed": false, "verdict": "deny", "remaining": 0, "resetsIn": 855, "retryAfter": 855, "rateLimit": { "current": 100, "max": 100, "window": 3600 }, "concurrency": null }, "isError": false } } ``` *** guard_release_slot [#guard_release_slot] Release a concurrency slot granted by `guard_check`, freeing it for the next caller. Call it as soon as the protected work finishes, success or failure. Releasing is not required for safety -- a lease expires on its own, so a crashed holder never parks a slot forever -- but releasing returns the slot immediately instead of at the end of `slotTtl`, which is what keeps a pool of short tasks running at full width. **Parameters:** | Name | Type | Required | Description | | ----------- | ------ | -------- | ------------------------------------------------------ | | `key` | string | Yes | Guard key identifier -- the key the slot was leased on | | `slotToken` | string | Yes | Slot token received from `guard_check` | **Code Samples:** ```json { "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "guard_release_slot", "arguments": { "key": "report-workers", "slotToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/guard" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "guard_release_slot", "arguments": { "key": "report-workers", "slotToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" } } }' ``` **Response:** `active` is how many slots are still held after this release. ```json { "jsonrpc": "2.0", "id": 5, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "key": "report-workers", "success": true, "active": 2 }, "isError": false } } ``` **Response (the lease is gone -- already expired, already released, or a token that never held a slot on this key):** ```json { "jsonrpc": "2.0", "id": 5, "result": { "content": [{ "type": "text", "text": "NOT_FOUND: Slot not found or lease expired (report-workers)" }], "structuredContent": { "code": "NOT_FOUND", "message": "Slot not found or lease expired", "retryable": false, "key": "report-workers" }, "isError": true } } ``` This is not a failure to recover from: the slot is free either way. Treat `NOT_FOUND` here as "already released". *** guard_status [#guard_status] Check current status without incrementing a counter or leasing a slot. Uses stored policies -- no additional parameters needed. A key may carry both policy types; each block is `null` when its policy is not attached to the key. A key with no policy at all answers `NOT_FOUND`. **Parameters:** | Name | Type | Required | Description | | ----- | ------ | -------- | -------------------- | | `key` | string | Yes | Guard key identifier | **Code Samples:** ```json { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "guard_status", "arguments": { "key": "send-emails" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/guard" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "guard_status", "arguments": { "key": "send-emails" } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "key": "send-emails", "exists": true, "rateLimit": { "current": 42, "max": 100, "window": 3600, "resetsIn": 2145 }, "concurrency": null }, "isError": false } } ``` **Response (key with both policies):** ```json { "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "key": "invoice-api", "exists": true, "rateLimit": { "current": 42, "max": 100, "window": 3600, "resetsIn": 2145 }, "concurrency": { "active": 3, "max": 5, "resetsIn": 412 } }, "isError": false } } ``` The concurrency block reports `active` slots out of `max`, and `resetsIn` -- the seconds until the earliest held lease frees itself (`0` when no slots are held). *** guard_reset [#guard_reset] Reset the runtime state of a key: rate-limit counters go back to zero and every held concurrency slot is freed at once. Policies remain intact -- deleting one is REST-only. A key with no policy answers `NOT_FOUND`. For debugging purposes only. Freeing every slot does not stop the workers holding them, so a reset on a busy concurrency key can briefly admit more than `max` parallel executions. Prefer resetting idle keys. **Parameters:** | Name | Type | Required | Description | | ----- | ------ | -------- | -------------------- | | `key` | string | Yes | Guard key identifier | **Code Samples:** ```json { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "guard_reset", "arguments": { "key": "send-emails" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/guard" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "guard_reset", "arguments": { "key": "send-emails" } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "key": "send-emails", "success": true }, "isError": false } } ``` Resources [#resources] guard://{key} [#guardkey] Read-only resource for checking the status of every policy on a key -- rate limit and concurrency. Uses stored policies -- no additional parameters needed. * **MIME Type:** `application/json` * **URI Pattern:** `guard://key` where key is the guard identifier **Example:** ```json { "jsonrpc": "2.0", "id": 4, "method": "resources/read", "params": { "uri": "guard://send-emails" } } ``` **Response:** ```json { "jsonrpc": "2.0", "id": 4, "result": { "contents": [ { "uri": "guard://send-emails", "mimeType": "application/json", "text": "{\"key\":\"send-emails\",\"exists\":true,\"rateLimit\":{\"current\":42,\"max\":100,\"window\":3600,\"resetsIn\":2145},\"concurrency\":null}" } ] } } ``` **How a resource read fails** | Situation | Answer | | -------------------------------------------------------------- | -------------------------------------------------------------- | | `params` carries no `uri`, or a `uri` that is not a string | `-32602` (`Missing 'uri' parameter` / `Invalid params format`) | | A key longer than the configured maximum, or otherwise invalid | `-32602` | | A URI that is not `guard://…`, or a key with no policy | `-32001` | | The backing store failed | `-32603` | A key with no policy is a URI that resolves to nothing, so it answers `-32001` rather than a body you have to read `exists` out of. Use `guard_status` when you want the answer as a tool result instead. Beyond the tools [#beyond-the-tools] The tool set is deliberately smaller than the Guard REST surface, so a client is not handed a menu it has to scroll. These capabilities exist but no tool reaches them — reach them over REST on `https://api.ainoflow.io`, with the same API key. | Capability | REST | What it gives you | | ---------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | List all policies | `GET /api/v1/guard/policies` | Every policy in the project, to audit what `guard_check` has created implicitly. Paginated like every other list on this API: page or offset with limit (0-5000, default 100), and `limit=0` answers the count alone. A value outside those ranges is refused with 400 — this list used to accept and ignore them, and it used to return every policy rather than the first page. | | Policies for one key | `GET /api/v1/guard/{key}/policies` | The rate and concurrency policies attached to a single key, with their current parameters. | | Read a policy | `GET /api/v1/guard/policies/{id}` | One policy by id. | | Create a policy explicitly | `POST /api/v1/guard/policies` | Define a policy up front instead of letting the first `guard_check` create it. | | Retune a policy | `PUT /api/v1/guard/policies/{id}` | Change a limit without passing `allowPolicyOverwrite` on every subsequent check. | | Delete a policy | `DELETE /api/v1/guard/policies/{id}` | Retire a policy. `guard_reset` only clears counters; it leaves the policy in place. | | Delete every policy on a key | `DELETE /api/v1/guard/{key}/policies` | Retire the whole key at once. | `guard_guide` returns this same list as data, each entry with its method, path and description. Failures [#failures] Two different things can go wrong, and they arrive differently. A refusal [#a-refusal] Your call reached the handler and was declined. It answers `200` with a tool result carrying `isError: true`, and `structuredContent` is machine-readable: ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "NOT_FOUND: No policies found for key 'invoice-run' (invoice-run)" }], "structuredContent": { "code": "NOT_FOUND", "message": "No policies found for key 'invoice-run'", "retryable": false, "key": "invoice-run" }, "isError": true } } ``` | `code` | When | Extra fields | | -------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `SCHEMA_VALIDATION_FAILED` | `guard_check` on a key with no policy and no parameters to create one | `invalidPath` only when a refusal has a position to point at — Guard takes no document, so in practice it never does | | `CONFLICT` | `guard_check` passed policy parameters that differ from the stored policy without `allowPolicyOverwrite` | — | | `NOT_FOUND` | `guard_status`, `guard_reset` on a key with no policy, or `guard_release_slot` on a lease that is already gone | — | | `RATE_LIMITED` | The scope's monthly `guard_check` allowance is exhausted | — | **These four are Guard's whole set — it cannot send the other two.** Each Ainoflow MCP surface declares only the codes it can actually produce, and `guard_guide` returns Guard's set as `errors.codes`. Guard accepts no payload, so nothing can be `ITEM_TOO_LARGE`; it holds no ETag, so no `PRECONDITION_FAILED` can arrive. The page used to print all six, which is an upper bound wearing the words of a description. `retryable` is `true` only for `RATE_LIMITED`; every other code needs a changed request — different policy parameters, `allowPolicyOverwrite`, or a different key. A protocol or server error [#a-protocol-or-server-error] Your call never ran, or the service failed. It arrives as a JSON-RPC error: | Code | Meaning | | -------- | --------------------------------------------------------------------------------------------- | | `-32600` | Not a valid JSON-RPC 2.0 request, or a `tools/call` without an `id` | | `-32601` | Unknown method, unknown tool, or a capability this surface does not offer | | `-32602` | An argument is absent, blank, or of the wrong JSON type — your call never reached the handler | | `-32603` | The server itself failed. Not your request; retrying it unchanged is reasonable | | `-32001` | Unauthorized, or a resource URI that resolves to nothing | **Branch on `code`, never on the message text.** Messages are written for humans and may be reworded; `code` is part of the contract. **This changed.** A refusal used to come back as JSON-RPC `-32603` — the same code as a crashed server — so "these parameters differ from your stored policy" and "we fell over" were indistinguishable to a client, and the advice attached to `-32603` ("retrying it unchanged is reasonable") was exactly wrong for a refusal only a changed call can clear. Business failures now answer `200` with `isError: true` and a code; `-32603` means the server, and only the server. A wrong argument type is still `-32602`, unchanged. # Inbox (/docs/mcp/inbox) The Inbox MCP API provides AI assistants with email inbox access capabilities. Agents can list email messages, mark them as handled, download attachments, and delete processed messages using the MCP protocol over HTTP transport with JSON-RPC 2.0. **Entry contract** ```http POST https://mcp.ainoflow.io/mcp/v1/inbox Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` The MCP host is **`mcp.ainoflow.io`**. It is not `api.ainoflow.io` — that host serves the REST API and does not route `/mcp/v1/*`, so an MCP client pointed at it is rejected before it reaches this surface. Both headers are required on every request. Connection [#connection] Claude Desktop [#claude-desktop] Add to your Claude Desktop configuration: ```json { "mcpServers": { "ainoflow-inbox": { "url": "https://mcp.ainoflow.io/mcp/v1/inbox", "transport": "http", "description": "Email receiving and processing for automation", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Cursor IDE [#cursor-ide] Add to your Cursor MCP configuration. Cursor reads the same shape as Claude Desktop: a top-level `mcpServers` object, with the API key in an `Authorization` header. ```json { "mcpServers": { "ainoflow-inbox": { "url": "https://mcp.ainoflow.io/mcp/v1/inbox", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Start here: `inbox_guide` [#start-here-inbox_guide] Call `inbox_guide` before anything else. It takes no arguments and returns the contract of this surface as data: every tool with its input schema, any response-shape exception, the one failure code below, and the capabilities of this area that no tool reaches. The guide's `limits` array reports the two allowances this area enforces: | `name` | `unit` | What it bounds | | ---------------------- | ---------- | ------------------------------------------------------------------------------------------------------ | | `inboxMessagesMonthly` | `messages` | Messages received into this scope per calendar month, across every inbox. `0` or less means unlimited. | | `inboxCount` | `inboxes` | Inboxes this scope may hold at once. `0` or less means unlimited. | Both are resolved for your key from the readers that enforce them, not published defaults; a token with no resolvable scope has none to report and the array comes back empty. **Both limits are charged outside this surface, which is why it still reports only one failure code.** Messages are counted where mail is ingested — once the allowance is spent, further mail is simply not accepted and `inbox_messages` stops showing anything new — and the inbox count is refused at `POST /api/v1/inbox`, which is REST-only. **No tool here ever answers `RATE_LIMITED`.** They are published anyway because the alternative is what used to happen: an agent whose inbox stopped receiving mail found nothing on this surface to explain why, while every tool kept answering successfully with an unchanging list. ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "inbox_guide", "arguments": {} } } ``` `initialize` also returns an `instructions` string naming it, so a client learns the guide exists without reading anything. **This page and the guide describe the same surface — the guide is the copy that cannot go stale.** Its tool inventory is generated from the same definitions `tools/list` returns, so a tool cannot exist without appearing in it. Limits it reports are resolved for your key, not the published default. Where this page and the guide disagree, the guide is right and this page is a bug. Tools [#tools] inbox_messages [#inbox_messages] List the messages of an inbox, addressed by its hook email address. Each message carries its **full text body** and its attachment metadata, so a page of messages is a page of content, not of stubs -- keep `limit` small. **Parameters:** | Name | Type | Required | Description | | ---------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `email` | string | Yes | Inbox hook email address (e.g., `habcd1234-x7z8wq3m5n2kabcd@ainobox.com`) | | `isHandled` | boolean | No | Filter: `true` = handled only, `false` = unhandled only, omit = all. A value that is not a boolean is **refused**, not ignored. | | `fromAddress` | string | No | Filter by sender email (case-insensitive, partial match). The page is unfiltered by sender when this argument is **absent, and only when it is absent**; a value that is not a string is **refused**, not ignored. | | `receivedAfter` | string | No | Filter messages received after this date (ISO 8601). No lower bound applies when this argument is **absent, and only when it is absent**; a value that is not a string, or a string that is not an ISO 8601 date-time, is **refused**, not ignored. | | `receivedBefore` | string | No | Filter messages received before this date (ISO 8601). No upper bound applies when this argument is **absent, and only when it is absent**; a value that is not a string, or a string that is not an ISO 8601 date-time, is **refused**, not ignored. | | `sortBy` | string | No | Sort field: `ReceivedAt` (default), `FromAddress`, `Subject`, `SizeBytes`. A value outside this list, or of any other JSON type, is **refused**, not ignored. | | `sortOrder` | string | No | Sort direction: `Asc`, `Desc` (default). A value outside this list, or of any other JSON type, is **refused**, not ignored. | | `page` | integer | No | Page number (1-based, minimum `1`). Cannot be used together with `offset`. A value below `1` is **refused**, not raised to `1`; a value that is not a whole number is **refused**, not ignored. | | `offset` | integer | No | Number of messages to skip (minimum `0`). Cannot be used together with `page`. A negative value is **refused**, not raised to `0`; a value that is not a whole number is **refused**, not ignored. | | `limit` | integer | No | Items per page, `0`-`5000` (default: 50). A value outside that range is **refused**, not clamped; a value that is not a whole number is **refused**, not ignored. `0` is valid — see below. | **An unrecognised `sortBy` or `sortOrder` is refused with `-32602`, not silently ignored.** Ignoring it handed back the default order under the caller's own sort name, so a typo read as a sorted page. `{"sortBy": "received"}` now returns `-32602` with a message naming the supported values. `page` only reaches offsets that are multiples of `limit`. Use `offset` to resume a scan at an arbitrary position, or to change the batch size between calls. Supplying both returns `-32602` ("Cannot specify both 'page' and 'offset' parameters"). **A pagination value outside its range is refused with `-32602`, not adjusted.** `{"limit": 10000}` is not quietly served as 5000, so a page that comes back short is short because the data ran out. **`limit: 0` is the exception, because it is a capability rather than a mistake:** it answers an empty `items` array carrying the real `totalCount` — how many are there, without the rows. On this tool that is the cheapest call it has, since every message it returns carries its full body text. **Count without the rows:** ```json { "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": { "name": "inbox_messages", "arguments": { "email": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "isHandled": false, "limit": 0 } } } ``` ```json { "jsonrpc": "2.0", "id": 6, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "items": [], "totalCount": 7, "page": 1, "limit": 0, "totalPages": 0 }, "isError": false } } ``` **Every argument here is refused with `-32602` when it carries the wrong JSON type**, so a mistyped filter can never masquerade as an unfiltered page. `page`, `offset` and `limit` are read as integers: `{"page": "2"}` and `{"limit": 1.5}` are both refused rather than dropped. `isHandled` must be a boolean — `{"isHandled": "false"}` answers `Argument 'isHandled' must be a boolean`, not an unfiltered listing. The three remaining filters behave the same way: `{"fromAddress": 42}`, `{"receivedAfter": 1741000000}` and a non-string `receivedBefore` each answer `Argument '' must be a string`. They used to be dropped, which handed the caller a full page under its own filter — a listing that reads as filtered and is not. A default applies only when its argument is *absent*; JSON `null` counts as absent. **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "inbox_messages", "arguments": { "email": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "isHandled": false, "limit": 10 } } } ``` **Example response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "items": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "fromAddress": "sender@example.com", "toAddress": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "subject": "Invoice #1234", "bodyText": "Please find the attached invoice...", "sizeBytes": 245760, "receivedAt": "2026-03-06T10:30:00Z", "isHandled": false, "hasAttachments": true, "attachmentCount": 1, "attachments": [ { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "fileName": "invoice.pdf", "sizeBytes": 102400, "contentType": "application/pdf", "downloadUrl": "https://storage.example.com/...?signature=...", "downloadUrlExpiresAt": "2026-03-06T11:30:00Z" } ] } ], "totalCount": 42, "page": 1, "limit": 10, "totalPages": 5 }, "isError": false } } ``` *** inbox_attachment_url [#inbox_attachment_url] Get a pre-signed download URL for an email attachment, plus its `fileName`, `contentType` and `sizeBytes`. The URL is valid for 1 hour. This is how attachment bytes leave the surface -- no tool returns them. Answers `NOT_FOUND` for an attachment outside your scope. **Parameters:** | Name | Type | Required | Description | | -------------- | ------------- | -------- | ------------------------------------------------------ | | `attachmentId` | string (uuid) | Yes | Attachment identifier (from `inbox_messages` response) | **Example request:** ```json { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "inbox_attachment_url", "arguments": { "attachmentId": "b2c3d4e5-f6a7-8901-bcde-f12345678901" } } } ``` **Example response:** ```json { "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "attachmentId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "fileName": "invoice.pdf", "contentType": "application/pdf", "sizeBytes": 102400, "downloadUrl": "https://storage.example.com/...?signature=...", "downloadUrlExpiresAt": "2026-03-06T11:30:00Z" }, "isError": false } } ``` *** inbox_handle_message [#inbox_handle_message] Mark an email message as handled and return its full content. Idempotent -- calling on an already-handled message returns it successfully with `200 OK`. **Parameters:** | Name | Type | Required | Description | | ----------- | ------------- | -------- | --------------------------------------------------- | | `messageId` | string (uuid) | Yes | Message identifier (from `inbox_messages` response) | **Example request:** ```json { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "inbox_handle_message", "arguments": { "messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } } ``` **Example response:** ```json { "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "fromAddress": "sender@example.com", "toAddress": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "subject": "Invoice #1234", "bodyText": "Please find the attached invoice...", "bodyHtml": "...", "isHandled": true, "handledAt": "2026-03-06T11:00:00Z", "hasAttachments": true, "attachmentCount": 1, "attachments": [ { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "fileName": "invoice.pdf", "sizeBytes": 102400, "contentType": "application/pdf", "downloadUrl": "https://storage.example.com/...?signature=...", "downloadUrlExpiresAt": "2026-03-06T12:00:00Z" } ] }, "isError": false } } ``` *** inbox_unhandle_message [#inbox_unhandle_message] Revert a message to unhandled (clears the handled flag) and return its full content. Useful for re-queuing a message for reprocessing. **Parameters:** | Name | Type | Required | Description | | ----------- | ------------- | -------- | --------------------------------------------------- | | `messageId` | string (uuid) | Yes | Message identifier (from `inbox_messages` response) | **Example request:** ```json { "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "inbox_unhandle_message", "arguments": { "messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } } ``` **Example response:** ```json { "jsonrpc": "2.0", "id": 5, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "fromAddress": "sender@example.com", "toAddress": "habcd1234-x7z8wq3m5n2kabcd@ainobox.com", "subject": "Invoice #1234", "bodyText": "Please find the attached invoice...", "bodyHtml": "...", "isHandled": false, "handledAt": null, "hasAttachments": true, "attachmentCount": 1, "attachments": [ { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "fileName": "invoice.pdf", "sizeBytes": 102400, "contentType": "application/pdf", "downloadUrl": "https://storage.example.com/...?signature=...", "downloadUrlExpiresAt": "2026-03-06T12:00:00Z" } ] }, "isError": false } } ``` *** inbox_delete_message [#inbox_delete_message] Delete an email message and its attachments permanently. **Parameters:** | Name | Type | Required | Description | | ----------- | ------------- | -------- | --------------------------------------------------- | | `messageId` | string (uuid) | Yes | Message identifier (from `inbox_messages` response) | **Example request:** ```json { "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "inbox_delete_message", "arguments": { "messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } } ``` **Example response:** ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [{ "type": "text", "text": "{...}" }], "structuredContent": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }, "isError": false } } ``` Resources [#resources] inbox://list [#inboxlist] List all inboxes with name, email address, enabled flag, and message counts. * **MIME Type:** `application/json` **Example request:** ```json { "jsonrpc": "2.0", "id": 5, "method": "resources/read", "params": { "uri": "inbox://list" } } ``` **Example response:** ```json { "jsonrpc": "2.0", "id": 5, "result": { "contents": [ { "uri": "inbox://list", "mimeType": "application/json", "text": "[{\"name\":\"Support Inbox\",\"email\":\"habcd1234-x7z8wq3m5n2kabcd@ainobox.com\",\"enabled\":true,\"messageCount\":42,\"unhandledCount\":5}]" } ] } } ``` **How a resource read fails** `inbox://list` is the only URI this surface serves. | Situation | Answer | | -------------------------------------------------------------------------- | -------- | | `params` carries no `uri`, a `uri` that is not a string, or will not parse | `-32602` | | Any URI other than `inbox://list` | `-32001` | | The backing store failed | `-32603` | Beyond the tools [#beyond-the-tools] The tool set is deliberately smaller than the Inbox REST surface, so a client is not handed a menu it has to scroll. These capabilities exist but no tool reaches them — reach them over REST on `https://api.ainoflow.io`, with the same API key. | Capability | REST | What it gives you | | ---------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Create an inbox | `POST /api/v1/inbox` | Provision an inbox and its hook email address. That address is what every tool here takes as its `email` argument. | | List inboxes | `GET /api/v1/inbox` | Every inbox with its hook address, status and message counts. Also available as the `inbox://list` resource. | | Read an inbox | `GET /api/v1/inbox/{id}` | One inbox by id, including its webhook configuration and sender allow-list. | | Update an inbox | `PUT /api/v1/inbox/{id}` | Change its name, enabled state, webhook URL or allowed senders. | | Delete an inbox | `DELETE /api/v1/inbox/{id}` | Remove the inbox and everything it received. | | Read one message | `GET /api/v1/inbox/{id}/messages/{messageId}` | A single message by id, without listing the inbox first. | | Handle every message at once | `POST /api/v1/inbox/{id}/messages/handle` | Mark the whole inbox handled in one call, rather than one `inbox_handle_message` per message. | | Download an attachment | `GET /api/v1/inbox/{id}/messages/{messageId}/attachments/{attachmentId}` | Answers `302 Found` with a `Location` header pointing at a short-lived pre-signed URL — it does not stream the bytes through the API. It suits a browser, or an HTTP client set to follow redirects (`curl -L`); a client that does not follow them sees the `302` and no bytes. Both this and `inbox_attachment_url` end at the same kind of pre-signed URL, so the difference is redirect versus JSON body — prefer the tool, which hands the URL back as data an agent can pass on. | | Intake by IMAP | `GET /api/v1/inbox/{inboxId}/imap` | Pull mail from an existing mailbox instead of routing it to the hook address. The collection is CRUD (`GET`, `POST`, `PUT /{id}`, `DELETE /{id}`) and `POST /api/v1/inbox/{inboxId}/imap/fetch` triggers a fetch now. None of it is reachable over MCP. | `inbox_guide` returns this same list as data, each entry with its method, path and description. Failures [#failures] Two different things can go wrong, and they arrive differently. A refusal [#a-refusal] Your call reached the handler and was declined. It answers `200` with a tool result carrying `isError: true`, and `structuredContent` is machine-readable: ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "NOT_FOUND: Message not found" }], "structuredContent": { "code": "NOT_FOUND", "message": "Message not found", "retryable": false }, "isError": true } } ``` | `code` | When | Extra fields | | ----------- | ------------------------------------------------------------------- | ------------ | | `NOT_FOUND` | The inbox, message or attachment is not there, or not in your scope | — | **`NOT_FOUND` is the only code this surface produces.** Each Ainoflow MCP surface declares only the codes it can actually produce, and `inbox_guide` returns Inbox's set as `errors.codes` — a list of one. These tools read and triage what has already arrived: they accept no payload to be too large or misshapen, hold no ETag to fail a precondition on, and write nothing that can collide. The page used to print all six codes, which is an upper bound wearing the words of a description. **Nothing on this surface is retryable.** `RATE_LIMITED` — the one code that ever sets `retryable: true` — is not produced here, so every refusal you see needs a changed request: a different address, or a message that is actually in your scope. Everything else that can go wrong is a malformed argument (`-32602`) or a server fault (`-32603`). That holds even though `inbox_guide` publishes `inboxMessagesMonthly` and `inboxCount`: an area publishes the limits it enforces, and a surface declares the codes its own tools can return. Both of these are charged elsewhere — messages at ingestion, inboxes at `POST /api/v1/inbox` — so neither ever turns into a refusal you receive from a tool here. A protocol or server error [#a-protocol-or-server-error] Your call never ran, or the service failed. It arrives as a JSON-RPC error: | Code | Meaning | | -------- | --------------------------------------------------------------------------------------------- | | `-32600` | Not a valid JSON-RPC 2.0 request, or a `tools/call` without an `id` | | `-32601` | Unknown method, unknown tool, or a capability this surface does not offer | | `-32602` | An argument is absent, blank, or of the wrong JSON type — your call never reached the handler | | `-32603` | The server itself failed. Not your request; retrying it unchanged is reasonable | | `-32001` | Unauthorized, or a resource URI that resolves to nothing | **Branch on `code`, never on the message text.** Messages are written for humans and may be reworded; `code` is part of the contract. **This changed.** A refusal used to come back as JSON-RPC `-32603` — the same code as a crashed server — so "that message is not here" and "we fell over" were indistinguishable to a client. Business failures now answer `200` with `isError: true` and a code; `-32603` means the server, and only the server. A wrong argument type is still `-32602`, unchanged. # MCP Protocol (/docs/mcp) Ainoflow implements the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) for seamless AI agent integration. Connection [#connection] Each service exposes an MCP endpoint: | Service | Endpoint | | ---------- | --------------------------------------------- | | Shield | `https://mcp.ainoflow.io/mcp/v1/shield` | | Guard | `https://mcp.ainoflow.io/mcp/v1/guard` | | Convert | `https://mcp.ainoflow.io/mcp/v1/convert` | | Data store | `https://mcp.ainoflow.io/mcp/v1/storage/json` | | Files | `https://mcp.ainoflow.io/mcp/v1/files` | | Memory | `https://mcp.ainoflow.io/mcp/v1/memory` | | Inbox | `https://mcp.ainoflow.io/mcp/v1/inbox` | Transport [#transport] Streamable HTTP with JSON-RPC 2.0 messages. Send POST requests with `Content-Type: application/json`. Get an API key [#get-an-api-key] Use a project API key as the Bearer token in the configuration examples below. **Existing account:** Sign in at [ainoflow.io](https://www.ainoflow.io), choose **Open App**, select the project, open **Keys**, and choose **Issue key**. **First-time account:** [Create an account](https://www.ainoflow.io/signup), then choose **Open App**. Programmatic first-tenant provisioning is documented on the [Account API](/docs/api/account#self-onboarding). After a project exists, additional keys are issued from **Keys → Issue key**. Store the key in your MCP host environment or a secret manager. Do **not** paste long-lived keys into an AI chat. Prefer an environment-variable reference in local config when your client supports it. Configuration [#configuration] Claude Desktop [#claude-desktop] ```json { "mcpServers": { "ainoflow-shield": { "url": "https://mcp.ainoflow.io/mcp/v1/shield", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Cursor IDE [#cursor-ide] ```json { "mcpServers": { "ainoflow-shield": { "url": "https://mcp.ainoflow.io/mcp/v1/shield", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` # Memory (/docs/mcp/memory) Markdown memory for AI agents over the Model Context Protocol. Write and read documents addressed by category and key, edit them in place, walk their `[[wiki-link]]` neighborhood, and recall them by text ranked with match strength, inbound references, and freshness. Eight tools plus `memory_guide`, and no resources: memory is a tools-only surface. **Entry contract** ```http POST https://mcp.ainoflow.io/mcp/v1/memory Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` The surface is mounted at two paths on the same host and taking the same headers: `/mcp/v1/memory` (category in the arguments) and `/mcp/v1/memory/{category}` (category in the route). The MCP host is **`mcp.ainoflow.io`**. It is not `api.ainoflow.io` — that host serves the REST API and does not route `/mcp/v1/*`, so an MCP client pointed at it is rejected before it reaches this surface. Both headers are required on every request, on both paths. Connection [#connection] Claude Desktop [#claude-desktop] ```json { "mcpServers": { "ainoflow-memory": { "url": "https://mcp.ainoflow.io/mcp/v1/memory", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Cursor IDE [#cursor-ide] ```json { "mcpServers": { "ainoflow-memory": { "url": "https://mcp.ainoflow.io/mcp/v1/memory", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Category-bound endpoint [#category-bound-endpoint] A second endpoint binds the category in the URL: `https://mcp.ainoflow.io/mcp/v1/memory/{category}`. The tool names stay the same, but the `category` argument disappears from every tool and the `category` field disappears from every response -- one server per namespace, with a smaller schema for the model to carry. ```json { "mcpServers": { "ainoflow-memory-main": { "url": "https://mcp.ainoflow.io/mcp/v1/memory/main", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` The parameter tables below describe the category-agnostic endpoint (`/mcp/v1/memory`), where `category` is required. On the category-bound endpoint, drop every `category` and `targetCategory` argument. **Conventions** * Every successful tool response carries the result as JSON text in `content[0].text`, the same object in `structuredContent`, and `isError: false`. `memory_read` is the exception: its text block is the raw markdown body, with the metadata in `structuredContent`. * Keys are path-like (`projects/apollo`), 1-1024 characters; a category is 1-100. Both bounds are published as `maxLength` in every tool schema that takes an address, and an over-long or blank one is refused with `SCHEMA_VALIDATION_FAILED` before anything is written. Documents are up to 100 KB. The default category is `main`. * Paging is the same on every tool that pages: `page` (1 or greater), `offset` (0 or greater), `limit` (**0-5000**). A value outside its range is refused with `-32602`, never clamped, and `limit: 0` answers an empty page carrying the real `totalCount`. * Failures come back as an MCP tool result with `isError: true` carrying a machine-readable `code` -- a stale precondition, a `str_replace` that did not match exactly once, a taken destination key, a write that lost a race with a concurrent writer. See [Failures](#failures) for the codes this surface declares. * Every tool that writes -- `memory_write`, `memory_edit`, `memory_move`, `memory_delete` -- can answer `CONFLICT` because another writer landed first, whether or not you sent a precondition. It is published as `retryable: false`; see [Failures](#failures). Start here: `memory_guide` [#start-here-memory_guide] Call `memory_guide` before anything else. It takes no arguments and returns the contract of this surface as data: every tool with its input schema, any response-shape exception, the failure codes below, **the limits in force for your key**, and the capabilities of this area that no tool reaches. ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_guide", "arguments": {} } } ``` `initialize` also returns an `instructions` string naming it, so a client learns the guide exists without reading anything. **This page and the guide describe the same surface — the guide is the copy that cannot go stale.** Its tool inventory is generated from the same definitions `tools/list` returns, so a tool cannot exist without appearing in it. Limits it reports are resolved for your key, not the published default. Where this page and the guide disagree, the guide is right and this page is a bug. **Limits the guide reports** | Field | Default | What it bounds | | ------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `memoryDocumentSizeBytes` | 100 KB | The largest markdown body `memory_write` or `memory_edit` accepts. Over it, `ITEM_TOO_LARGE` with `currentBytes` and `maximumBytes`. | | `memoryDocumentCount` | 10 000 | Live documents the scope may hold, summed across every project in it. A write that would add one past it answers `RATE_LIMITED`. Absent when your scope cannot be resolved, rather than reported as a default you are not subject to. | Both are overridable per scope, so read them from the guide rather than from this table; `GET /api/v1/capabilities` returns the same resolved values over REST. **`memoryDocumentCount` counts what you hold, not what you wrote this month.** It is evaluated live against the documents your **scope** currently holds at the moment of the write, summed across every project in it -- the same way the Data store and Files allowances are counted. Deleted and expired documents stop counting. Because it measures what is held rather than what was spent, it does not reset on a billing cycle: `memory_delete` frees room immediately, and until room is freed an unchanged retry answers `RATE_LIMITED` again however long you wait. **Only a call that adds a document is charged.** `memory_write` is charged only when it writes to a free address -- writing over a document that already exists replaces its body and is never refused by the allowance. `memory_edit`, `memory_move` and `memory_delete` are never charged and never answer `RATE_LIMITED`. Nothing is written when a call is refused. Over REST the same rule maps onto `429`: `POST /api/v1/memory/{category}/{key}` is charged whenever it creates (a create at a taken address answers `409` and is charged nothing), `PUT /api/v1/memory/{category}/{key}` is charged only on the branch that creates, and `POST /api/v1/memory/{category}/{key}/edit` declares no `429` at all. Tools [#tools] memory_write [#memory_write] Create or replace a markdown memory document at `key`. Content is stored verbatim; an existing document is overwritten and its previous body kept as a revision. Frontmatter is parsed out of the body at write time -- it is what `memory_search` filters on. **Parameters:** | Name | Type | Required | Description | | ----------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping of documents (e.g. `main`), 1-100 characters. A longer one is refused with `SCHEMA_VALIDATION_FAILED`. | | `key` | string | Yes | Document key, path-like (e.g. `projects/apollo`), 1-1024 characters. A longer one is refused with `SCHEMA_VALIDATION_FAILED` and writes nothing. | | `content` | string | Yes | Full markdown body. Over the size limit in force for your key it is refused with `ITEM_TOO_LARGE`, carrying `currentBytes` and `maximumBytes`. | | `ifMatch` | string | No | ETag precondition (`*` matches any existing document); the write fails with a precondition error if the current ETag differs. The write is unconditional when this argument is **absent, and only when it is absent** — a value that is not a string is **refused**, not dropped. | | `expectedVersion` | integer | No | Version precondition; the write fails with a precondition error if the current version differs. A value that is not a whole number is **refused**, not ignored. | | `expiresAt` | string | No | Absolute expiry (ISO 8601). Cannot be used with `expiresMs`. A value that is not an ISO 8601 date-time string is **refused**, not ignored, and so is one that is not in the future — an expiry already past returns `SCHEMA_VALIDATION_FAILED` and writes nothing. | | `expiresMs` | integer | No | TTL in milliseconds from now. Cannot be used with `expiresAt`. A value that is not a whole number is **refused**, not ignored. | `expiresAt` and `expiresMs` are mutually exclusive — supplying both returns `-32602` ("Cannot specify both expiresAt and expiresMs parameters") and stores nothing, the same rule the REST API applies. An `expiresAt` must be in the future — one that is already past is refused with `SCHEMA_VALIDATION_FAILED` and stores nothing. An `expiresMs` of `0` or less means **no expiration**. An expired document reads as absent everywhere and frees its key. **A wrong-typed `ifMatch` is refused with `-32602`, never dropped.** Read leniently, `{"ifMatch": 5}` was discarded, the command carried no ETag precondition at all, the conditional write ran as an unconditional one, and the call answered success — so the lost-update protection you asked for was gone and nothing in the response said so. The write is unconditional only when you send neither precondition. The REST twin refuses the same mistake: `PUT /api/v1/memory/{category}/{key}` answers `400` when `If-Match` is present but names no entity tag (`""`, `W/""`, a bare `W/`, or whitespace), where it used to answer `200` having quietly written unconditionally. The `edit` and `move` endpoints behave the same way. An **absent** `If-Match` still means an unconditional write. **A write with no precondition still cannot lose an update -- it answers `CONFLICT` instead.** Every write is applied against the exact version it was computed from, whether or not you sent `ifMatch` or `expectedVersion`, so a concurrent writer landing in between gets your write refused rather than applied over theirs. Nothing is written. That is `CONFLICT` and not `PRECONDITION_FAILED`, because the latter would name a guard you never set. The message tells you which of the two conflicts you met: *"...already exists"* means the address was taken by another writer, and *"The document was modified by another writer while this write was in flight..."* means it moved. `retryable` is `false` for both -- read the document again and write again from what you find. **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_write", "arguments": { "category": "main", "key": "projects/apollo", "content": "# Apollo\n\nPipeline migration owned by [[people/dana]].\n\n## Status\n\nPhase 2 of 3 complete.\n" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/memory" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_write", "arguments": { "category": "main", "key": "projects/apollo", "content": "# Apollo\n\nPhase 2 of 3 complete.\n" } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"category\":\"main\",\"key\":\"projects/apollo\",\"version\":2,\"etag\":\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6\",\"size\":148,\"expiresAt\":null}" } ], "isError": false } } ``` `expiresAt` is the document's absolute expiry, and `null` when it has none. *** memory_read [#memory_read] Read the raw markdown of a memory document by key. The markdown comes back as the text block, so an agent reads back exactly what was written; version and ETag arrive in `structuredContent`, ready to feed back as `ifMatch` on the next write. **Parameters:** | Name | Type | Required | Description | | ---------- | ------ | -------- | ----------------------------- | | `category` | string | Yes | Logical grouping of documents | | `key` | string | Yes | Document key, path-like | **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_read", "arguments": { "category": "main", "key": "projects/apollo" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/memory" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_read", "arguments": { "category": "main", "key": "projects/apollo" } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "# Apollo\n\nPipeline migration owned by [[people/dana]].\n\n## Status\n\nPhase 2 of 3 complete.\n" } ], "structuredContent": { "category": "main", "key": "projects/apollo", "version": 2, "etag": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "updatedAt": "2026-01-16T09:12:00Z" }, "isError": false } } ``` *** memory_edit [#memory_edit] Edit a memory document in place: `str_replace` (the old text must occur exactly once), `insert` at a line, or `append`. `str_replace` is compare-and-swap. If `oldText` is missing from the document, or occurs more than once, the edit is rejected and nothing is written -- narrow the anchor text and retry. **`CONFLICT` also means the edit lost a race.** The edit is applied against the version it was computed from even when you send no precondition, so a concurrent writer landing in between gets it refused rather than applied to a body it was not computed from. The message separates the two: an anchor refusal names the match count, a lost race says the document was modified by another writer. In that case re-read and re-apply -- repeating the identical call recomputes the same edit from the same stale read. **An edit is never charged against the live-document allowance, so it never answers `RATE_LIMITED`.** It only ever rewrites a document that already exists, so it cannot add one: a scope sitting exactly at `memoryDocumentCount` can still edit every document it holds. The REST twin, `POST /api/v1/memory/{category}/{key}/edit`, declares no `429` for the same reason -- that absence is the contract, not an omission. **Parameters:** | Name | Type | Required | Description | | ----------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping of documents | | `key` | string | Yes | Document key, path-like | | `operation` | string | Yes | `str_replace`, `insert`, or `append` | | `oldText` | string | No | Text to replace, must occur exactly once (`str_replace`) | | `newText` | string | No | Replacement, inserted, or appended text | | `line` | integer | No | 1-based line the text becomes (`insert`). A value that is not a whole number is **refused**, not ignored. | | `ifMatch` | string | No | ETag precondition (`*` matches any existing document); the edit fails with a precondition error if the current ETag differs. The edit is unconditional when this argument is **absent, and only when it is absent** — a value that is not a string is **refused**, not dropped. | | `expectedVersion` | integer | No | Version precondition; the edit fails with a precondition error if the current version differs. A value that is not a whole number is **refused**, not ignored. | **A wrong-typed precondition is refused with `-32602`, never dropped.** A dropped `ifMatch` turned a conditional edit into an unconditional one while reporting success. Over REST, `POST /api/v1/memory/{category}/{key}/edit` now answers `400` when `If-Match` is present but names no entity tag, where it used to answer `200` having quietly edited unconditionally. **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_edit", "arguments": { "category": "main", "key": "projects/apollo", "operation": "str_replace", "oldText": "Phase 2 of 3 complete.", "newText": "Phase 3 of 3 complete." } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/memory" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_edit", "arguments": { "category": "main", "key": "projects/apollo", "operation": "append", "newText": "\nShipped 2026-01-20.\n" } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"category\":\"main\",\"key\":\"projects/apollo\",\"version\":3,\"etag\":\"b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7\",\"size\":168}" } ], "isError": false } } ``` *** memory_move [#memory_move] Rename a memory document to `targetKey`, preserving its history and references. The document identity, its revisions, and its reference metrics survive the rename, and inbound links are re-resolved -- but no other document's `[[link]]` text is rewritten. **Parameters:** | Name | Type | Required | Description | | ----------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Current logical grouping of documents | | `key` | string | Yes | Current document key | | `targetKey` | string | Yes | New document key, 1-1024 characters. A longer one is refused with `SCHEMA_VALIDATION_FAILED` and nothing is renamed. | | `targetCategory` | string | No | New category, 1-100 characters. The current category is kept when this argument is **absent, and only when it is absent** — a value that is not a string is **refused**, not ignored, and a blank one is refused as the invalid address it is rather than falling back to the current category. | | `ifMatch` | string | No | ETag precondition (`*` matches any existing document); the rename fails with a precondition error if the current ETag differs. The rename is unconditional when this argument is **absent, and only when it is absent** — a value that is not a string is **refused**, not dropped. | | `expectedVersion` | integer | No | Version precondition; the rename fails with a precondition error if the current version differs. A value that is not a whole number is **refused**, not ignored. | **A wrong-typed precondition is refused with `-32602`, never dropped.** Over REST, `POST /api/v1/memory/{category}/{key}/move` now answers `400` when `If-Match` is present but names no entity tag, where it used to answer `200` having quietly renamed unconditionally. **`CONFLICT` here is two refusals.** A live document at the destination rejects the rename as a taken address (*"...already exists"*, naming the destination) -- choose another key. A source document that moved while the rename was in flight is a lost race (*"The document was modified by another writer while this move was in flight..."*) -- re-read it and rename again from what you find. Nothing is renamed either way, and neither is retryable as sent. **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_move", "arguments": { "category": "main", "key": "projects/apollo", "targetKey": "archive/apollo" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/memory" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_move", "arguments": { "category": "main", "key": "projects/apollo", "targetKey": "archive/apollo" } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"category\":\"main\",\"key\":\"archive/apollo\",\"sourceCategory\":\"main\",\"sourceKey\":\"projects/apollo\",\"version\":4,\"etag\":\"b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7\",\"size\":168}" } ], "isError": false } } ``` *** memory_delete [#memory_delete] Delete a memory document by key. The delete is soft: the document leaves reads, lists, and search, while its revision history stays readable. **A delete can lose a race too, and its `CONFLICT` message reads differently on purpose.** ``` The document was modified by another writer while this delete was in flight. Re-read the document - the address may now hold another writer's document, or nothing at all - and delete again only if that is still what you intend. ``` Every other write carries content computed from a version that has gone, so repeating it unchanged cannot succeed. A delete carries no payload and re-reads the address itself, so an unchanged retry **would** succeed -- which is exactly why it should not be issued blindly. What the race cost you is not the write; it is the knowledge of *what you are deleting*. Call `memory_read` first, then delete again only if that is still what you meant. A `CONFLICT` here also means the document is still there and still live -- that is what separates it from `NOT_FOUND`. **Parameters:** | Name | Type | Required | Description | | ---------- | ------ | -------- | ----------------------------- | | `category` | string | Yes | Logical grouping of documents | | `key` | string | Yes | Document key, path-like | **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_delete", "arguments": { "category": "main", "key": "projects/apollo" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/memory" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_delete", "arguments": { "category": "main", "key": "projects/apollo" } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"category\":\"main\",\"key\":\"projects/apollo\"}" } ], "isError": false } } ``` *** memory_list [#memory_list] List memory documents with metadata -- never bodies. For recent activity, pass `updatedAfter` with `sortBy=updatedAt`; for the most-linked documents, use `sortBy=weight`. **Parameters:** | Name | Type | Required | Description | | -------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping of documents | | `prefix` | string | No | Filter keys by prefix (e.g. `projects/`). The listing is unscoped when this argument is **absent, and only when it is absent** — a value that is not a string is **refused**, not ignored. | | `updatedAfter` | string | No | Only documents updated after this instant (ISO 8601). No lower bound applies when this argument is **absent, and only when it is absent** — a value that is not an ISO 8601 date-time string is **refused**, not ignored. | | `sortBy` | string | No | `key`, `createdAt`, `updatedAt`, `size`, `weight`, `lastReferencedAt` (default: `updatedAt`). A value outside the list, or of any other JSON type, is **refused**, not ignored. | | `sortOrder` | string | No | `asc`, `desc` (default: `desc`). A value outside the list, or of any other JSON type, is **refused**, not ignored. | | `page` | integer | No | Page number (1-based, minimum 1, default: 1). Cannot be used together with `offset`. A value that is not a whole number, or one below 1, is **refused**, not ignored. | | `offset` | integer | No | Number of documents to skip (minimum 0). Cannot be used together with `page`. A value that is not a whole number, or a negative one, is **refused**, not ignored. | | `limit` | integer | No | Documents per page (0-5000, default: 100). `0` answers an empty page carrying the real `totalCount`. A value that is not a whole number, or one outside the range, is **refused**, not adjusted. | **No argument here is dropped in silence — and that now covers the filters, not just the sorts.** `page`, `offset` and `limit` are read as integers: `{"page": "2"}` and `{"limit": 1.5}` both answer `-32602` and nothing is listed. They used to be dropped, so a mistyped `page` handed back page 1 under the caller's own argument. `sortBy` and `sortOrder` are refused the same way — a value outside the list, **or of any other JSON type**. So are `prefix` (a string) and `updatedAfter` (an ISO 8601 date-time string): a dropped `updatedAfter` was a filter that silently did not apply, so a caller asking for what changed since Tuesday was handed everything with nothing in the response to say so. Supplying both `page` and `offset` answers `-32602` too. **An out-of-range page size is refused too, not clamped.** `limit` is 0-5000, `page` is 1 or greater and `offset` is 0 or greater; anything else answers `-32602` and lists nothing. `{"limit": 10000}` used to come back as 5000 rows under a success, which an agent cannot tell from the category holding 5000 documents — it reads the page as complete and stops walking. **`limit: 0` is the one value outside 1-5000 that is accepted**, because it is a capability rather than a mistake: it answers an empty `items` array carrying the real `totalCount` — how many there are, without the rows. Ask it before deciding how to page. **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_list", "arguments": { "category": "main", "prefix": "projects/", "sortBy": "weight", "sortOrder": "desc", "limit": 20 } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/memory" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_list", "arguments": { "category": "main", "updatedAfter": "2026-01-15T00:00:00Z", "sortBy": "updatedAt" } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"items\":[{\"category\":\"main\",\"key\":\"projects/apollo\",\"title\":\"Apollo\",\"size\":148,\"version\":3,\"etag\":\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6\",\"referenceCount\":4,\"lastReferencedAt\":\"2026-01-16T08:40:00Z\",\"createdAt\":\"2026-01-15T10:30:00Z\",\"updatedAt\":\"2026-01-16T09:12:00Z\"}],\"page\":1,\"limit\":20,\"totalCount\":1,\"totalPages\":1}" } ], "isError": false } } ``` *** memory_context [#memory_context] Walk the wiki-link neighborhood of a document: related keys with distance and link status, no bodies. Links count in both directions, results are deduplicated, and the order is deterministic -- distance, then reference weight descending, then key. Link status is one of `resolved`, `stale`, or `ambiguous` (a dangling link reaches no document, so it never appears in a neighborhood). **Parameters:** | Name | Type | Required | Description | | ------------ | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping of documents | | `key` | string | Yes | Document key to walk from | | `depth` | integer | No | Hops to walk from the document (1-3, declared as `minimum` / `maximum` in the schema). `1` applies when the argument is **absent, and only when it is absent** — a value that is not a whole number is **refused**, not ignored; out of range is refused, not clamped. | | `maxRelated` | integer | No | Maximum related documents (1-500, declared as `minimum` / `maximum` in the schema). `50` applies when the argument is **absent, and only when it is absent** — a value that is not a whole number is **refused**, not ignored; out of range is refused, not clamped. | **A mistyped bound is refused with `-32602`, never replaced by the default.** A dropped `depth` or `maxRelated` walked a different neighbourhood than the one asked for while reporting success — the agent is never told it saw more, or less, of the graph than it did. The two refusals arrive differently. A wrong JSON type never reaches the handler and answers `-32602`; a whole number outside 1-3 or 1-500 does reach it and comes back as a tool result carrying `SCHEMA_VALIDATION_FAILED`. Either way the walk does not happen. **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_context", "arguments": { "category": "main", "key": "projects/apollo", "depth": 2, "maxRelated": 25 } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/memory" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_context", "arguments": { "category": "main", "key": "projects/apollo", "depth": 2 } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"category\":\"main\",\"key\":\"projects/apollo\",\"title\":\"Apollo\",\"depth\":2,\"items\":[{\"category\":\"main\",\"key\":\"people/dana\",\"title\":\"Dana\",\"distance\":1,\"status\":\"resolved\",\"referenceCount\":9}],\"totalCount\":1}" } ], "isError": false } } ``` *** memory_search [#memory_search] Search memory documents by text. Returns one ranked page of hits -- `category`, `key`, `title`, `frontmatter`, `section`, `snippet`, `score`, `referenceCount`, `updatedAt` -- and never a body. `sort=blended` mixes match strength, inbound links, and freshness. A search hit never returns the document body -- only the key, title, frontmatter, section breadcrumb, and a marked snippet. Call `memory_read` on the key you want. **Parameters:** | Name | Type | Required | Description | | ------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `category` | string | Yes | Logical grouping of documents | | `q` | string | Yes | Search text (quoted phrases and `or` / `-not` supported), max 1024 characters | | `mode` | string | No | `text`, `title`, `fuzzy` (default: `text`). A value outside the list, or of any other JSON type, is **refused**, not ignored. | | `prefix` | string | No | Filter keys by prefix (e.g. `projects/`). A value that is not a string is **refused**, not ignored. | | `tags` | string\[] | No | Frontmatter tags a document must all carry, by containment. A value that is not an array of strings is **refused**, not ignored. | | `frontmatter` | object | No | Frontmatter the document must contain, by containment (e.g. `{"status":"active"}` for current documents only). **Filters, never ranks.** A value that is not an object is **refused**, not ignored. | | `updatedAfter` | string | No | Only documents updated after this instant (ISO 8601). A value that is not an ISO 8601 date-time string is **refused**, not ignored. | | `followFrontmatter` | string | No | Frontmatter key whose value names another document (e.g. `supersededBy`). Each hit carrying it is expanded along it -- see below. A value that is not a string is **refused**, not ignored. | | `sort` | string | No | `relevance`, `weight`, `recency`, `blended` (default: `relevance`). A value outside the list, or of any other JSON type, is **refused**, not ignored. | | `weights` | object | No | Blended weights `{relevance, weight, recency}`; omitted components keep their server default. A `weights` that is not an object, or a component that is not a number, is **refused**, not ignored. | | `halfLifeDays` | number | No | Days after which the freshness signal halves (default: 30). A value that is not a number is **refused**, not ignored. | | `page` | integer | No | Page number (1-based, minimum 1, default: 1). Cannot be used together with `offset`. A value that is not a whole number, or one below 1, is **refused**, not ignored. | | `offset` | integer | No | Number of hits to skip (minimum 0). Cannot be used together with `page`. A value that is not a whole number, or a negative one, is **refused**, not ignored. | | `limit` | integer | No | Hits per page (0-5000, default: **20** — a recall page is read by an agent, so it is shorter than `memory_list`'s 100). `0` answers an empty page carrying the real `totalCount`: how many documents match, without reading a hit. A value that is not a whole number, or one outside the range, is **refused**, not adjusted. | **Every argument of this tool is refused when mistyped, not ignored.** `{"sort": 3}` used to be dropped in silence — the page then came back in the default order under the caller's own argument, while the equally wrong `{"sort": "3"}` was refused. Both now answer `-32602`, and so does every other argument that carries the wrong JSON type. **The filters are where this mattered most.** A dropped `tags` or `frontmatter` filter is the worst shape of the defect: the call succeeds, the page is full, and every document on it may be one the filter existed to exclude — which is exactly the lifecycle question `frontmatter` was added to answer. A dropped `updatedAfter` handed back everything to a caller asking what changed since Tuesday. A default applies when its argument is *absent*, and only when it is absent; JSON `null` counts as absent. **A whole number outside its range is refused as well**, so `{"limit": 10000}` answers `-32602` rather than a clamped page of 5000 that reads as the complete result set. `limit: 0` is the exception, and a deliberate one: an empty page carrying the real `totalCount`. Every hit carries the per-signal breakdown behind its score (`relevance`, `weight`, `recency`, `blended`), whatever the sort, so a page can be re-ranked locally without asking again. Server default weights are `relevance 0.6`, `weight 0.25`, `recency 0.15`. `prefix`, `tags`, `frontmatter` and `updatedAfter` are ANDed with the text match, and none of them ranks. **Only `frontmatter` can exclude a superseded document.** No sort and no weight can: the blended score has no lifecycle term, so a superseded document can outrank its replacement, and paging a large result set keeps returning it. Pass `"frontmatter": {"status":"active"}` -- containment, so the document's other frontmatter keys are ignored -- for current documents only. Every hit also carries its own `frontmatter`, so you can judge lifecycle without a second read. **The snippet is `ts_headline` over the prose zone whatever the `mode`.** A `fuzzy` hit can come back with no `` at all -- it matched on the title -- and its `section` then falls back to the document title. **Following a frontmatter link (`followFrontmatter`)** `followFrontmatter` names a frontmatter key whose value is another document's key. Each hit carrying that key is expanded along it: the document it names is resolved and **appended after the ranked page**, marked with `expandedFrom` (the key of the hit it was reached from), with `relevance` `0` -- it did not match `q` -- and outside `totalCount`, so paging is unaffected. Any key name works; no lifecycle vocabulary is built in, so an agent writing `replacedBy` or `nextVersion` gets the same behaviour by naming its own key. One hop, at most one page's worth of extra documents, a document already on the page is not appended twice, and a value naming no live document is skipped. Omit the argument and the search behaves exactly as it did before expansion existed. **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_search", "arguments": { "category": "main", "q": "pipeline migration", "sort": "blended", "limit": 10 } } } ``` The second sample reaches each hit's replacement through the frontmatter key the caller names, so an active document is returned even when its own lexical match is weaker than the superseded one's. Add `"frontmatter": {"status":"active"}` instead when you want the superseded documents gone from the ranked page altogether. ```json { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "memory_search", "arguments": { "category": "main", "q": "canonical operational state synchronization", "prefix": "projects/data/", "followFrontmatter": "supersededBy" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/memory" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "memory_search", "arguments": { "category": "main", "q": "migration", "prefix": "projects/", "tags": ["active"], "sort": "blended", "weights": { "weight": 0.6 }, "halfLifeDays": 7 } } }' ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"items\":[{\"category\":\"main\",\"key\":\"projects/apollo\",\"title\":\"Apollo\",\"frontmatter\":{\"status\":\"active\",\"owner\":\"dana\"},\"section\":\"Apollo > Status\",\"snippet\":\"Pipeline migration owned by dana.\",\"score\":{\"relevance\":1,\"weight\":0.82,\"recency\":0.61,\"blended\":0.89},\"referenceCount\":4,\"updatedAt\":\"2026-01-16T09:12:00Z\",\"expandedFrom\":null}],\"page\":1,\"limit\":10,\"totalCount\":7,\"totalPages\":1}" } ], "isError": false } } ``` `frontmatter` is the document's own frontmatter, exactly as the filter sees it, and `null` when the document has none. `expandedFrom` is `null` on every hit that matched `q` itself. **Response with `followFrontmatter`** (the expanded document is appended last, with `relevance` `0` and `expandedFrom` naming the hit it was reached from; `totalCount` still counts only the matched set): ```json { "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "{\"items\":[{\"category\":\"main\",\"key\":\"projects/data/architecture/layered-memory-model\",\"title\":\"Layered memory model\",\"frontmatter\":{\"status\":\"superseded\",\"supersededBy\":\"projects/data/architecture/ainoflow-first-memory-model\"},\"section\":\"Layered memory model > State\",\"snippet\":\"Canonical operational state synchronization.\",\"score\":{\"relevance\":1,\"weight\":0.4,\"recency\":0.22,\"blended\":0.73},\"referenceCount\":2,\"updatedAt\":\"2026-05-02T08:00:00Z\",\"expandedFrom\":null},{\"category\":\"main\",\"key\":\"projects/data/architecture/ainoflow-first-memory-model\",\"title\":\"Ainoflow-first memory model\",\"frontmatter\":{\"status\":\"active\"},\"section\":\"Ainoflow-first memory model\",\"snippet\":null,\"score\":{\"relevance\":0,\"weight\":0.9,\"recency\":0.95,\"blended\":0.37},\"referenceCount\":6,\"updatedAt\":\"2026-08-11T14:05:00Z\",\"expandedFrom\":\"projects/data/architecture/layered-memory-model\"}],\"page\":1,\"limit\":20,\"totalCount\":1,\"totalPages\":1}" } ], "isError": false } } ``` Resources [#resources] Memory exposes no MCP resources and no subscriptions -- path-shaped document keys do not survive URI parsing, so the whole surface is the eight tools above plus `memory_guide`. A `resources/list` or `resources/read` call returns a JSON-RPC error directing the client to the `memory_*` tools. Beyond the tools [#beyond-the-tools] The tool set is deliberately smaller than the Memory REST surface, so a client is not handed a menu it has to scroll. These capabilities exist but no tool reaches them — reach them over REST on `https://api.ainoflow.io`, with the same API key. | Capability | REST | What it gives you | | ----------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Category manifest | `GET /api/v1/memory/{category}/manifest` | Every live document of the category as `{ key, etag, version, size, updatedAt }`, unpaginated — the one call that diffs a local copy. It carries no title, weight or link counts; `/meta` has those. | | Document metadata | `GET /api/v1/memory/{category}/{key}/meta` | Addressing and concurrency metadata without the body: title, slug, version, etag, size, timestamps, headings, `referenceCount`. | | Outbound links | `GET /api/v1/memory/{category}/{key}/links` | The wiki-links this document points at, resolved and unresolved. | | Inbound links | `GET /api/v1/memory/{category}/{key}/backlinks` | The documents that point at this one — what would break if it were moved or deleted. | | Revision history | `GET /api/v1/memory/{category}/{key}/revisions` | Every previous version with the operation that produced it. **Memory is versioned**, and nothing in the tool list says so. | | A single revision | `GET /api/v1/memory/{category}/{key}/revisions/{version}` | The body of one past version, to compare against or restore by writing it back. | | Create-if-absent | `POST /api/v1/memory/{category}/{key}` | Write only when nothing is there, answering `409` if the document exists. `memory_write` is an upsert and will overwrite. Both take the same `expiresAt`/`expiresMs` TTL. | `memory_guide` returns this same list as data, each entry with its method, path and description. Failures [#failures] Two different things can go wrong, and they arrive differently. A refusal [#a-refusal] Your call reached the handler and was declined. It answers `200` with a tool result carrying `isError: true`, and `structuredContent` is machine-readable: ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "PRECONDITION_FAILED: If-Match precondition failed: the document has been modified (main/notes/plan)" }], "structuredContent": { "code": "PRECONDITION_FAILED", "message": "If-Match precondition failed: the document has been modified", "retryable": false, "category": "main", "key": "notes/plan" }, "isError": true } } ``` | `code` | When | Extra fields | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | `ITEM_TOO_LARGE` | The markdown body exceeds the size limit in force for your key | `currentBytes`, `maximumBytes` — always, on every size failure | | `SCHEMA_VALIDATION_FAILED` | An invalid address (a blank or over-long `category` or `key`), an `expiresAt` that is already past, an edit request this tool will not apply, or a `depth` / `maxRelated` outside its range | `invalidPath`, when the refusal has a position to point at | | `CONFLICT` | The write collided with the current state: the destination key is taken, a `str_replace` matched nothing or matched twice, or **any** write lost a race with a concurrent writer and changed nothing. The lost race reaches `memory_write`, `memory_edit`, `memory_move` and `memory_delete` alike — they could always meet it and previously reported it as `-32603` | — | | `PRECONDITION_FAILED` | An `ifMatch` or `expectedVersion` precondition did not hold | — | | `NOT_FOUND` | No document exists at that address | — | | `RATE_LIMITED` | The scope's live-document allowance is exhausted **and the call would add a document** — `memory_write` to a free address. Never `memory_edit`, `memory_move`, or a write over a document that already exists | — | **Each surface declares the codes it can actually produce, and Memory is one of the two that reach all six.** The vocabulary is shared across every Ainoflow MCP surface, but the list above is Memory's own: `memory_guide` returns it as `errors.codes`, and a code a surface does not declare never arrives from it. It used to be one fixed list of six printed identically everywhere — an upper bound wearing the words of a description. Check another area's guide before assuming it speaks the same subset. **`CONFLICT` and `PRECONDITION_FAILED` are different refusals, and the difference is whose guard failed.** `PRECONDITION_FAILED` means the `ifMatch` / `expectedVersion` **you sent** did not hold — a stale tag, a stale version, or an address that holds nothing. `CONFLICT` means nothing of yours failed. Every write — conditional or not — is applied against the exact version it was computed from, so a concurrent writer landing in between makes your write describe a document that no longer exists, and it is refused rather than overwriting that writer. It is `CONFLICT` rather than `PRECONDITION_FAILED` because the latter would name a precondition you never set. **`CONFLICT` carries two messages, and they want different things from you.** *"...already exists"* means the address is occupied: pick another key, or overwrite deliberately with `memory_write`. *"The document was modified by another writer while this write / edit / move was in flight..."* means you lost a race: re-read and re-apply. `retryable` is `false` for both, and on the lost race that is not a formality — repeating the identical call recomputes the same content from the same stale read. **`memory_delete` is the exception, deliberately.** Its lost-race message ends *"...the address may now hold another writer's document, or nothing at all - and delete again only if that is still what you intend."* A delete carries no payload, so an unchanged retry would succeed; what you lost is the knowledge of what you are deleting, not the write. Read before deleting again. `retryable` is `true` only for `RATE_LIMITED`; every other code needs a changed request — a smaller payload, a fresh ETag, a different key, or a fresh read. **`retryable: true` means the refusal can clear without changing the request — not that waiting clears it.** The live-document allowance is a count of what the scope holds, not a periodic quota, so it clears when documents are deleted and never on a billing cycle. An unchanged retry answers `RATE_LIMITED` again until room is freed. The two byte counts travel on **every** size refusal, so resize from `currentBytes` and `maximumBytes` rather than parsing the message. `invalidPath` is conditional by contrast: it appears only on a shape refusal that has a position to point at. A protocol or server error [#a-protocol-or-server-error] Your call never ran, or the service failed. It arrives as a JSON-RPC error: | Code | Meaning | | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `-32600` | Not a valid JSON-RPC 2.0 request, or a `tools/call` without an `id` | | `-32601` | Unknown method, unknown tool, or a capability this surface does not offer | | `-32602` | An argument is absent, blank, of the wrong JSON type, or a pagination value outside its range — your call never reached the handler | | `-32603` | The server itself failed. Not your request; retrying it unchanged is reasonable | | `-32001` | Unauthorized, or a resource URI that resolves to nothing | **Branch on `code`, never on the message text.** Messages are written for humans and may be reworded; `code` is part of the contract. **Every argument of every memory tool is refused with `-32602` when it carries the wrong JSON type — none is dropped in silence.** The tools say so themselves: each of the eight descriptions returned by `tools/list` and `memory_guide` ends with that sentence, appended by the tool factory rather than written by hand, so a tool cannot be added to this surface without the statement travelling with it. A default applies when its argument is *absent*, and only when it is absent. **This changed.** A refusal used to come back as JSON-RPC `-32603` — the same code as a crashed server — so "too large" and "we fell over" were indistinguishable to a client. Business failures now answer `200` with `isError: true` and a code; `-32603` means the server, and only the server. A wrong argument type is still `-32602`, unchanged. # Shield (/docs/mcp/shield) Shield MCP provides AI assistants with distributed locking and idempotency protection capabilities. It prevents duplicate task executions across agents, ensuring workflows run exactly once. The API uses the MCP protocol over HTTP transport with JSON-RPC 2.0. **Entry contract** ```http POST https://mcp.ainoflow.io/mcp/v1/shield Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` The MCP host is **`mcp.ainoflow.io`**. It is not `api.ainoflow.io` — that host serves the REST API and does not route `/mcp/v1/*`, so an MCP client pointed at it is rejected before it reaches this surface. Both headers are required on every request. Connection [#connection] Claude Desktop [#claude-desktop] ```json { "mcpServers": { "ainoflow-shield": { "url": "https://mcp.ainoflow.io/mcp/v1/shield", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Cursor IDE [#cursor-ide] ```json { "mcpServers": { "ainoflow-shield": { "url": "https://mcp.ainoflow.io/mcp/v1/shield", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Start here: `shield_guide` [#start-here-shield_guide] Call `shield_guide` before anything else. It takes no arguments and returns the contract of this surface as data: every tool with its input schema, any response-shape exception, the failure codes below, and the capabilities of this area that no tool reaches. The guide's `limits` array reports the allowance Shield enforces on you: | `name` | `unit` | What it bounds | | -------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `shieldLocksMonthly` | `locks` | `shield_acquire` calls in this scope per calendar month. **Only acquire is charged** — complete, renew and status act on a lock already counted. An acquire past it answers `RATE_LIMITED` with `retryable: true`, which clears when the month turns over and not before. `0` means unlimited. | The number is resolved for your key from the same reader the acquire is measured against, so it is the allowance actually in force rather than a published default. A token with no resolvable scope has none to report and the array comes back empty. Shield declared `RATE_LIMITED` while publishing no limit at all, so a guide that named the refusal could not say what would cause it. Shield's **deployment bounds** — the maximum key length, the default lease, the maximum lifetime a renew may reach and the completed-lock TTL — are a different thing, read from this deployment's configuration and published in the tool input schemas that `shield_guide` and `tools/list` return. Read them there rather than from the numbers on this page, which are the values this deployment runs today. ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "shield_guide", "arguments": {} } } ``` `initialize` also returns an `instructions` string naming it, so a client learns the guide exists without reading anything. **This page and the guide describe the same surface — the guide is the copy that cannot go stale.** Its tool inventory is generated from the same definitions `tools/list` returns, so a tool cannot exist without appearing in it. Limits it reports are resolved for your key, not the published default. Where this page and the guide disagree, the guide is right and this page is a bug. Tools [#tools] shield_acquire [#shield_acquire] Acquire a distributed lock for idempotency protection. Answers `{ key, acquired, status, token, expiresIn }`: `status: "locked"` with a token when the lock is yours, `status: "duplicate"` with `acquired: false` and no token when the key is already held. A duplicate is a **successful** call, not a tool error -- branch on `acquired`. Keep the token: `shield_complete` and `shield_renew` need it. | Name | Type | Required | Description | | ----------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `key` | string | Yes | Lock identifier, derived from the work itself so the same work produces the same key. Maximum length is deployment-configured — **512 characters** on this deployment, and the tool schema states the number in force. A longer key answers `-32602`. | | `ttl` | integer | No | Lease length in seconds. The deployment default — **1800** (30 minutes) — applies when the argument is **absent, and only when it is absent**; a value that is not a whole number is refused, never dropped in favour of the default. Clamped to the maximum lifetime, **86,400 seconds** (24 hours) on this deployment. | | `reference` | string | No | Optional reference for audit. A value that is not a string is **refused**, not ignored. Maximum length is deployment-configured — **512 characters** on this deployment, the same bound as the key, and the tool schema states the number in force. A longer reference answers `-32602` and takes no lock. | **The reference is bounded before the lock is taken.** It is written by the audit step *after* acquire succeeds, and it used to be bounded by nothing but the column it lands in — so an over-long reference passed acquire, **took the lock**, then failed the audit insert. The caller saw a server error while the lock it had just taken was still held, and the audit row that would have said who held it was the row that failed. The bound now sits on the path both transports share, so it answers `-32602` here and `400` over REST, and the key is left free either way. **A wrong-typed argument is refused with `-32602`, never dropped.** A dropped `ttl` took the deployment default lease under the caller's own argument, so a caller that asked for 30 seconds held the key for 30 minutes — far longer than the work it was guarding. The same holds on `shield_renew`, where a heartbeat that quietly renewed for the default lease is a lock that expires while its work is still running. **An exhausted monthly lock allowance is `RATE_LIMITED`, and it is the one refusal worth retrying.** Once the scope's monthly Shield allowance is spent, `shield_acquire` answers a tool result with `isError: true`, `code: "RATE_LIMITED"` and `retryable: true`. It used to arrive as JSON-RPC `-32603` — "the server itself failed; retrying it unchanged is reasonable" — so callers retried, hard, the one call that cannot succeed until the month turns over. Wait rather than hammer. The refusal is raised on the path both transports share, so it is the same one REST answers as `429 Too Many Requests` on `POST /api/v1/shield/{key}`. No lock is taken either way, and only acquire is charged. ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "RATE_LIMITED: Shield API locks limit exceeded. Limit=500. (order-12345)" }], "structuredContent": { "code": "RATE_LIMITED", "message": "Shield API locks limit exceeded. Limit=500.", "retryable": true, "key": "order-12345" }, "isError": true } } ``` **Code Samples** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "shield_acquire", "arguments": { "key": "order-12345", "ttl": 3600, "reference": "workflow-run-1" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/shield" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "shield_acquire", "arguments": { "key": "order-12345", "ttl": 3600 } } }' ``` **Success response** (lock acquired): ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{\"key\":\"order-12345\",\"acquired\":true,\"status\":\"locked\",\"token\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\",\"expiresIn\":3600}" }], "isError": false } } ``` **Duplicate response** (lock already exists): ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{\"key\":\"order-12345\",\"acquired\":false,\"status\":\"duplicate\",\"expiresIn\":1800}" }], "isError": false } } ``` Check the `acquired` field to determine if the lock was obtained. Duplicates are not errors. *** shield_complete [#shield_complete] Complete a lock (mark workflow as finished). Extends TTL to the completed-lock lifetime for duplicate detection — **2,592,000 seconds** (30 days) on this deployment, as the tool schema states. Requires the token from acquire. Answers `NOT_FOUND` when the lock has expired and `CONFLICT` when the token does not own it. | Name | Type | Required | Description | | ------- | ------ | -------- | --------------------------- | | `key` | string | Yes | Lock key identifier | | `token` | string | Yes | Token received from acquire | **Code Samples** ```json { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "shield_complete", "arguments": { "key": "order-12345", "token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/shield" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "shield_complete", "arguments": { "key": "order-12345", "token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" } } }' ``` **Success response:** ```json { "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "{\"key\":\"order-12345\",\"success\":true,\"status\":\"completed\",\"expiresIn\":2592000}" }], "isError": false } } ``` **Error response** (the lock has expired -- `NOT_FOUND`; a token that does not own it answers `CONFLICT` the same way): ```json { "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "NOT_FOUND: Lock not found or expired (order-12345)" }], "structuredContent": { "code": "NOT_FOUND", "message": "Lock not found or expired", "retryable": false, "key": "order-12345" }, "isError": true } } ``` *** shield_renew [#shield_renew] Renew the lock lease as a heartbeat while long work runs. The new TTL is capped to the maximum lifetime measured from acquire — **86,400 seconds** (24 hours) on this deployment, as the tool schema states. Requires the token from acquire. Answers `NOT_FOUND` when the lock has expired and `CONFLICT` when the token does not own it or the maximum lifetime is reached. | Name | Type | Required | Description | | ------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `key` | string | Yes | Lock key identifier. A key longer than the configured maximum answers `-32602`. | | `token` | string | Yes | Token received from acquire | | `ttl` | integer | No | Requested lease length in seconds. The deployment default — **1800** (30 minutes) — applies when the argument is **absent, and only when it is absent**; a value that is not a whole number is refused, never dropped in favour of the default. Capped to the remaining deadline. | **Code Samples** ```json { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "shield_renew", "arguments": { "key": "order-12345", "token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "ttl": 3600 } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/shield" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "shield_renew", "arguments": { "key": "order-12345", "token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "ttl": 3600 } } }' ``` **Success response:** ```json { "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "{\"key\":\"order-12345\",\"success\":true,\"expiresIn\":3600}" }], "isError": false } } ``` **Success response (TTL capped):** ```json { "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "{\"key\":\"order-12345\",\"success\":true,\"expiresIn\":1800,\"capped\":true}" }], "isError": false } } ``` `capped: true` indicates the requested TTL was reduced to fit within the max\_lifetime deadline. **Refusal (the maximum lifetime is reached -- no renew can extend past it):** ```json { "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "CONFLICT: Lock has exceeded maximum lifetime of 24 hours (order-12345)" }], "structuredContent": { "code": "CONFLICT", "message": "Lock has exceeded maximum lifetime of 24 hours", "retryable": false, "key": "order-12345" }, "isError": true } } ``` *** shield_status [#shield_status] Read whether a lock is held and how long it has left, without taking or extending it. Answers `NOT_FOUND` unless `allowEmpty` is set to `true`, which answers `{}` instead. No filters and no sorts: Shield addresses one key at a time and has no listing. | Name | Type | Required | Description | | ------------ | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `key` | string | Yes | Lock key identifier | | `allowEmpty` | boolean | No | If `true`, a key holding no lock answers a success carrying `{}` instead of `NOT_FOUND`. `false` applies when the argument is **absent, and only when it is absent** — a value that is not a boolean is refused, never read as `false`. | **`allowEmpty` is refused when it is not a boolean.** Read by value kind, `{"allowEmpty": "true"}` tested false and a caller that asked for `{}` on an absent lock was refused with `NOT_FOUND` instead — a refusal it had explicitly opted out of, arriving under its own argument. The flag is now checked before the query runs, so the answer never depends on whether the lock happened to exist. **Code Samples** ```json { "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "shield_status", "arguments": { "key": "order-12345" } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/shield" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "shield_status", "arguments": { "key": "order-12345" } } }' ``` **Response (lock exists):** ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [{ "type": "text", "text": "{\"key\":\"order-12345\",\"exists\":true,\"expiresIn\":2400}" }], "isError": false } } ``` **Response (not found, default):** ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [{ "type": "text", "text": "NOT_FOUND: Lock not found: order-12345 (order-12345)" }], "structuredContent": { "code": "NOT_FOUND", "message": "Lock not found: order-12345", "retryable": false, "key": "order-12345" }, "isError": true } } ``` **Response (not found, allowEmpty=true):** ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [{ "type": "text", "text": "{}" }], "isError": false } } ``` *** Resources [#resources] shield://{key} [#shieldkey] Access lock status as a read-only resource. **MIME Type:** `application/json` **URI Pattern:** `shield://{key}` where `key` is the lock identifier. URL-encode special characters. **Example request:** ```json { "jsonrpc": "2.0", "id": 5, "method": "resources/read", "params": { "uri": "shield://order-12345" } } ``` **Example response:** ```json { "jsonrpc": "2.0", "id": 5, "result": { "contents": [{ "uri": "shield://order-12345", "mimeType": "application/json", "text": "{\"key\":\"order-12345\",\"exists\":true,\"expiresIn\":2400}" }] } } ``` **This changed: a key holding no lock now answers `-32001`.** It used to answer `200` with `exists: false` in the body, which made Shield the one surface where a client had to read the body to learn the resource was not there. A URI that resolves to nothing is `-32001` on every Ainoflow MCP surface, and Shield now says the same thing. **Response (no lock on that key):** ```json { "jsonrpc": "2.0", "id": 5, "error": { "code": -32001, "message": "Lock not found: order-12345", "data": null } } ``` Use `shield_status` with `allowEmpty: true` when you want to ask without being refused — it answers a successful tool result carrying `{}`. **How a resource read fails** | Situation | Answer | | -------------------------------------------------------------- | -------------------------------------------------------------- | | `params` carries no `uri`, or a `uri` that is not a string | `-32602` (`Missing 'uri' parameter` / `Invalid params format`) | | A key longer than the configured maximum, or otherwise invalid | `-32602` | | A URI that is not `shield://…`, or a key holding no lock | `-32001` | | The backing store failed | `-32603` | Beyond the tools [#beyond-the-tools] The tool set is deliberately smaller than the Shield REST surface, so a client is not handed a menu it has to scroll. These capabilities exist but no tool reaches them — reach them over REST on `https://api.ainoflow.io`, with the same API key. | Capability | REST | What it gives you | | -------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Release a lock | `DELETE /api/v1/shield/{key}` | Drop a lock without marking its work complete, so the key can be acquired again immediately. `shield_complete` is the opposite: it extends the lock for 30 days to keep detecting duplicates. | `shield_guide` returns this same list as data, each entry with its method, path and description. Failures [#failures] Two different things can go wrong, and they arrive differently. A refusal [#a-refusal] Your call reached the handler and was declined. It answers `200` with a tool result carrying `isError: true`, and `structuredContent` is machine-readable: ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "CONFLICT: Provided token does not match lock owner (invoice-run)" }], "structuredContent": { "code": "CONFLICT", "message": "Provided token does not match lock owner", "retryable": false, "key": "invoice-run" }, "isError": true } } ``` | `code` | When | Extra fields | | -------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------ | | `NOT_FOUND` | The lock has expired or was never taken — `shield_complete`, `shield_renew`, or `shield_status` without `allowEmpty` | — | | `CONFLICT` | The token presented does not own the lock, or `shield_renew` is past the maximum lifetime — no renew can extend beyond it | — | | `RATE_LIMITED` | The scope's monthly lock allowance is exhausted. `retryable: true` | — | **These three are Shield's whole set — it cannot send the other three.** Each Ainoflow MCP surface declares only the codes it can actually produce, and `shield_guide` returns Shield's set as `errors.codes`. Shield takes no payload, so nothing can be `ITEM_TOO_LARGE` or `SCHEMA_VALIDATION_FAILED` — a malformed argument is `-32602` instead. The page used to print all six, which is an upper bound wearing the words of a description. `retryable` is `true` only for `RATE_LIMITED`; every other code needs a changed request — a fresh token, a new lock, a different key. A protocol or server error [#a-protocol-or-server-error] Your call never ran, or the service failed. It arrives as a JSON-RPC error: | Code | Meaning | | -------- | --------------------------------------------------------------------------------------------- | | `-32600` | Not a valid JSON-RPC 2.0 request, or a `tools/call` without an `id` | | `-32601` | Unknown method, unknown tool, or a capability this surface does not offer | | `-32602` | An argument is absent, blank, or of the wrong JSON type — your call never reached the handler | | `-32603` | The server itself failed. Not your request; retrying it unchanged is reasonable | | `-32001` | Unauthorized, or a resource URI that resolves to nothing | **Branch on `code`, never on the message text.** Messages are written for humans and may be reworded; `code` is part of the contract. **This changed.** A refusal used to come back as JSON-RPC `-32603` — the same code as a crashed server — so "your monthly lock allowance is spent" and "we fell over" were indistinguishable to a client, and the advice attached to `-32603` ("retrying it unchanged is reasonable") was exactly wrong. Business failures now answer `200` with `isError: true` and a code; `-32603` means the server, and only the server. A wrong argument type is still `-32602`, unchanged. # Data store (/docs/mcp/storage) The Data store keeps JSON documents for configuration, workflow state, and structured records. Store and retrieve schema-free JSON documents organized by category and key, with optional TTL expiration and partial updates via JSON Merge Patch (RFC 7386). **Entry contract** ```http POST https://mcp.ainoflow.io/mcp/v1/storage/json Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` The surface is also mounted bound to a single category, where the route supplies the category and the tools are named `{category}_json_*` instead of `storage_json_*`: ```http POST https://mcp.ainoflow.io/mcp/v1/storage/json/{category} Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` The MCP host is **`mcp.ainoflow.io`**. It is not `api.ainoflow.io` — that host serves the REST API and does not route `/mcp/v1/*`, so an MCP client pointed at it is rejected before it reaches this surface. Both headers are required on every request, on both paths. Connection [#connection] Claude Desktop [#claude-desktop] ```json { "mcpServers": { "ainoflow-storage": { "url": "https://mcp.ainoflow.io/mcp/v1/storage/json", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Cursor IDE [#cursor-ide] ```json { "mcpServers": { "ainoflow-storage": { "url": "https://mcp.ainoflow.io/mcp/v1/storage/json", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Limits [#limits] **Document size** A single JSON document may be up to **10 MB**. That is the default; the limit is resolved per scope, and an individual API key may carry its own override, so treat 10 MB as the default rather than a constant and read the effective value from `storage_guide`. A write above the effective limit is rejected before it is stored, and nothing is partially written. The limit applies to the **document you end up with**, not to the payload you send. A `storage_json_patch` whose merge result would cross the limit is refused with `ITEM_TOO_LARGE` and nothing is stored, however small the patch itself is — a sequence of small patches cannot walk a document past the ceiling. | Constraint | Value | | ---------------- | ------------------------------------------------------------------------------ | | Document size | 10 MB (default, resolved per scope) | | Stored documents | 5,000 (default, resolved per scope) | | Key length | 1 to 1024 characters | | Category length | 1 to 100 characters | | Root JSON type | Object `{...}` or array `[...]` — never a bare string, number, boolean or null | **Ask instead of assuming.** The numbers above are defaults. `GET /api/v1/capabilities` returns `limits` resolved for your key, and `storage_guide` reports the same values over MCP — both already have any per-scope override applied. A size refusal also carries `currentBytes` and `maximumBytes`, so a client never has to guess what it is allowed to send. **An address that does not fit is refused as your mistake, not ours.** A `category` outside 1–100 characters, or a `key` outside 1–1024, is refused with `SCHEMA_VALIDATION_FAILED` — a tool result carrying `isError: true`, not a JSON-RPC error — and nothing is stored. Unlike the document size limit these two bounds are the same for every project, and both are published on the tool schemas as `maxLength`, so a client can check before it calls. Data store bounded an address nowhere but in the stored column until this check was added, so an over-long key travelled the whole write path and came back as `-32603`: *the server itself failed; retrying unchanged is reasonable*, for a call that would never succeed. A long hierarchical key (`recall/profile/user-42`) is exactly the shape this surface encourages, so it was reachable by following the documentation. **The document allowance is a stored count, not a monthly one.** It counts the JSON documents your scope is keeping right now, across every category. Once it already holds as many as the allowance permits, a call that would add one more is refused with `RATE_LIMITED` and **nothing is stored**. `RATE_LIMITED` carries `retryable: true` because it is the one code a retry can ever clear — but on this surface what clears it is **freeing room**, not waiting. Nothing about the allowance resets on a billing cycle, so the identical call answers `RATE_LIMITED` again for as long as the scope stays full. A successful `storage_json_delete` frees room immediately, and an item written with a TTL frees room when it expires — but check the result, because a delete refused with `CONFLICT` deleted nothing and freed nothing. The monthly allowances — Convert jobs, Guard checks, Shield locks, Inbox messages — genuinely do reset with the calendar month; Data store's does not, so do not carry their advice over to it. **Only a write that adds a document is charged:** | Tool | When the allowance applies | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `storage_json_create` | Always — an auto-generated key always adds a document | | `storage_json_upsert` | Only when it **creates**. Replacing a key that already holds something is never refused by the allowance | | `storage_json_patch` | Only on the upsert branch — an **unconditional** patch against an address that holds nothing. Patching an item that already exists is never refused by the allowance, and a conditional patch never reaches the branch: it answers `PRECONDITION_FAILED` instead of creating | The REST-only create-if-absent (`POST /api/v1/storage/json/{category}/{key}`, see [Beyond the tools](#beyond-the-tools)) checks the address first, so an occupied key answers `409` rather than the allowance. `RATE_LIMITED` and `ITEM_TOO_LARGE` are different refusals: one says this document is over the size limit, the other says the scope has no room for another however small it is. The number above is the default; `storage_guide` reports `storageDocumentCount` resolved for your key. Start here: `storage_guide` [#start-here-storage_guide] Call `storage_guide` before anything else. It takes no arguments and returns the contract of this surface as data: every tool with its input schema, any response-shape exception, the failure codes below, **the limits in force for your key**, and the capabilities of this area that no tool reaches. ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "storage_guide", "arguments": {} } } ``` `initialize` also returns an `instructions` string naming it, so a client learns the guide exists without reading anything. **This page and the guide describe the same surface — the guide is the copy that cannot go stale.** Its tool inventory is generated from the same definitions `tools/list` returns, so a tool cannot exist without appearing in it. Limits it reports are resolved for your key, not the published default. Where this page and the guide disagree, the guide is right and this page is a bug. Tools [#tools] storage_json_list_categories [#storage_json_list_categories] List all distinct categories in JSON storage with item counts. Useful for discovering what data namespaces exist. **Parameters:** | Name | Type | Required | Description | | -------- | ------- | -------- | ------------------------------------------------------------------------------------------------ | | `page` | integer | No | Page number (1-based, default: 1). Cannot be used with `offset`. | | `offset` | integer | No | Items to skip. Cannot be used with `page`. | | `limit` | integer | No | Items per page (0-5000, default: 100). `0` answers an empty page carrying the real `totalCount`. | **A pagination argument that is not a whole number is refused, not ignored.** `page`, `offset` and `limit` are read as integers: `{"page": "2"}` and `{"page": 1.5}` both answer `-32602` (`Argument 'page' must be an integer number`) and nothing is listed. They used to be dropped in silence, so a mistyped `page` handed back page 1 under the caller's own argument. Supplying both `page` and `offset` answers `-32602` as well. **A pagination value outside its range is refused too, never adjusted.** `limit` below `0` or above `5000`, `page` below `1`, and `offset` below `0` each answer `-32602` and list nothing. `limit: 0` is the one value outside `1-5000` that is accepted, because it is a capability rather than a mistake: it answers an empty page carrying the real `totalCount` — how many are there, without the rows. The tool schema declares exactly that, `minimum: 0` and `maximum: 5000`; it used to declare `minimum: 1` and forbid the count-only page the handler had always accepted. An out-of-range `limit` used to be substituted instead — negative values silently became `100`, and anything above `5000` was clamped to `5000` — so a caller asking for `10000` rows received `5000` under an `isError: false` and could not tell that from there being `5000` rows: the page read as complete and the scan stopped early. **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "storage_json_list_categories", "arguments": {} } } ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{\"categories\":[{\"category\":\"config\",\"count\":5},{\"category\":\"workflows\",\"count\":42}],\"total\":2,\"page\":1,\"limit\":100,\"totalPages\":1}" }], "isError": false } } ``` *** storage_json_create [#storage_json_create] Create a JSON document with an auto-generated key (UUID without dashes). This tool always adds a document, so it is always subject to the scope's stored-document allowance: once that is full it is refused with `RATE_LIMITED` and nothing is stored. Freeing room is what clears it, not waiting — see [Limits](#limits). **Parameters:** | Name | Type | Required | Description | | ----------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `category` | string | Yes | Logical grouping (namespace). At most 100 characters. | | `data` | string | Yes | JSON string to store (must be object or array) | | `expiresAt` | string | No | Expiration date (ISO 8601). Cannot be used with `expiresMs`. Must be in the future — an expiry already past returns `SCHEMA_VALIDATION_FAILED` and stores nothing. | | `expiresMs` | integer | No | TTL in milliseconds from now. Cannot be used with `expiresAt`. | `expiresAt` and `expiresMs` are mutually exclusive — supplying both returns `-32602` ("Cannot specify both expiresAt and expiresMs parameters") and stores nothing, the same rule the REST API applies. An `expiresAt` must be in the future — one that is already past is refused with `SCHEMA_VALIDATION_FAILED` and stores nothing. An `expiresMs` of `0` or less means **no expiration**. **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "storage_json_create", "arguments": { "category": "events", "data": "{\"event\":\"user_login\",\"timestamp\":\"2026-01-15T10:30:00Z\"}" } } } ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{\"category\":\"events\",\"key\":\"a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6\",\"version\":1,\"etag\":\"abc123\",\"expiresAt\":null}" }], "isError": false } } ``` *** storage_json_upsert [#storage_json_upsert] Create or replace a JSON document by category and key. Only a call that **creates** is charged against the scope's stored-document allowance, so replacing a key that already holds something is never refused by it. A create past a full scope is refused with `RATE_LIMITED` and nothing is stored — see [Limits](#limits). **Parameters:** | Name | Type | Required | Description | | ----------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping (namespace). At most 100 characters. | | `key` | string | Yes | Unique identifier within category. At most 1024 characters. | | `data` | string | Yes | JSON string to store (must be object or array) | | `expiresAt` | string | No | Expiration date (ISO 8601). Cannot be used with `expiresMs`. Must be in the future — an expiry already past returns `SCHEMA_VALIDATION_FAILED` and stores nothing. | | `expiresMs` | integer | No | TTL in milliseconds from now. Cannot be used with `expiresAt`. | | `ifMatch` | string | No | ETag precondition (`*` matches any existing document); the write fails if the current ETag differs. The write is unconditional when this argument is **absent, and only when it is absent** — a value that is not a string is **refused**, not dropped. | | `expectedVersion` | integer | No | Version precondition; the write fails if the current version differs. A value that is not a whole number is **refused**, not ignored. | `ifMatch` and `expectedVersion` make the write conditional, which is how concurrent writers avoid overwriting each other. When the precondition does not hold, nothing is stored and the call returns a precondition error rather than silently replacing the document. `expectedVersion` is an integer — send `5`, not the quoted `"5"` or a fractional `5.5`, either of which returns `-32602`. **A wrong-typed `ifMatch` is refused with `-32602`, never dropped.** This is the argument the strictness sweep existed for. Read leniently, `{"ifMatch": 5}` was discarded, the command then carried no ETag precondition at all, the conditional write ran as an unconditional one, and the call answered success — so the lost-update protection you asked for was gone and nothing in the response said so. Every other mistyped argument at worst returns the wrong page; this one overwrites somebody else's data. Pass the ETag as a string, and note that the write is unconditional only when you send neither precondition. The REST twin now refuses the same mistake: `PUT /api/v1/storage/json/{category}/{key}` answers `400` when `If-Match` is present but names no entity tag (`""`, `W/""`, a bare `W/`, or whitespace), where it used to answer `200` having quietly written unconditionally. An **absent** `If-Match` still means an unconditional write. `PATCH` and `storage_json_patch` take the same preconditions under the same rules. `expiresAt` and `expiresMs` are mutually exclusive — supplying both returns `-32602` ("Cannot specify both expiresAt and expiresMs parameters") and stores nothing, the same rule the REST API applies. An `expiresAt` must be in the future — one that is already past is refused with `SCHEMA_VALIDATION_FAILED` and stores nothing. An `expiresMs` of `0` or less means **no expiration**. **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "storage_json_upsert", "arguments": { "category": "config", "key": "app-settings", "data": "{\"theme\":\"dark\",\"language\":\"en\"}" } } } ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{\"category\":\"config\",\"key\":\"app-settings\",\"version\":1,\"etag\":\"abc123\",\"expiresAt\":null}" }], "isError": false } } ``` *** storage_json_patch [#storage_json_patch] Partially update a JSON document using JSON Merge Patch (RFC 7386). Only specified fields are modified. Creates the item if it does not exist (upsert behavior) — **unless you send a precondition**, which makes the merge conditional and removes the create branch. * Null values **remove** fields * Objects are **recursively merged** * Arrays and primitives **replace** the existing value `storage_json_patch` takes the same two preconditions as `storage_json_upsert` — `ifMatch` and `expectedVersion` — and still takes **no TTL**: use `storage_json_upsert` when you need to set or change an expiry. Only the upsert branch — an **unconditional** patch against an address that holds nothing — is charged against the scope's stored-document allowance, so patching an item that already exists is never refused by it, and a conditional patch never reaches the branch. That branch past a full scope is refused with `RATE_LIMITED` and nothing is stored — see [Limits](#limits). **Parameters:** | Name | Type | Required | Description | | ----------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping (namespace). At most 100 characters. | | `key` | string | Yes | Unique identifier within category. At most 1024 characters. | | `patch` | string | Yes | JSON patch object to merge | | `ifMatch` | string | No | ETag precondition (`*` matches any existing document); the patch fails if the current ETag differs. The patch is unconditional when this argument is **absent, and only when it is absent** — a value that is not a string is **refused**, not dropped. | | `expectedVersion` | integer | No | Version precondition; the patch fails if the current version differs. A value that is not a whole number is **refused**, not ignored. | `ifMatch` and `expectedVersion` make the merge conditional, which is how concurrent writers avoid overwriting each other. **Send both and both must hold.** When a precondition does not hold, nothing is stored and the call returns `PRECONDITION_FAILED` rather than silently merging into a document you did not read. These are the same two arguments `storage_json_upsert` takes, with the same rules: `expectedVersion` is an integer — send `7`, not the quoted `"7"` or a fractional `7.5`, either of which returns `-32602` — and a wrong-typed `ifMatch` is refused with `-32602`, never dropped into an unconditional write. **A conditional patch does not create.** An unconditional `storage_json_patch` against an address that holds nothing still creates the document from the patch body — that is unchanged. Send `ifMatch` or `expectedVersion` and that branch is gone: there is nothing for the precondition to match, so the call is refused with `PRECONDITION_FAILED` instead of creating. It is the rule `storage_json_upsert` already applies, and RFC 7232's meaning of `If-Match: *`. Patch without a precondition when creation is what you want. **`CONFLICT` and `PRECONDITION_FAILED` are different refusals, and the difference is whose guard failed.** `PRECONDITION_FAILED` means the `ifMatch` / `expectedVersion` **you sent** did not hold — a stale tag, a stale version, or an address that holds nothing. `CONFLICT` means nothing of yours failed. Every patch — conditional or not — is written against the exact version its merge was computed from, so a concurrent writer landing in between makes the merge describe a document that no longer exists, and the patch is refused rather than overwriting that writer. It is `CONFLICT` rather than `PRECONDITION_FAILED` because the latter would name a precondition you never set. `CONFLICT` carries `retryable: false`, and that is not a formality: repeating the identical call recomputes the same merge from the same stale read. **Read the document again and re-apply the patch.** On either code the stored body, version, ETag, expiry and every metadata field are left byte-identical — there is no partial merge to clean up. **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "storage_json_patch", "arguments": { "category": "config", "key": "app-settings", "patch": "{\"theme\":\"light\",\"notifications\":{\"email\":false}}" } } } ``` **Example request (conditional, both guards ANDed):** ```json { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "storage_json_patch", "arguments": { "category": "config", "key": "app-settings", "patch": "{\"theme\":\"light\"}", "ifMatch": "abc123", "expectedVersion": 7 } } } ``` ```bash curl -X POST "https://mcp.ainoflow.io/mcp/v1/storage/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "storage_json_patch", "arguments": { "category": "config", "key": "app-settings", "patch": "{\"theme\":\"light\"}", "ifMatch": "abc123", "expectedVersion": 7 } } }' ``` Both guards are sent here, so **both** must hold: the document must still carry ETag `abc123` *and* be at version `7`. Read them from `storage_json_get_metadata` first, and send just one when one is enough. **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{\"category\":\"config\",\"key\":\"app-settings\",\"version\":2,\"etag\":\"xyz789\",\"expiresAt\":null}" }], "isError": false } } ``` *** storage_json_get [#storage_json_get] Retrieve a JSON document by category and key. The result is the stored document itself, with no metadata wrapper — `storage_json_get_metadata` carries the version and etag. Answers `NOT_FOUND` unless `allowEmpty` is set to true. **Parameters:** | Name | Type | Required | Description | | ------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `category` | string | Yes | Logical grouping (namespace). At most 100 characters. | | `key` | string | Yes | Unique identifier within category. At most 1024 characters. | | `allowEmpty` | boolean | No | If true, an absent item answers a success carrying `{}` marked `_meta["io.ainoflow/found"] = false` instead of `NOT_FOUND`. `false` applies when the argument is **absent, and only when it is absent** — a value that is not a boolean is **refused**, never read as `false`. | **`allowEmpty` is refused when it is not a boolean.** Read as "the JSON literal `true`, or else false", `{"allowEmpty": "true"}` opted the caller *out* of the behaviour it had just asked for, and the call was then refused with `NOT_FOUND` under its own argument. The flag is now checked before the query runs, so the refusal never depends on whether the item happened to exist. **Response-shape exception: how to tell an absent item from a stored empty object.** With `allowEmpty=true`, an absent item answers `{}` **plus** `_meta["io.ainoflow/found"]: false` on the result. A stored empty object answers `{}` with **no `_meta` at all**, so the two are distinguishable. The marker sits outside `structuredContent` because every field inside it belongs to the stored document; a client that never reads `_meta` sees exactly what it saw before. **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "storage_json_get", "arguments": { "category": "config", "key": "app-settings" } } } ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{\"theme\":\"dark\",\"language\":\"en\"}" }], "structuredContent": { "theme": "dark", "language": "en" }, "isError": false } } ``` **Response (nothing stored, `allowEmpty: true`):** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{}" }], "structuredContent": {}, "isError": false, "_meta": { "io.ainoflow/found": false } } } ``` *** storage_json_delete [#storage_json_delete] Delete a JSON document by category and key. The delete is written against the exact version it read, so one that loses a race with another writer is refused with `CONFLICT` having deleted nothing; an address that already holds nothing answers `NOT_FOUND`. **Parameters:** | Name | Type | Required | Description | | ---------- | ------ | -------- | ----------------------------------------------------------- | | `category` | string | Yes | Logical grouping (namespace). At most 100 characters. | | `key` | string | Yes | Unique identifier within category. At most 1024 characters. | **A delete that loses a race is `CONFLICT`, and it deleted nothing.** The delete is written against the exact version it read, so a writer that changes the item in between makes it refuse rather than remove a document it never saw. The item is still stored as that writer left it, and the scope's stored-document allowance is unchanged — a refused delete frees no room. `NOT_FOUND` is the other answer, and it is not the same one: it means nothing live is at the address (never stored, already deleted, or expired), including an item that vanishes while your delete is in flight — the address is empty, which is what you asked for. **`CONFLICT` carries `retryable: false`, and on a delete that is a warning, not an impossibility.** `storage_json_upsert`, `storage_json_create` and `storage_json_patch` cannot succeed on a bare retry, because their payload was computed from a version that no longer exists. A delete carries no payload and re-reads the address itself, so repeating it unchanged *would* go through — and would remove whatever is there now, quite possibly the other writer's document. Read the key back, then delete again only if what you find is still what you meant to remove. **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "storage_json_delete", "arguments": { "category": "config", "key": "app-settings" } } } ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{\"key\":\"app-settings\"}" }], "isError": false } } ``` **Response (another writer landed while the delete was in flight):** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "CONFLICT: The item was modified by another writer while this delete was in flight. Re-read the item - the address may now hold another writer's document, or nothing at all - and delete again only if that is still what you intend. (config/app-settings)" }], "structuredContent": { "code": "CONFLICT", "message": "The item was modified by another writer while this delete was in flight. Re-read the item - the address may now hold another writer's document, or nothing at all - and delete again only if that is still what you intend.", "retryable": false, "category": "config", "key": "app-settings" }, "isError": true } } ``` *** storage_json_list_keys [#storage_json_list_keys] List a category's keys with their size and timestamps, never the data. Always ordered `createdAt` ascending — **sorting is REST-only** (see [Beyond the tools](#beyond-the-tools)). **Parameters:** | Name | Type | Required | Description | | ---------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | string | Yes | Logical grouping (namespace). At most 100 characters. | | `page` | integer | No | Page number (1-based, default: 1). Cannot be used with `offset`. | | `offset` | integer | No | Items to skip. Cannot be used with `page`. | | `limit` | integer | No | Items per page (0-5000, default: 100). `0` answers an empty page carrying the real `totalCount`. | | `prefix` | string | No | Filter keys by prefix (e.g. `recall/profile/`). A value that is not a string is **refused**, not ignored. | | `where` | object | No | Top-level field equality filter (e.g. `{"status":"done"}`); multiple fields combine with AND. The listing is unfiltered when this argument is **absent, and only when it is absent** — a value that is not an object is **refused**, not ignored. | `page` only reaches offsets that are multiples of `limit`. Use `offset` to resume a scan at an arbitrary position, or to change the batch size between calls. **A pagination argument that is not a whole number is refused, not ignored.** `page`, `offset` and `limit` are read as integers: `{"limit": "10"}` and `{"offset": 2.5}` both answer `-32602` and nothing is listed. Supplying both `page` and `offset` answers `-32602` too (`Cannot specify both 'page' and 'offset' parameters`). **A value outside its range is refused the same way, never adjusted.** `limit` below `0` or above `5000`, `page` below `1`, and `offset` below `0` each answer `-32602` — so a page that comes back short is short because the data ran out, not because the request was quietly rewritten. `limit: 0` is the one value outside `1-5000` that is accepted: it answers an empty page carrying the real `totalCount`. **A `where` that is not an object is refused with `-32602`, not dropped.** `{"where": "status=done"}` used to fail the object test, be discarded, and hand back every key in the category under the caller's own filter — a full page that reads as a filtered one. An **empty** object still means "no filter": that is naming no field, rather than naming one wrongly. The REST twin refuses the same mistake: `GET /api/v1/storage/json/{category}?where=status` — an entry that is not in `field:value` form — now answers `400`, where it used to skip the entry and answer `200` with the whole category. **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "storage_json_list_keys", "arguments": { "category": "config", "page": 1, "limit": 10 } } } ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{\"items\":[{\"category\":\"config\",\"key\":\"app-settings\",\"size\":45,\"createdAt\":\"2026-01-15T10:30:00Z\",\"updatedAt\":\"2026-01-15T14:20:00Z\",\"expiresAt\":null}],\"page\":1,\"limit\":10,\"totalCount\":1,\"totalPages\":1}" }], "isError": false } } ``` *** storage_json_get_metadata [#storage_json_get_metadata] Get metadata for a stored JSON item without retrieving the data itself. **Parameters:** | Name | Type | Required | Description | | ------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `category` | string | Yes | Logical grouping (namespace). At most 100 characters. | | `key` | string | Yes | Unique identifier within category. At most 1024 characters. | | `allowEmpty` | boolean | No | If true, an absent item answers a success carrying `{}` marked `_meta["io.ainoflow/found"] = false` instead of `NOT_FOUND`. `false` applies when the argument is **absent, and only when it is absent** — a value that is not a boolean is **refused**, never read as `false`. | **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "storage_json_get_metadata", "arguments": { "category": "config", "key": "app-settings" } } } ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{\"category\":\"config\",\"key\":\"app-settings\",\"createdAt\":\"2026-01-15T10:30:00Z\",\"updatedAt\":\"2026-01-15T14:20:00Z\",\"expiresAt\":null,\"version\":3,\"etag\":\"abc123\",\"size\":256,\"createdBy\":\"api_key_123\",\"updatedBy\":\"api_key_123\"}" }], "isError": false } } ``` Resources [#resources] `storage://json/{category}/{key}` [#storagejsoncategorykey] Access stored JSON data as a resource. * **MIME Type:** `application/json` * **URI Pattern:** `storage://json/{category}/{key}` **Example request:** ```json { "jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": { "uri": "storage://json/config/app-settings" } } ``` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "contents": [ { "uri": "storage://json/config/app-settings", "mimeType": "application/json", "text": "{\"theme\":\"dark\",\"language\":\"en\"}" } ] } } ``` `storage://json/{category}/{key}/meta` [#storagejsoncategorykeymeta] Access metadata for a stored JSON item as a resource. * **MIME Type:** `application/json` * **URI Pattern:** `storage://json/{category}/{key}/meta` **Response:** ```json { "jsonrpc": "2.0", "id": 1, "result": { "contents": [ { "uri": "storage://json/config/app-settings/meta", "mimeType": "application/json", "text": "{\"category\":\"config\",\"key\":\"app-settings\",\"createdAt\":\"2026-01-15T10:30:00Z\",\"updatedAt\":\"2026-01-15T14:20:00Z\",\"expiresAt\":null,\"version\":3,\"etag\":\"abc123\",\"size\":256}" } ] } } ``` **This changed. Data store resources now resolve.** Every `resources/read` on this surface used to fail to parse, whatever was stored: the URI advertised by `resources/list` was `storage://json/{category}/{key}` while the read parsed against `storage://json`, so no URI could satisfy both. One prefix now serves the advertisement and the read, so the URI you are handed is the URI that works. Send the whole prefix: `storage://config/app-settings` — without the `json/` segment — is a URI this surface does not serve and answers `-32001`. **How a resource read fails** | Situation | Answer | | -------------------------------------------------------------------- | -------------------------------------------------------------- | | `params` carries no `uri`, or a `uri` that is not a string | `-32602` (`Missing 'uri' parameter` / `Invalid params format`) | | A URI this surface does not serve, or a category/key holding nothing | `-32001` | | The backing store failed | `-32603` | `-32001` and `-32603` are now distinct: this surface answered `-32001` to *everything*, so an unreachable store was reported as "that item does not exist" — a client that trusted the code then dropped its local copy of a document that was still there. On the category-bound endpoint (`/mcp/v1/storage/json/{category}`) a URI naming a different category resolves to nothing as well. A **wrong-typed** `uri` is `-32602` too. It used to reach the outermost handler and arrive as `-32603` — "the server itself failed", for a request that never named a readable address. All six Ainoflow MCP surfaces that serve resources now answer `-32602` for a `uri` they cannot read. Beyond the tools [#beyond-the-tools] The tool set is deliberately smaller than the Data store REST surface, so a client is not handed a menu it has to scroll. These capabilities exist but no tool reaches them — reach them over REST on `https://api.ainoflow.io`, with the same API key. | Capability | REST | What it gives you | | -------------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Create-if-absent at a chosen key | `POST /api/v1/storage/json/{category}/{key}` | Write only when nothing is there, answering `409` if the key is taken. `storage_json_create` generates the key for you and `storage_json_upsert` overwrites, so neither reserves a key you name. | | Conditional read | `GET /api/v1/storage/json/{category}/{key}` | The same document as `storage_json_get`, but with ETag and `If-None-Match` handling, so a poller can be told `304` instead of re-reading a body it already has. | | Sorted listing | `GET /api/v1/storage/json/{category}` | `sortBy` (`key`, `createdAt`, `updatedAt`, `size`) with `sortOrder` (`asc`, `desc`). `storage_json_list_keys` takes neither and always answers `createdAt` ascending; it filters by `prefix` and `where` exactly as this endpoint does. | `storage_guide` returns this same list as data, each entry with its method, path and description. Failures [#failures] Two different things can go wrong, and they arrive differently. A refusal [#a-refusal] Your call reached the handler and was declined. It answers `200` with a tool result carrying `isError: true` — never a JSON-RPC error — and `structuredContent` is machine-readable: `code`, `message`, `retryable`, and the address that failed (`category`, and `key` when the call named one): ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "ITEM_TOO_LARGE: JSON data is 70KB and exceeds the 64KB size limit (config/profile)" }], "structuredContent": { "code": "ITEM_TOO_LARGE", "message": "JSON data is 70KB and exceeds the 64KB size limit", "retryable": false, "category": "config", "key": "profile", "currentBytes": 71680, "maximumBytes": 65536 }, "isError": true } } ``` | `code` | When | Extra fields | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `ITEM_TOO_LARGE` | The payload exceeds the size limit in force for your key | `currentBytes`, `maximumBytes` — always, on every size failure | | `SCHEMA_VALIDATION_FAILED` | Not valid JSON, valid JSON of a shape this tool will not store, an address outside its length bounds (`category` 1–100, `key` 1–1024), or an `expiresAt` that is already past | `invalidPath`, when the refusal has a position in the document to point at | | `CONFLICT` | The write collided with the current state: the key is already taken, or **any** write lost a race with a concurrent writer and changed nothing. On `storage_json_patch` that means its merge was discarded rather than applied to a version it was not computed from; on `storage_json_upsert`, `storage_json_create` and `storage_json_delete` it is the same race, which those tools could always lose and previously reported as `-32603`. On a delete nothing was removed and no allowance was credited back — see [storage\_json\_delete](#storage_json_delete) | — | | `PRECONDITION_FAILED` | An `ifMatch` or `expectedVersion` precondition **you sent** did not hold — on `storage_json_upsert` or `storage_json_patch`, including a conditional call at an address that holds nothing, which is refused rather than creating | — | | `NOT_FOUND` | Nothing exists at that address | — | | `RATE_LIMITED` | The scope already holds as many stored JSON documents as its allowance permits, on a call that would add one. Nothing is stored | — | **Each surface declares the codes it can actually produce, and Data store is the one that reaches all six.** The vocabulary is shared across every Ainoflow MCP surface, but the list above is Data store's own: `storage_guide` returns it as `errors.codes`, and a code a surface does not declare never arrives from it. It used to be one fixed list of six printed identically everywhere — an upper bound wearing the words of a description — so the set is now something to branch over rather than defend against. Check another area's guide before assuming it speaks the same subset. `retryable` is `true` only for `RATE_LIMITED`; every other code needs a changed request — a smaller payload, a fresh ETag, a different key, a merge recomputed from a fresh read. `storage_json_delete` is the one exception worth knowing: its `retryable: false` is advice rather than arithmetic, because an unchanged delete *would* go through and would remove whatever the other writer has since put there. On this surface, though, read `retryable: true` as *this call can start working without being changed*, not as *retry it in a moment*: Data store's allowance is a stored count that does not reset with the billing cycle, so what makes the retry succeed is deleting or expiring documents. Retrying on a timer against a full scope just repeats the refusal. The two byte counts travel on **every** size refusal, so resize from `currentBytes` and `maximumBytes` rather than parsing the message. `invalidPath` is conditional by contrast: a rejected JSON document has a position to name, while a category that is too long does not — which is what an address refusal looks like: ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "SCHEMA_VALIDATION_FAILED: Key must be between 1 and 1024 characters (config/kkkkkkkk…)" }], "structuredContent": { "code": "SCHEMA_VALIDATION_FAILED", "message": "Key must be between 1 and 1024 characters", "retryable": false, "category": "config", "key": "kkkkkkkk…" }, "isError": true } } ``` A protocol or server error [#a-protocol-or-server-error] Your call never ran, or the service failed. It arrives as a JSON-RPC error: | Code | Meaning | | -------- | --------------------------------------------------------------------------------------------- | | `-32600` | Not a valid JSON-RPC 2.0 request, or a `tools/call` without an `id` | | `-32601` | Unknown method, unknown tool, or a capability this surface does not offer | | `-32602` | An argument is absent, blank, or of the wrong JSON type — your call never reached the handler | | `-32603` | The server itself failed. Not your request; retrying it unchanged is reasonable | | `-32001` | Unauthorized, or a resource URI that resolves to nothing | **Branch on `code`, never on the message text.** Messages are written for humans and may be reworded; `code` is part of the contract. **A wrong argument type is your error, not ours — for every argument, without exception.** Arguments are type-checked before anything is stored or read, and a mismatch returns `-32602` with a message naming the argument — never `-32603` and never a tool result. `{"key": 123}` returns `Argument 'key' must be a string`; `{"expectedVersion": "5"}` returns `Argument 'expectedVersion' must be an integer number`; an `expiresAt` that is not an ISO 8601 date-time string returns `Argument 'expiresAt' must be an ISO 8601 date-time string`. Pagination is no exception: `{"page": "2"}` and `{"limit": 1.5}` are refused the same way rather than dropped. Nor are the last three that used to be read leniently — `ifMatch` (string), `where` (object) and `allowEmpty` (boolean) are each refused when mistyped. **A default applies when its argument is *absent*, and only when it is absent.** JSON `null` counts as absent; a value that was sent and could not be read is never replaced by a default. A value of the right type but outside its range is refused as well, not clamped: `{"limit": 10000}` and `{"page": 0}` each answer `-32602` rather than coming back as `5000` rows and page `1`. This governs the arguments each tool **declares**. An argument a tool declares no property for — `sortBy` on `storage_json_list_keys`, say — is not read at all and has no effect, whatever its type. **This changed.** A refusal used to come back as JSON-RPC `-32603` — the same code as a crashed server — so "too large" and "we fell over" were indistinguishable to a client. Business failures now answer `200` with `isError: true` and a code; `-32603` means the server, and only the server. A wrong argument type is still `-32602`, unchanged.