# LeanTools documentation > Complete documentation in one file. Generated from https://leantools.ai/doc on 2026-10-23. MCP endpoint: /mcp. REST base: /api/v1. --- # LeanTools documentation LeanTools documentation LeanTools hosts native tools and connectors for AI agents: LeanConnect (web search, email and connectors to third-party services), LeanBrowser, LeanModel (text, image, speech, music, video, avatar and realtime audio models), LeanMachine sandboxes, LeanVision camera analysis (beta), LeanMemory (warehouse, vectors, events and storage) and LeanData datasets. Your agent reaches all of them on one host with one API key: web search, email, data and connectors as MCP tools, and every service over a plain REST API. Every call is metered against prepaid credits. [Quickstart](https://leantools.ai/doc/quickstart) [MCP server](https://leantools.ai/doc/mcp) [REST reference](https://leantools.ai/doc/api) ## Pick your path [**Quickstart**Create a workspace, connect Claude, Codex or any MCP client, and make a first billed call in five minutes.](https://leantools.ai/doc/quickstart) [**MCP server**Streamable HTTP, JSON-RPC 2.0, bearer auth. The tool catalogue, call semantics, errors and limits.](https://leantools.ai/doc/mcp) [**Tools API and native tools**The same catalogue over plain HTTP, plus the native web_search.search and email.send tools.](https://leantools.ai/doc/tools) [**Connectors and approvals**Act inside third-party services with stored credentials, hold writes for a human, poll the decision.](https://leantools.ai/doc/connectors) [**Credits and billing**How prepaid credits, meters, caps, budgets and auto-reload work, and what each error code means.](https://leantools.ai/doc/credits) [**Workspace console**Keys, members and roles, tool and connector settings, billing, activity and the approval queue.](https://leantools.ai/doc/console) ## How it fits together A **workspace** is the unit of billing and access. It has members with roles, API keys, a prepaid credit balance and a set of enabled tools, connectors and datasets. Every request an agent makes carries an API key; LeanTools resolves the workspace, checks that the key may reach the tool, that the tool is switched on, that no cap or budget is exhausted and that credits remain, then runs the call and records one metering row. 1. **Connect once**Add the MCP endpoint `/mcp` and a key to your harness. Web search, email, datasets and connector actions you enable later are discovered automatically through `tools/list`; the other services take the same key over REST. 2. **Switch capabilities on**In the console, enable tools, link connector credentials, subscribe to datasets, and decide per function what agents may call, what needs approval and what stays off. 3. **Pay per call**Each call draws down credits at a published unit price. Keys can carry spend caps, tools monthly budgets, connectors rate limits and daily caps. Auto-reload keeps agents running. ## Two ways in | Surface | Best for | Where | | --- | --- | --- | | MCP server | Claude, Codex, Cursor and any MCP-capable agent framework. Tools are discovered, described and called over one endpoint. | [MCP server](https://leantools.ai/doc/mcp) | | Tools API | Agents and scripts that do not speak MCP but want the same catalogue with real HTTP status codes. | [Tools API](https://leantools.ai/doc/tools) | | REST API | Direct, typed access to each service: web search, LeanBrowser (HTTP and WebSocket), LeanModel (OpenAI-compatible, plus avatar streams over WebRTC and realtime audio over WebSocket), email, LeanMachine, LeanVision, LeanMemory (data stores and storage). | [Reference](https://leantools.ai/doc/api) | > The interactive OpenAPI description of every REST route, with schemas and a try-it panel, lives at [/swagger](https://leantools.ai/swagger) on this host. --- # Quickstart Getting started From an empty browser tab to an agent that searches the web through LeanTools, in five steps. No servers to run and no card required: every new workspace starts with starter credit. 1. **Create a workspace** Go to [/signup](https://leantools.ai/signup), enter your email, a password and a workspace name. The workspace is created with starter credit, an MCP endpoint and a **default API key**. The key is shown exactly once on the onboarding page: copy it right away. Lost keys cannot be recovered, only replaced (Settings → API keys → Rotate). 2. **Connect your harness** Every harness talks to the same endpoint, `/mcp`, with the key as a bearer token. ``` claude mcp add --transport http leantools https://YOUR-HOST/mcp \ --header "Authorization: Bearer lt-YOUR-KEY" claude mcp list # leantools connected · N tools ``` ``` # ~/.codex/config.toml [mcp_servers.leantools] url = "https://YOUR-HOST/mcp" http_headers = { Authorization = "Bearer lt-YOUR-KEY" } ``` ``` { "mcpServers": { "leantools": { "type": "http", "url": "https://YOUR-HOST/mcp", "headers": { "Authorization": "Bearer lt-YOUR-KEY" } } } } ``` ``` curl -s https://YOUR-HOST/mcp \ -H "Authorization: Bearer lt-YOUR-KEY" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` 3. **Make the first call** Ask your agent to search the web, or send a test call from the onboarding page. The native `web_search.search` tool is enabled on every new workspace. tools/call · web_search.search ``` { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "web_search.search", "arguments": { "query": "Model Context Protocol", "resultCount": 5 } } } ``` result (the text is the JSON the REST route would answer, plus usage) ``` { "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "{\"query\":\"Model Context Protocol\",\"totalResultCount\":5,\"items\":[{\"position\":\"1\",\"title\":\"…\",\"url\":\"https://…\",\"description\":\"…\"}],\"usage\":{\"credits\":0.005,\"balance\":9.995}}" } ], "isError": false } } ``` The console's Home page switches from *Get started* to *Dashboard* the moment the first successful call lands. 4. **Switch on what you need** Open [Tools & Connectors](https://leantools.ai/app#/tools). Toggle native tools on or off, set a monthly budget per tool, link a connector with your own credentials, decide which of its functions agents may call and whether writes need approval, and subscribe to datasets. Nothing here requires redeploying your agent: the next `tools/list` reflects the change. 5. **Create a key per agent** Under [Settings → API keys](https://leantools.ai/app#/settings/keys), create one key per agent, scoped to the tools it needs, with a monthly spend cap and an optional expiry. Keys are the unit of attribution in the activity log and of spend in the dashboard. ## What happens on every call Requests are admitted in a fixed order, and each refusal has a stable code you can branch on: | Check | Refusal | | --- | --- | | Key present, known, active, not expired | `401 authentication_error`, `401 key_expired` | | Workspace active | `403 account_suspended`, `403 account_closed` | | Tool switched on in the workspace | `403 tool_disabled` | | Key scoped to the tool | `403 service_not_allowed` | | Tool budget and key cap not exhausted | `402 tool_budget_reached`, `402 key_cap_reached` | | Credits remain (unless the meter is free) | `402 insufficient_credits` | | Connector guardrails (function exposed, key allowed, rate and daily caps, approval) | `403 action_disabled`, `403 key_not_allowed`, `429 rate_limited`, `429 daily_cap_reached`, `202 approval_pending` | Failed and refused calls cost nothing. See [Credits and billing](https://leantools.ai/doc/credits) and [Errors](https://leantools.ai/doc/errors). ## Next steps [**MCP server**Protocol details, the tool catalogue and result shapes.](https://leantools.ai/doc/mcp) [**Connectors and approvals**Link a CRM or inbox, hold writes for review.](https://leantools.ai/doc/connectors) [**REST reference**Every service over plain HTTP, including LeanModel's OpenAI-compatible endpoints, avatar streams and realtime audio.](https://leantools.ai/doc/api) [**Console guide**Members, roles, keys, billing and activity.](https://leantools.ai/doc/console) --- # Authentication Getting started Every call to the MCP server, the tools API and the REST API authenticates with one credential: a workspace API key, sent as a bearer token. ``` Authorization: Bearer lt-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ## API keys - **Format.** `lt-` followed by 32 URL-safe base64 characters (35 characters in total). Keys issued before the LeanTools rename start with `gsk-` and remain valid. - **Where they come from.** Every new workspace gets a `default-key` at sign-up. Owners and Admins create more under Settings → API keys; Developers can create keys for themselves. Operators of the host can also issue keys from the admin panel. - **What they identify.** Exactly one workspace, which is billed for every call made with the key, and the member who created it, who is shown next to each call in the activity log. - **Storage.** Only the SHA-256 hash is stored. The plaintext is shown once at creation (and at rotation) and cannot be retrieved later; the console shows the prefix, `••••••••` and the last four characters. ### Scope, cap and expiry A key carries three limits, all set in the console: | Limit | Effect | Refusal | | --- | --- | --- | | Scope | "Everything enabled", or a list of tools the key may reach (`Search`, `Browser`, the seven LeanModel catalogues from `TextAssistant` to `RealtimeAssistant`, `Email`, `Machine`, `Data`, `Connectors`, …). Connectors can additionally restrict which keys may call them. | `403 service_not_allowed`, `403 key_not_allowed` | | Monthly cap | Credits the key may spend in a calendar month (UTC). The dashboard shows spend against cap per key. | `402 key_cap_reached` | | Expiry | A date after which the key is refused. Rotate it to issue a fresh key with the same settings and revoke the old one at once. | `401 key_expired` | ## Admission order Each call is checked in this order: credential → workspace status → tool enabled → key scope → tool budget and key cap → credits → tool-specific guardrails. Credentials and workspace policy are cached for 60 seconds (`LeanTools.Credits.CredentialCacheSeconds`); changes made in the console invalidate the cache for that workspace at once, so a revoked key or a disabled tool is refused immediately. ## Where the key goes - **Header** for everything: `Authorization: Bearer `. - **Query string** only where a header is impossible: the LeanBrowser WebSocket upgrades (`?token=…`), the LeanVision live feed and the LeanModel realtime audio socket. Every other endpoint rejects a key in the query so keys never end up in access logs or browser history. - **WebSocket subprotocol** on the realtime audio socket only, for browser clients that can set neither headers nor a safe query string: offer `openai-insecure-api-key.` as a subprotocol, as OpenAI's browser clients do; LeanTools accepts it and echoes `realtime` as the agreed subprotocol when it was offered too (see the [reference](https://leantools.ai/doc/api#realtime)). - **LeanMemory's high-throughput columnar data channel** (gRPC; see the [reference](https://leantools.ai/doc/api#leanmemory)) takes the same bearer in the `authorization` gRPC metadata. ## Error envelope A refused call answers a JSON envelope with a stable `errorCode`: ``` { "isSuccess": false, "errorMessage": "This API key has expired. Create a new key in the console.", "errorCode": "key_expired" } ``` The OpenAI-compatible endpoints use the OpenAI envelope (`{ "error": { "message", "type", "code" } }`); the MCP server answers JSON-RPC errors for protocol failures and `isError: true` results for tool failures. The full list is on the [Errors](https://leantools.ai/doc/errors) page. ## Console sign-in The workspace console at [/app](https://leantools.ai/app) and the public site use a separate, per-person sign-in (email and password) that issues a short-lived session token in a cookie. API keys never sign in to the console, and console sessions never authenticate API calls. Invite teammates from Settings → Members; each accepts with their own account. > Treat API keys as secrets. Put them in environment variables or your harness's secret store, never in prompts, repositories or client-side code. If a key leaks, revoke it in the console: every agent using it is refused within a minute. --- # Credits and billing Getting started Tools are paid for in credits. A workspace holds a prepaid balance, every call draws it down at a published unit price, and the console shows where each credit went. ## Buckets A workspace holds three blocks of credits, consumed in this order: | Bucket | Filled by | Expires | | --- | --- | --- | | Trial | Starter credit at sign-up, or a trial package an operator applies | After the package's trial period, or never | | Subscription | Reloaded every cycle by a subscription package | At the next reload (no rollover) | | Prepaid | Reloads from the console (card, or invoice above the threshold) and auto-reload | Never | One credit has a fixed dollar value (`LeanTools.Billing.CreditUsdValue`, shown in the console and returned as `creditUsdValue` by the console APIs). Prices on the public site are that value times the credit rate. ## Meters Each service exposes one or more meters; the workspace's package defines the credits per unit of every meter, falling back to the service default. A refused or failed call is never charged. | Service | Meter | Unit | Notes | | --- | --- | --- | --- | | Web Search | `request` | per query | Also the native MCP tool `web_search.search` | | LeanBrowser | `request`, `session_minute` | per HTTP request, per active WebSocket minute | | | LeanModel text | `input_tokens`, `output_tokens`, `audio_minute` | per 1,000,000 tokens; per minute of audio transcribed or heard | Priced per tier; a tier the package does not price is `403 tier_not_in_package`. A chat request carrying audio parts adds an `audio_minute` line to its tokens | | LeanModel image | `image` | per image generated or edited | Priced per tier; billed per image produced | | LeanModel speech | `tts_character` | per 1,000 characters | Priced per tier | | LeanModel music | `music_second` | per second of music produced | Priced per tier; the seconds asked for when the model reports no length | | LeanModel video | `video_second` | per second of video produced | Priced per tier | | LeanModel avatar | `stream_minute`, `management` | per connected minute; free | Priced per tier; accrued every minute from creation until the stream closes | | LeanModel realtime audio | `audio_input_tokens`, `audio_output_tokens`, `input_tokens`, `output_tokens`, `cached_input_tokens` | per 1,000,000 tokens, as the model reports them per response | Priced per tier, all five together; committed as the conversation goes, and the socket closes with code 1008 when credits run out | | Email | `email` | per recipient (to + cc + bcc, at most 50) | Also the native MCP tool `email.send` | | LeanMachine | `active_minute` | per minute a machine runs | Machines are stopped when credits run out | | LeanVision | `stream_minute` | priced per stream-hour | Accrued every minute until disposal | | LeanMemory: warehouse, vectors, events | `storage_gb_month`, `compute_second`, `ingest_gb`, `egress_gb`, `management` | GB-month, per 1,000 s, per GiB, per GiB, free | Attributed by the database named | | LeanMemory storage | `storage_gb_month` | per GB-month held | No ingress, egress or request charge | | LeanData | `call`, `row`, `month` | per query, per 1,000 rows, per subscription month | Priced per dataset (qualifier = dataset key) | | LeanConnect connectors | `call` | per completed action | Priced per connector (qualifier = connector key); refused, failed and timed-out actions cost 0 | Only measurements and upstream attribution are stored per call (zero data retention): no request or response payload is kept. Per-request rows are archived monthly; aggregates are kept indefinitely. ## Caps, budgets and guardrails - **Key cap.** A monthly spend cap per API key: `402 key_cap_reached` once reached, reset on the first of the month (UTC). - **Tool budget.** A monthly credit budget per native tool with "pause at budget": `402 tool_budget_reached`. Without pausing, the budget only drives the warning in the console. - **Connector guardrails.** Per connector: calls per minute (`429 rate_limited`), calls per day (`429 daily_cap_reached`), the keys allowed to use it, the functions exposed, and whether write actions are held for [approval](https://leantools.ai/doc/connectors#approvals). - **In-flight reserve.** When several calls of one workspace are in flight and the remaining balance no longer covers roughly one credit per running call, new calls are refused with `402 insufficient_credits` until they finish or the balance grows (`LeanTools.Credits.InFlightReserveCredits`). ## Reloading credits Owners and Billing members reload from [Settings → Billing & credits](https://leantools.ai/app#/settings/billing): - **Any amount** between the host's minimum and maximum (`LeanTools.Billing.MinReloadUsd`, `MaxReloadUsd`), paid with a saved card. The quote shows the credits added and the balance after. A retried or double-submitted payment is charged once. - **Auto-reload.** When the balance falls under a threshold, the default card is charged a fixed amount, up to a monthly limit. The console banner and the low-balance email warn before credits run out. - **Invoice.** Amounts of `LeanTools.Billing.InvoiceThresholdUsd` or more can be requested as an invoice; credits are granted when the host confirms the bank transfer (an operator marks the order paid in the admin panel). - **Receipts.** Every paid order has a numbered receipt (HTML) and the credit history exports as CSV. > The payment provider is pluggable (`LeanTools.Billing.PaymentProvider`). A host running the sandbox provider shows a "sandbox" badge in the console: card numbers ending in `4242` succeed, numbers ending in `0002` are declined, and nothing is charged. ## Account endpoints (GET)/api/v1/accountbalances, status, effective package and the rates that apply to the caller ``` { "accountGuid": "…", "name": "Acme", "status": "Active", "trialUnlimited": false, "packageCode": "SUB-2K", "packageName": "Subscription 2K", "packageMode": "Subscription", "available": 2874.25, "trial": 0, "subscription": 2874.25, "prepaid": 0, "overdraft": 0, "nextReloadTime": "2026-10-01T00:00:00+00:00", "trialExpiry": null, "services": [ { "key": "TextAssistant", "name": "LeanTools Text Gen", "billingMode": "PerRequest", "meters": [ { "meterKey": "input_tokens", "unit": "Token", "unitScale": 1000000, "isFree": false, "requiresTierRate": true, "creditPerUnit": null } ], "tiers": [ { "id": "leanmodel-flash", "displayName": "LeanModel Flash", "kind": "Text", "catalog": "LeanTools Text Gen", "isDefault": true, "isStandard": true, "operations": [ "chat.completions", "audio.transcriptions" ], "rates": { "input_tokens": { "creditPerUnit": 1.0, "unitScale": 1000000 }, "output_tokens": { "creditPerUnit": 4.0, "unitScale": 1000000 } } } ] } ] } ``` (GET)/api/v1/account/usage?from=&to=&groupBy=service|meter|dayrequests and credits for the range (default last 30 days) (GET)/api/v1/account/transactionsthe latest 200 credit movements other than usage (GET)/api/v1/packagesthe public package catalogue (GET)/api/v1/servicesevery service with its meters (no authentication) (GET)/api/v1/models/tiers?service=the LeanModel tiers the caller may name in `model`, with catalogue, capabilities, operations and rates; `?service=` narrows to one catalogue ### Usage feedback on responses Chat completion responses (and the final streaming chunk) carry the standard `usage` object extended with `credits_charged`, `credits_remaining` and `cost_usd`. Image, transcription, music and video responses carry `usage` with `credits_charged` and `cost_usd`; a speech response is the audio itself, so its usage rides in the `X-LeanTools-Usage` header; an avatar stream reports its minutes and credits when it is closed, and realtime audio is billed per `response.done` as the conversation goes. Data, connector and tool calls answer a `usage` block with `credits` and `balance`. MCP results embed the same block in the result text. --- # MCP server Agents LeanTools speaks the Model Context Protocol over Streamable HTTP. One endpoint, `/mcp`, exposes the tools the workspace has switched on: the native web search and email tools, LeanData datasets and LeanConnect connector actions. Each `tools/call` is billed like the REST call it wraps and admitted by the same rules. LeanBrowser, LeanModel, LeanMachine, LeanVision and LeanMemory are not MCP tools; they take the same key over REST and WebSocket (see the [reference](https://leantools.ai/doc/api)). ## Transport - `POST /mcp` with one JSON-RPC 2.0 message per request. Stateless: no SSE stream, no session id, no batching. - Authenticate with the workspace API key as a bearer: `Authorization: Bearer lt-…`. - Protocol versions `2025-06-18`, `2025-03-26` and `2024-11-05` are negotiated; an unknown version is answered with the latest. - `Accept: text/event-stream` is tolerated but the answer is always a single JSON document. Notifications (no `id`) are acknowledged with `202` and no body. `GET` and `DELETE /mcp` answer `405`. Bodies over `LeanTools.Mcp.MaxBodyBytes` (1 MiB) are `413`. ## Initialize and ping request ``` curl -s https://YOUR-HOST/mcp -H "Authorization: Bearer lt-..." -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "my-agent", "version": "1.0" } } }' ``` response ``` { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": { "listChanged": false } }, "serverInfo": { "name": "LeanTools", "version": "1.0.0" }, "instructions": "LeanTools tools server. Every tool call is billed in credits ..." } } ``` `ping` answers `{}`. ## The catalogue: tools/list The list is computed per workspace and per key: it holds only the tools the workspace has enabled and the key is scoped to. Names use dots; the underscore form (`data_query`) is accepted on calls for clients that cannot send dots. | Tool | Kind | Billing | | --- | --- | --- | | `web_search.search` | Native | One Web Search `request` per query | | `email.send` | Native | One Email `email` per recipient (to + cc + bcc) | | `data.list`, `data.describe` | Data | Free | | `data.query` | Data | One `call` plus `row` per 1,000 rows, at the dataset's rate | | `data.documents.list`, `data.documents.get` | Data | One `call` plus one `row` per summary or document | | `data.subscribe`, `data.unsubscribe` | Data | The first month at subscribe; nothing to cancel | | `connector..` | Connector | One connector `call` at the connector's rate, only when the action completed | tools/list (excerpt) ``` { "jsonrpc": "2.0", "id": 2, "result": { "tools": [ { "name": "web_search.search", "title": "Web Search: search", "description": "Searches the web through the platform's search engine and returns ranked results (title, url, description). Billed 0.005 credits per query.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string" }, "resultCount": { "type": "integer", "minimum": 1, "maximum": 50 }, "language": { "type": "string" } }, "required": ["query"], "additionalProperties": false }, "annotations": { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true }, "kind": "NativeSearch" }, { "name": "data.query", "title": "Query a dataset", "description": "Runs a filtered, ordered, paged query ... Billed per call plus per 1,000 rows returned ...", "inputSchema": { "type": "object", "properties": { "dataset": { "type": "string", "enum": ["leantools-usage.service-usage"] }, "filters": { "type": "array" }, "take": { "type": "integer" } }, "required": ["dataset"] }, "annotations": { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, "kind": "DataQuery" }, { "name": "connector.twilio.sms.send", "title": "Twilio: Send SMS", "description": "... Billed 1 credits per completed call. Needs a Twilio credential.", "inputSchema": { "type": "object", "properties": { "input": { "type": "object" }, "credentialId": { "type": "string" }, "credential": { "type": "object" } } }, "annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": true }, "kind": "ConnectorAction", "itemKey": "Twilio", "actionKey": "sms.send" } ] } } ``` Annotations follow the MCP specification so harnesses can ask before destructive calls. Connector actions a workspace has disabled in the console are absent from the list; a connector's default credential is used when a call names none. ## Calling a tool: tools/call request ``` { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "data.query", "arguments": { "dataset": "leantools-usage.service-usage", "take": 50, "orderBy": "day", "descending": true } } } ``` response ``` { "jsonrpc": "2.0", "id": 3, "result": { "content": [ { "type": "text", "text": "{\"dataset\":\"leantools-usage.service-usage\",\"columns\":[...],\"rows\":[[...]],\"nextPageToken\":\"NTA=\",\"totalCount\":null,\"truncated\":false,\"usage\":{\"credits\":0.105,\"balance\":1868.05,\"rows\":50}}" } ], "isError": false } } ``` The text is the JSON the REST route would answer plus its `usage` block. Results over `LeanTools.Mcp.MaxResultChars` (200,000 characters) drop trailing rows and say so (`truncated: true`, `rowsShown`, `rowsFetched`) while keeping `nextPageToken`; the rows fetched were billed, so ask for smaller pages. ### Refusals and failures A refused or failed call is a *result* with `isError: true` whose text carries the same code and status the REST API would answer, so the agent can read the reason and react: ``` { "jsonrpc": "2.0", "id": 4, "result": { "content": [ { "type": "text", "text": "{\"error\":{\"code\":\"insufficient_credits\",\"message\":\"The account has no credits left. Top up or renew the subscription to continue.\",\"status\":402}}" } ], "isError": true } } ``` A connector write held for approval is such a result too: `approval_pending`, status `202`, and `error.details` with `approvalId`, `expiresUtc` and `pollUrl` the agent can poll (see [approvals](https://leantools.ai/doc/connectors#approvals)). ### Protocol errors | Situation | HTTP | JSON-RPC error | | --- | --- | --- | | Missing or invalid key | 401 (403 for a suspended workspace), with `WWW-Authenticate: Bearer realm="LeanTools", error="invalid_token"` | `-32001`, `data.code = authentication_error` | | Unknown tool | 200 | `-32602` | | Unknown method | 200 | `-32601` | | Malformed JSON | 200 | `-32700` | | Batch (array) or message without `method` | 200 | `-32600` | ## Native tools ### web_search.search - `query`requiredWhat to search for. - `resultCount`integer 1–50Results per page; the engine default when omitted. - `language`stringTwo-letter language code, e.g. `en`. Returns `{ query, totalResultCount, items[{ position, title, url, description }] }`. The search engine is chosen by the host; the caller never picks it. ### email.send - `to`requiredRecipient address. - `subject`requiredSubject line. - `body`requiredPlain text, or HTML when `isBodyHtml` is true. - `from`, `fromName`, `toName`, `cc`, `bcc`, `isBodyHtml`Optional. Omit `from` to send from the platform address. Returns `{ isSuccess, provider }`. Because sending mail is a write, harnesses that honour annotations ask before calling it (`readOnlyHint: false`). ## Bring your own server Any remote MCP server can be registered as a connector (the *Generic MCP* connector type): give it a URL and a credential, and its tools appear in the catalogue as `connector..`, metered like every other connector call and subject to the same guardrails. Stdio transports are not accepted by a hosted gateway (`403 transport_not_allowed`). ## Limits **`LeanTools.Mcp.MaxBodyBytes`** Request body limit, 1 MiB by default (`413`). **`LeanTools.Mcp.MaxResultChars`** Result text limit, 200,000 characters by default; longer results are truncated with a page token. **Per-connector rate limit and daily cap** Set in the console; `429 rate_limited` / `429 daily_cap_reached`. **In-flight reserve** See [Credits and billing](https://leantools.ai/doc/credits). --- # Tools API and native tools Agents Agents and scripts that do not speak MCP get the identical catalogue over plain HTTP, with real status codes. The list, the input schemas and the billing are the same as on the MCP server. ## Endpoints (GET)/api/v1/toolsthe catalogue for this key: the same objects as MCP `tools/list` (POST)/api/v1/tools/{name}call a tool; the body is the tool's `arguments` object curl ``` curl -s https://YOUR-HOST/api/v1/tools/web_search.search \ -H "Authorization: Bearer lt-..." -H "Content-Type: application/json" \ -d '{ "query": "LeanTools MCP", "resultCount": 5 }' ``` 200 ``` { "query": "LeanTools MCP", "totalResultCount": 5, "items": [ { "position": "1", "title": "…", "url": "https://…", "description": "…" } ], "usage": { "credits": 0.005, "balance": 9.995 } } ``` A refusal answers the standard envelope with the HTTP status of the reason (`402 insufficient_credits`, `403 tool_disabled`, `202 approval_pending`, …); see [Errors](https://leantools.ai/doc/errors). Tool names accept the underscore form (`web_search_search`) as on MCP. ## Native tools Native tools are services LeanTools runs itself. Two of them, web search and email (both part of LeanConnect), are exposed as MCP functions; the others (LeanBrowser, the seven LeanModel catalogues, LeanMachine, LeanVision, LeanMemory) have richer REST, WebRTC and WebSocket surfaces described in the [reference](https://leantools.ai/doc/api). Every native tool can be switched off per workspace, given a monthly budget, and excluded from a key's scope. | Tool | Service key | On MCP | Priced per | | --- | --- | --- | --- | | Web Search | `Search` | `web_search.search` | query | | LeanBrowser | `Browser` | REST and WebSocket only | request, active session minute | | LeanModel text | `TextAssistant` | REST (OpenAI-compatible) | 1M tokens and audio minute, per tier | | LeanModel image | `ImageAssistant` | REST (OpenAI-compatible) | image, per tier | | LeanModel speech | `SpeechAssistant` | REST (OpenAI-compatible) | 1,000 characters, per tier | | LeanModel music | `MusicAssistant` | REST | second produced, per tier | | LeanModel video | `VideoAssistant` | REST | second produced, per tier | | LeanModel avatar | `AvatarAssistant` | REST and WebRTC | connected minute, per tier | | LeanModel realtime audio | `RealtimeAssistant` | WebSocket (OpenAI Realtime events) | 1M audio, text and cached tokens, per tier | | Embeddings | `Embeddings` | REST (OpenAI-compatible) | 1M input tokens | | Email | `Email` | `email.send` | recipient | | LeanMachine | `Machine` | REST only | active minute | | LeanVision (beta) | `TrueSight` | REST and WebSocket only | stream-hour | | LeanMemory: warehouse, vectors, events | `DataWarehouse`, `VectorStore`, `EventStore` | REST and the columnar data channel (gRPC) | GB-month, compute, ingest, egress | | LeanMemory storage | `Storage` | REST only | GB-month | | LeanData | `Data` | `data.*` | call, 1,000 rows, subscription month | | LeanConnect connectors | `Connectors` | `connector.*` | completed call, per connector | ## Tool settings in the console Under Tools & Connectors, each native tool has a configure drawer: - **Enabled.** Off means `403 tool_disabled` for every key, and the tool disappears from `tools/list`. - **Monthly budget.** Credits (shown in dollars) the workspace intends to spend on the tool per month. With *pause at budget* on, calls beyond it are refused with `402 tool_budget_reached`; off, the budget only drives the dashboard warning. - **Settings.** Tool-specific defaults (for example the default result count of web search), stored as JSON and applied server-side. Changes apply within the credential cache window (60 seconds at most; immediately for the workspace that made the change). --- # Connectors and approvals Agents LeanConnect connectors let agents act inside the services your team already uses, with credentials the workspace stores and the model never sees. One completed action is one `call` at the connector's rate; refused, failed and timed-out actions cost nothing. The [connector directory](https://leantools.ai/connectors) lists every connector by category with its actions; `GET /api/v1/connectors` answers the ones switched on for your workspace. ## Endpoints (GET)/api/v1/connectorsenabled connectors, their actions, credential types and your price per call (GET)/api/v1/connectors/credential-typescredential types with their properties (secrets flagged) (GET)/api/v1/connectors/credentialsyour stored credentials (never the secrets) (POST)/api/v1/connectors/credentials`{ name, credentialTypeKey, data }` → 201 credential (DELETE)/api/v1/connectors/credentials/{id}`{ ok, id }` (GET)/api/v1/connectors/{key}actions with `inputSchema` and `outputHint`, credential requirement (POST)/api/v1/connectors/{key}/actions/{action}`{ credentialId | credential, input }` → `{ output, usage }` (GET)/api/v1/connectors/approvals/{id}status of a held write action, and its output once approved ## List and describe `GET /api/v1/connectors` answers `[{ key, name, description, category, version, credentialTypes[], actions[{ key, name, description, isReadOnly, isDestructive }], pricing { perCall } }]`. `GET /api/v1/connectors/{key}` adds each action's `inputSchema` (JSON Schema of the `input` object) and `outputHint`, plus `credential { required, types[] }`. Actions the workspace disabled in the console are not listed. ## Credentials Credentials belong to the workspace. Link them in the console (Tools & Connectors → connector → Credentials) or through the API: ``` curl -s -X POST https://YOUR-HOST/api/v1/connectors/credentials \ -H "Authorization: Bearer lt-..." -H "Content-Type: application/json" \ -d '{ "name": "Twilio prod", "credentialTypeKey": "Twilio", "data": { "AccountSid": "AC...", "AuthToken": "..." } }' ``` 201 ``` { "id": "7c1f0a...", "name": "Twilio prod", "credentialTypeKey": "Twilio", "status": "Active", "lastError": null, "createdTime": "2026-09-27T09:00:00+00:00", "lastUsedTime": null } ``` `data` holds the properties listed by `GET /api/v1/connectors/credential-types` (`properties[{ name, type, required, isSecret, options }]`); secrets are encrypted at rest and never returned. Another workspace's id is `404 credential_not_found`. Types that need a browser consent flow (OAuth) are linked from the console. At most `LeanTools.Connectors.MaxCredentialsPerAccount` (50) per workspace. A credential the upstream rejects is marked *Invalid* and the console shows a "needs attention" badge. ## Run an action ``` curl -s -X POST https://YOUR-HOST/api/v1/connectors/Twilio/actions/sms.send \ -H "Authorization: Bearer lt-..." -H "Content-Type: application/json" \ -d '{ "credentialId": "7c1f0a...", "input": { "from": "+15550001111", "to": "+60123456789", "body": "Hello" } }' ``` 200 ``` { "connector": "Twilio", "action": "sms.send", "output": { "sid": "SM...", "status": "queued" }, "usage": { "credits": 1, "balance": 1868.15, "durationMs": 412, "upstreamProvider": "Twilio" } } ``` Pass `credentialId` (a stored credential), `credential: { credentialTypeKey, data }` (used once, never persisted), or nothing to use the connector's default credential set in the console. `input` is validated against the action's `inputSchema` before anything runs (`400 invalid_input` / `unknown_field`, `413 payload_too_large` over `LeanTools.Connectors.MaxInputBytes`). Over MCP the same action is `connector.twilio.sms.send` with `arguments: { input, credentialId? }`. ## Guardrails Each connector has a configure drawer in the console with four guardrails, enforced on every call whether it arrives over REST or MCP: | Guardrail | Effect | Refusal | | --- | --- | --- | | Functions | Each action can be exposed or hidden from agents. Hidden actions vanish from the catalogue. | `403 action_disabled` | | Allowed keys | Restrict the connector to specific API keys (all keys by default). | `403 key_not_allowed` | | Rate limit and daily cap | Calls per minute and calls per UTC day across the workspace. | `429 rate_limited`, `429 daily_cap_reached` | | Require approval for writes | Actions that are not read-only are parked until a member with the *approve calls* capability decides. | `202 approval_pending` | ## Approvals: human in the loop With *require approval for writes* on, a write action does not run. The call answers `202` with an approval id and expiry; the request appears in the console's Activity → Approvals queue with its arguments, where an Owner or Admin approves (the action then runs with the workspace credential, and the credits are charged) or denies it. Pending requests expire after `LeanTools.Tenant.ApprovalExpiryMinutes` (30 by default). 202 on the call ``` { "isSuccess": false, "errorCode": "approval_pending", "errorMessage": "The action 'update_deal' of connector 'hubspot' is held for human approval (request a1b2c3…). Poll GET /api/v1/connectors/approvals/a1b2c3… for the decision and the output; the request expires at 2026-09-28T10:30:00.0000000+00:00.", "details": { "approvalId": "a1b2c3…", "expiresUtc": "2026-09-28T10:30:00+00:00", "pollUrl": "/api/v1/connectors/approvals/a1b2c3…" } } ``` Poll the decision with the same key (a free `management` call): GET /api/v1/connectors/approvals/{id} ``` { "id": "a1b2c3…", "connector": "hubspot", "action": "update_deal", "status": "executed", "createdUtc": "2026-09-28T10:00:00+00:00", "expiresUtc": "2026-09-28T10:30:00+00:00", "decidedUtc": "2026-09-28T10:04:12+00:00", "output": { "id": "deal_123", "stage": "closedwon" }, "usage": { "credits": 1 } } ``` `status` is `pending`, `executing` (claimed by an approver, the call is running), `executed` (approved and run; `output` and `usage` are filled), `denied`, `expired` or `failed` (approved but the call could not run: the upstream rejected it, or its key was revoked in the meantime, `key_revoked`; an approved call is bounded to 10 minutes, and a claim that never finished because the node died mid-call is failed with `execution_interrupted` after 15 minutes, which polling a due request settles; an `executed` request whose charge could not be recorded at once carries `error { code: "billing_deferred" }`, the call ran and the charge is recorded later); denied and failed requests carry an `error { code, message }`. Over MCP the held call is an `isError: true` result with the same fields, and the agent can call the connector again after the decision or wait for it, as the harness prefers. Approvals also carry the note the reviewer wrote, so the audit trail is complete. ## Bring your own server The *Generic MCP* connector type registers any remote MCP server (Streamable HTTP) as a connector: provide its URL and, as the credential, the header it expects. Its tools become `connector..` actions, metered per completed call, listed with the annotations the server declares, and subject to the same guardrails and approvals as built-in connectors. Stdio transports are refused (`403 transport_not_allowed`). ## Status codes | Status | Code | Meaning | | --- | --- | --- | | 202 | `approval_pending` | Held for a human decision; poll the approval | | 400 | `invalid_request`, `invalid_input`, `unknown_field`, `credential_required`, `invalid_credential`, `oauth_not_supported`, `expressions_not_supported` | Body shape, input validation, credential missing / wrong type / disabled | | 401 | `authentication_error`, `key_expired` | Missing, unknown or expired bearer | | 402 | `insufficient_credits`, `key_cap_reached`, `tool_budget_reached` | No credits, or a cap reached | | 403 | `service_not_allowed`, `tool_disabled`, `connector_disabled`, `connector_paused`, `action_disabled`, `key_not_allowed`, `transport_not_allowed` | Key scope, a tool switched off, a connector switched off by the host (`connector_disabled`) or paused by the workspace (`connector_paused`), a hidden action, a key the connector does not allow | | 404 | `connector_not_found`, `action_not_found`, `credential_not_found`, `credential_type_not_found`, `approval_not_found` | | | 413 | `payload_too_large` | `input` over the host limit | | 422 | `connector_error` | The upstream rejected the request (0 credits) | | 429 | `rate_limited`, `daily_cap_reached` | Workspace guardrails | | 502 / 504 | `connector_upstream_error`, `connector_timeout` | The upstream failed or did not answer within `LeanTools.Connectors.CallTimeoutSeconds` (0 credits) | --- # LeanData Agents LeanData serves queryable datasets published by data providers: tables (filter, order, page) and document collections (list, fetch). A dataset is addressed by `{provider}.{dataset}` and priced per dataset: one `call` plus the rows returned, and for subscription datasets a monthly charge. ## Endpoints (GET)/api/v1/data?domain=&country=&kind=table|document&provider=catalogue with prices and access state (GET)/api/v1/data/subscriptionsyour dataset subscriptions (GET)/api/v1/data/{dataset}columns, document types, examples, operators, capabilities (POST)/api/v1/data/{dataset}/queryJSON page (default), NDJSON (`?format=ndjson`) or CSV (`?format=csv`) (GET)/api/v1/data/{dataset}/documents?type=&search=&take=&pageToken=document summaries (GET)/api/v1/data/{dataset}/documents/{id}?redirect=a document, inline or with a `downloadUrl` (GET)/api/v1/data/downloads/{ticket}the bytes behind a `downloadUrl` (no bearer) (POST)/api/v1/data/{dataset}/subscribe201 subscription (first month charged now) (DELETE)/api/v1/data/{dataset}/subscribecancel at the end of the period The key must be scoped to the `Data` service; catalogue, describe and subscription calls are free. Over MCP the same operations are `data.list`, `data.describe`, `data.query`, `data.documents.list`, `data.documents.get`, `data.subscribe` and `data.unsubscribe`, with the dataset as an argument. ## Catalogue and describe `GET /api/v1/data` lists the enabled datasets your workspace may use (a dataset licensed per country is hidden when your workspace's country is not allowed). Each item carries `pricing` at your rates and `access`, which says whether a query would be admitted right now: ``` { "items": [ { "key": "leantools-usage.service-usage", "provider": "leantools-usage", "name": "Service usage", "kind": "table", "domains": ["Usage"], "coverage": ["GLOBAL"], "columns": ["day", "serviceKey", "meterKey", "requests", "credits"], "pricing": { "callCredits": 0, "rowCreditsPer1000": 0, "monthlyCredits": null, "subscriptionRequired": false, "subscribed": false, "summary": "0 credits/call, 0 credits/1,000 rows" }, "access": { "allowed": true, "reason": null, "requiresSubscription": false, "subscribed": false, "subscriptionStatus": null, "currentPeriodEndUtc": null } } ], "count": 1 } ``` `GET /api/v1/data/{dataset}` adds `columnDefinitions[{ name, type, description, isKey, isFilterable, isSortable, isNullable }]`, `documentTypes`, `examples` (ready-to-send query bodies), `operators` and `capabilities` (`supportsFilters`, `supportsOrderBy`, `supportsFieldSelection`, `supportsNativeQuery`, `supportsTotalCount`, `maxPageSize`, `defaultPageSize`). ## Query (JSON page) ``` curl -s https://YOUR-HOST/api/v1/data/leantools-usage.service-usage/query \ -H "Authorization: Bearer lt-..." -H "Content-Type: application/json" \ -d '{ "filters": [ { "field": "serviceKey", "op": "eq", "value": "Search" } ], "fields": ["day", "requests", "credits"], "orderBy": "day", "descending": true, "take": 100, "includeTotalCount": true }' ``` 200 ``` { "dataset": "leantools-usage.service-usage", "columns": [ { "name": "day", "type": "DateTime" }, { "name": "requests", "type": "Integer" }, { "name": "credits", "type": "Decimal" } ], "rows": [ [ "2026-09-27T00:00:00+00:00", 41, 4.1 ], [ "2026-09-26T00:00:00+00:00", 12, 1.2 ] ], "nextPageToken": "MjAw", "totalCount": 233, "truncated": false, "usage": { "rows": 100, "credits": 0.11, "balance": 1874.25 } } ``` - `filters[].op` is one of `eq`, `ne`, `gt`, `ge`, `lt`, `le`, `in` (array value), `contains`, `startsWith`, `between` (two-element array), `isNull` (no value); filters are combined with AND and only `isFilterable` columns are accepted (`400 invalid_filter`). - `take` is clamped to the dataset's `maxPageSize` and the host's `LeanTools.Data.MaxRowsPerCall`; pass `nextPageToken` back as `pageToken` to continue. An empty body returns the first page at the default page size. `nativeQuery` is accepted only where `capabilities.supportsNativeQuery` is true. - The body is limited to `LeanTools.Data.MaxQueryBodyBytes` (1 MiB); a page over `LeanTools.Data.MaxResponseBytes` (64 MiB) is refused with `413 response_too_large` (stream it instead). - `usage.balance` is the balance after this call; `usage.credits` = the call price plus `rows / 1000 × row price`. ## Streaming (NDJSON) and CSV Ask for `?format=ndjson` (or `?stream=true`, or `Accept: application/x-ndjson`) to receive rows as they are produced, one JSON document per line: ``` curl -N https://YOUR-HOST/api/v1/data/leantools-usage.service-usage/query?format=ndjson \ -H "Authorization: Bearer lt-..." -H "Content-Type: application/json" -d '{ "take": 5000 }' ``` ``` {"dataset":"leantools-usage.service-usage","columns":[{"name":"day","type":"DateTime"},{"name":"requests","type":"Integer"}]} {"day":"2026-09-27T00:00:00+00:00","requests":41} {"day":"2026-09-26T00:00:00+00:00","requests":12} {"nextPageToken":null,"totalCount":null,"truncated":false,"usage":{"rows":2,"credits":0.1002,"balance":1874.15}} ``` A header line with the columns, one object per row keyed by column name, then a trailer with the page token and the usage block. If the provider fails after the header the stream ends with `{"error":{"code":"provider_error","message":"…","status":502},"usage":{…}}` and the rows already delivered are charged; if you disconnect mid-stream, the rows already sent are charged as well. `?format=csv` answers `text/csv` with a header row (RFC 4180 quoting; cells starting with `=`, `+`, `-`, `@` are prefixed with `'`); the usage block travels in the `X-LeanTools-Usage` HTTP trailer. ## Documents `GET /api/v1/data/{dataset}/documents` lists a document dataset's items (`{ items[{ id, type, title, contentType, size, modifiedTime, metadata }], nextPageToken, totalCount, usage }`; billed one `call` plus one `row` per summary). `GET …/documents/{id}` returns one document (one `call` plus one `row`): ``` { "dataset": "leantools-usage.monthly-statement", "id": "2026-08", "type": "statement", "title": "August 2026", "contentType": "text/markdown", "size": 2140, "content": "# Statement for August 2026\n...", "downloadUrl": null, "expiresUtc": null, "usage": { "rows": 1, "credits": 0, "balance": 1874.15 } } ``` Textual documents up to `LeanTools.Data.InlineDocumentBytes` (256 KiB) come back inline. Larger or binary documents carry `content: null` and a signed `downloadUrl` valid for `LeanTools.Data.DownloadTtlSeconds` (no bearer needed, range requests honoured); `?redirect=true` answers `302`. A tampered or expired link is `403 invalid_ticket`; the download itself is not billed. ## Subscriptions A dataset with `access.requiresSubscription: true` refuses queries with `402 subscription_required` until the workspace subscribes, from the console (Tools & Connectors → Datasets) or the API: ``` curl -s -X POST https://YOUR-HOST/api/v1/data/market.prices/subscribe -H "Authorization: Bearer lt-..." ``` 201 ``` { "subscription": { "datasetKey": "market.prices", "status": "active", "monthlyCredits": 5, "currentPeriodEndUtc": "2026-10-27T09:12:00+00:00" }, "created": true, "notRequired": false, "usage": { "credits": 5, "balance": 1869.15 } } ``` The first month is charged immediately (`402 insufficient_credits` when the balance does not cover it) and access opens at once. Repeating the call on an active subscription answers `200` with `created: false`. The month renews automatically; a paid renewal that finds no credits suspends the subscription (`402 subscription_suspended`) until the workspace is topped up. `DELETE …/subscribe` answers `{ datasetKey, status: "cancelled", accessUntilUtc }`: access continues until the end of the paid period. ## Billing notes Every query, listing or fetch writes one usage row under the `call` meter with the dataset key as qualifier and the rows delivered as `outputUnits`. A call refused before its first row (bad filter, no credits, no subscription, provider down) costs nothing; rows already delivered are always charged. A dataset whose `call` and `row` rates are both 0 (your own usage data, for instance) can be queried on an empty balance. ## Status codes | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request`, `invalid_query`, `invalid_filter`, `invalid_sort`, `invalid_fields`, `invalid_page_token`, `native_query_not_supported`, `documents_not_supported`, `not_subscribable` | Malformed body or format, a column, operator, page token or capability the dataset does not have | | 401 | `authentication_error` | Missing or unknown bearer | | 402 | `insufficient_credits`, `subscription_required`, `subscription_suspended` | No credits for a priced dataset; no (or a suspended) subscription | | 403 | `service_not_allowed`, `dataset_disabled`, `dataset_unavailable`, `country_blocked`, `country_not_allowed`, `country_required`, `invalid_ticket` | Key scope, a dataset switched off, geography, a forged or expired download link | | 404 | `dataset_not_found`, `document_not_found`, `not_subscribed` | | | 413 | `payload_too_large`, `response_too_large` | The query body, or a page / document over the host limit | | 502 / 503 / 504 | `provider_error`, `provider_unavailable`, `provider_timeout` | The data provider failed, is down, or exceeded `LeanTools.Data.QueryTimeoutSeconds` | --- # REST API reference REST API Every service over plain HTTP under `/api/v1`, authenticated with `Authorization: Bearer lt-…`. The interactive OpenAPI description lives at [/swagger](https://leantools.ai/swagger). > Each section names the API key scope it needs. LeanData, LeanConnect connectors and the tools API have their own pages: [LeanData](https://leantools.ai/doc/data), [Connectors and approvals](https://leantools.ai/doc/connectors), [Tools API](https://leantools.ai/doc/tools). ## Web Search Part of LeanConnect; scope `Search`. The concrete engine (Bing, Google, DuckDuckGo, Brave, …) is chosen server-side; callers never pick it. (GET)/api/v1/servicesevery service key, no authentication (POST)/api/v1/searchone `request` per call - `query`requiredThe search query. - `page`int1-based result page. - `resultCount`intMaximum number of results. - `language`stringLanguage code, e.g. `en`. 200 ``` { "query": "hello world", "totalResultCount": 10, "error": null, "items": [ { "position": "1", "title": "Example", "url": "https://example.com", "description": "An example result.", "images": [] } ] } ``` Engine-level problems are reported in `error` (still `200`); authentication and validation failures return a non-2xx status. ## LeanBrowser Scope `Browser`. Headless browser automation over HTTP and WebSocket, everything under `/api/v1/browser/`. Browser-prefixed twins exist for each route (`/chrome/…`, `/chromium/…`, `/edge/…`). ### Common query parameters and fields - `sessionKey`queryPersistent session key (1–19 characters) that reuses cookies and local storage across requests. Private to your workspace. - `trackingId`queryOptional label for grouping sessions. - `blockAds`, `timeout`queryBlock known ad domains; request timeout in ms (capped at 300000). - `url`, `gotoOptions`, `cookies`, `userAgent`, `authenticate`, `setExtraHTTPHeaders`, `waitForSelector`, `waitForTimeout`, `bestAttempt`bodyNavigation and wait options accepted by most POST routes. ### Routes (POST)/api/v1/browser/screenshotPNG/JPEG binary, or JSON with `encoding: "base64"`; `selector`, `scrollPage`, `options.fullPage`, `options.clip` (POST)/api/v1/browser/pdfbinary PDF; `options.format`, `landscape`, `printBackground`, `margin`, `pageRanges` (POST)/api/v1/browser/scrape`elements[{ selector, timeout }]` → text, html, attributes and box per match; optional `debugOpts` (html, screenshot, cookies, network) (POST)/api/v1/browser/contentthe rendered HTML (POST)/api/v1/browser/functionrun `code` in the page with a `context` object; JSON, HTML or text back (POST)/api/v1/browser/downloadrun code that triggers a download and return the file (POST)/api/v1/browser/performancenavigation, paint and resource metrics against `budgets[]` (POST)/api/v1/browser/automation/runLLM-driven task: `task`, `url`, `openai_api_key` or `base_url`, `model_id`, `max_steps`, `use_vision` → `{ result, success, steps_used }` (GET)/api/v1/browser/json/list · /json/version · /json/protocolChrome DevTools Protocol metadata; `PUT /json/new?url=` opens a tab (GET)/api/v1/browser/active · /sessions · /pressure · /kill/{id}health, your sessions, system pressure, kill one of your sessions (or `/kill/all`) ### WebSocket For CDP or Playwright control, pass the key as `?token=` because upgrades cannot carry headers. Each workspace may hold a limited number of concurrent sessions (default 4, `LeanTools.Browser.MaxSocketsPerAccount`; `429 too_many_sessions` beyond that). Active minutes are metered on `session_minute`. ``` ws://HOST/api/v1/browser/chromium?token=lt-xxx&sessionKey=my-session # CDP ws://HOST/api/v1/browser/devtools/browser/{browserId}?token=lt-xxx # reconnect to your own browser ws://HOST/api/v1/browser/chromium/playwright?token=lt-xxx # Playwright (User-Agent must contain "Playwright") ``` The `launch` query parameter takes JSON launch options; only `headless`, `defaultViewport`, `devtools`, `acceptInsecureCerts`, `slowMo`, `stealth`, `timeout`, `waitForInitialPage`, `blockAds` and `keepAlive` are honoured. ## LeanModel (OpenAI-compatible) Seven model catalogues behind OpenAI-shaped endpoints, each its own service with its own tiers, meters and key scope. `model` names a tier (`GET /api/v1/models/tiers`); `"default"` or no model selects the catalogue's default tier. Every catalogue ships with standard tiers that the operator binds to a model (`leanmodel-flash` and `leanmodel-pro` for text, `leanmodel-default` for the others), and operators may add tiers of their own; a standard tier that is not bound yet answers `400 model_not_configured`. Any OpenAI SDK works by pointing its base URL at `https://HOST/api/v1`; avatar streams and realtime audio take the same key over REST and WebSocket. | Catalogue | Scope | Standard tiers | Endpoints (under `/api/v1`) | Priced per | | --- | --- | --- | --- | --- | | LeanTools Text Gen | `TextAssistant` | `leanmodel-flash` (default), `leanmodel-pro` | `POST /chat/completions` (text, image and audio parts in; text and tool calls out; streaming), `POST /audio/transcriptions` | 1,000,000 input / output tokens; audio minute | | LeanTools Image Gen | `ImageAssistant` | `leanmodel-default` | `POST /images/generations`, `POST /images/edits` | image | | LeanTools Speech Gen | `SpeechAssistant` | `leanmodel-default` | `POST /audio/speech` | 1,000 characters | | LeanTools Music Gen | `MusicAssistant` | `leanmodel-default` | `POST /audio/music` | second produced | | LeanTools Video Gen | `VideoAssistant` | `leanmodel-default` | `POST /videos/generations` | second produced | | LeanTools Avatar | `AvatarAssistant` | `leanmodel-default` | `POST /avatar/streams`, then `/sdp`, `/ice`, `/speak` and `DELETE` on the stream | connected minute | | LeanTools Realtime Audio | `RealtimeAssistant` | `leanmodel-default` | `GET /realtime` (WebSocket) | 1,000,000 audio, text and cached tokens | Every tier is bound by the operator to one upstream model, and each package prices the tiers it includes: a tier your package does not price answers `403 tier_not_in_package`, and a request the tier's model cannot serve (image parts on a text-only tier, an edit on a tier without image input) answers `400 capability_not_supported`. Music and video responses wait until the clip is rendered, which can take minutes: allow at least five minutes per request in your client. Operators who put a reverse proxy in front of LeanTools must allow the same. ### Chat completions ``` curl https://HOST/api/v1/chat/completions \ -H "Authorization: Bearer lt-your-api-key" -H "Content-Type: application/json" \ -d '{"model":"leanmodel-flash","messages":[{"role":"user","content":"Hello!"}]}' ``` ``` from openai import OpenAI client = OpenAI(base_url="https://HOST/api/v1", api_key="lt-your-api-key") response = client.chat.completions.create(model="leanmodel-flash", messages=[{"role": "user", "content": "Hello!"}], stream=True) for chunk in response: print(chunk.choices[0].delta.content, end="") ``` ``` import OpenAI from 'openai'; const client = new OpenAI({ baseURL: 'https://HOST/api/v1', apiKey: 'lt-your-api-key' }); const response = await client.chat.completions.create({ model: 'leanmodel-flash', messages: [{ role: 'user', content: 'Hello!' }] }); console.log(response.choices[0].message.content); ``` - `messages`requiredChat messages; content may be a string or parts: `text`, `image_url` (a URL or data URI) for vision, `input_audio` for listening. - `input_audio`part`{ "type": "input_audio", "input_audio": { "data": "", "format": "wav" | "mp3" } }`. Bad base64, another format or a clip over 25 MB is `400 invalid_input`; a tier whose model takes no audio is `400 capability_not_supported`. Audio parts add an `audio_minute` line to the call's tokens. - `model`stringTier name from `/models/tiers`; unknown names are `404 model_not_found`. - `stream`, `temperature`, `max_tokens`, `top_p`Standard sampling and streaming options (SSE with `data: [DONE]`). - `tools`, `tool_choice`Function definitions; the model may answer `tool_calls` with `finish_reason: "tool_calls"`, and you send the results back as `role: "tool"` messages. - `response_format`, `disable_thinking``{ "type": "json_object" }` for JSON output; turn reasoning off for this call (wins over the `Reasoning` setting). - `properties`objectValues for the settings the tier offers, e.g. `{ "Reasoning": "Disabled" }`; the tiers listing names them under `properties`. a message with an audio part ``` { "model": "leanmodel-flash", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "What is the caller asking for?" }, { "type": "input_audio", "input_audio": { "data": "UklGRi4AAABXQVZFZm10…", "format": "wav" } } ] } ] } ``` **Tier settings.** Each tier in `/models/tiers` lists, under `properties`, the settings of its model the operator lets callers choose (name, type, options and the tier's own value as `defaultValue`). Send values as `"properties": { "Name": value }` on JSON requests, or as a JSON text field on multipart ones. Settings the tier does not offer, and values that do not fit, are ignored; the request's own fields (`voice`, `speed`, `size`, `seconds`, `resolution`, …) win and are mapped onto the matching settings. `Reasoning` is LeanTools' own switch on text tiers, mapped onto each provider's setting. Responses carry the standard `usage` extended with `credits_charged`, `credits_remaining` and `cost_usd` (also on the final streaming chunk, and on the trailing usage chunk with `stream_options.include_usage`). The other JSON responses of LeanModel carry `usage: { credits_charged, cost_usd }`. ### Transcription (POST)/api/v1/audio/transcriptionsmultipart: `file` (wav, mp3, mp4, webm, ogg, flac), `model`, `language`, `prompt`, `response_format` (`json` | `verbose_json` | `text`), `properties` (JSON text) Served by the text catalogue: `model` is a text tier whose model offers transcription (`capabilities.transcription`), billed on `audio_minute` for the length of the clip. curl ``` curl https://HOST/api/v1/audio/transcriptions -H "Authorization: Bearer lt-your-api-key" \ -F file=@recording.mp3 -F model=leanmodel-flash -F language=en ``` 200 ``` { "text": "Hello, this is a transcription of the recording.", "usage": { "credits_charged": 0.6, "cost_usd": 0.006 } } ``` ### Images (POST)/api/v1/images/generations`{ model, prompt, n (1–4), size, response_format ("b64_json"), properties }` → `{ created, model, data: [ { b64_json, revised_prompt } ], usage }` (POST)/api/v1/images/editsmultipart (`image`, or several as `image[]`; `prompt`; `model`; `n`; `size`; `response_format`; `properties` as a JSON string) *or* JSON `{ model, prompt, n, size, images: [ { image_url: "data:image/png;base64,…" } ], properties }` → the same response One `data` item per image, billed per image produced: a call that asked for three and received two is charged two. Edits need a tier whose model takes image input; `mask` is `400 unsupported_parameter`; uploads are limited to 25 MB. ``` curl https://HOST/api/v1/images/generations \ -H "Authorization: Bearer lt-your-api-key" -H "Content-Type: application/json" \ -d '{"model":"leanmodel-default","prompt":"A lighthouse at dusk, oil painting","n":2,"size":"1024x1024"}' ``` ``` curl https://HOST/api/v1/images/edits -H "Authorization: Bearer lt-your-api-key" \ -F image=@photo.png -F prompt="Replace the sky with a starry night" -F model=leanmodel-default -F size=1024x1024 ``` ``` curl https://HOST/api/v1/images/edits \ -H "Authorization: Bearer lt-your-api-key" -H "Content-Type: application/json" \ -d '{"model":"leanmodel-default","prompt":"Replace the sky with a starry night", "images":[{"image_url":"data:image/png;base64,iVBORw0KGgo…"}]}' ``` 200 ``` { "created": 1760400000, "model": "leanmodel-default", "data": [ { "b64_json": "iVBORw0KGgo…", "revised_prompt": "A lighthouse at dusk, oil painting" } ], "usage": { "credits_charged": 3, "cost_usd": 0.03 } } ``` ### Speech (POST)/api/v1/audio/speech`{ model, input, voice, speed, response_format ("mp3" | "wav"), properties }` → audio bytes (`audio/mpeg` or `audio/wav`) Billed on `tts_character` for the characters of `input`. The body is the audio itself, so the usage rides in the `X-LeanTools-Usage` response header as JSON. `voice` is the tier's own voice when omitted: a description for voice-design models, a voice id for the others. curl ``` curl https://HOST/api/v1/audio/speech -D headers.txt -o reply.mp3 \ -H "Authorization: Bearer lt-your-api-key" -H "Content-Type: application/json" \ -d '{"model":"leanmodel-default","input":"Your order is on its way.","voice":"a calm, low voice","response_format":"mp3"}' # headers.txt Content-Type: audio/mpeg X-LeanTools-Usage: {"units":26,"credits":0.104,"costUsd":0.00104,"balance":998.4} ``` ### Music (POST)/api/v1/audio/music`{ model, prompt, lyrics, negative_prompt, duration (seconds, at most 240), response_format ("mp3"), properties }` → `{ created, model, data: [ { b64_json, mime_type, seconds } ], usage }` Billed on `music_second` for the seconds produced (the seconds asked for when the model reports no length). The response waits for the clip. curl ``` curl https://HOST/api/v1/audio/music --max-time 600 \ -H "Authorization: Bearer lt-your-api-key" -H "Content-Type: application/json" \ -d '{"model":"leanmodel-default","prompt":"Warm acoustic folk, slow tempo","lyrics":"Morning light on the harbour wall…","duration":30}' ``` 200 ``` { "created": 1760400000, "model": "leanmodel-default", "data": [ { "b64_json": "SUQzBAAAAAAA…", "mime_type": "audio/mpeg", "seconds": 30 } ], "usage": { "credits_charged": 6, "cost_usd": 0.06 } } ``` ### Video (POST)/api/v1/videos/generations`{ model, prompt, seconds, resolution ("720p" | "480p"), orientation ("landscape" | "portrait"), properties }` → `{ created, model, data: [ { b64_json, mime_type: "video/mp4", seconds } ], usage }` Billed on `video_second` for the seconds produced. The response waits for the clip. curl ``` curl https://HOST/api/v1/videos/generations --max-time 600 \ -H "Authorization: Bearer lt-your-api-key" -H "Content-Type: application/json" \ -d '{"model":"leanmodel-default","prompt":"A drone shot over a vineyard at sunrise","seconds":5,"resolution":"720p","orientation":"landscape"}' ``` 200 ``` { "created": 1760400000, "model": "leanmodel-default", "data": [ { "b64_json": "AAAAIGZ0eXBpc29t…", "mime_type": "video/mp4", "seconds": 5 } ], "usage": { "credits_charged": 5, "cost_usd": 0.05 } } ``` ### Avatar streams A talking avatar over WebRTC, relayed to the avatar model bound to the tier. Create a stream, answer its offer, trickle ICE candidates, then send it text to speak; close it when you are done. Billed on `stream_minute` for every minute the stream stays connected, accrued from creation until close; the other calls are on the free `management` meter. A stream idle for ten minutes, older than sixty, or whose workspace runs out of credits is closed by LeanTools. `id` is LeanTools' own stream id; `session_id` is returned equal to it, and may be echoed back, where it is ignored. (POST)/api/v1/avatar/streams`{ model, stream_warmup, fluent, compatibility_mode, properties }` → 201 `{ id, session_id, offer: { type: "offer", sdp }, ice_servers: [ … ], model }` (POST)/api/v1/avatar/streams/{id}/sdp`{ answer: { type: "answer", sdp } }` → the provider's answer (POST)/api/v1/avatar/streams/{id}/ice`{ candidate, sdpMid, sdpMLineIndex }` → the provider's answer (POST)/api/v1/avatar/streams/{id}/speak`{ text }` (at most 4,000 characters) → the provider's answer (DELETE)/api/v1/avatar/streams/{id}closes the stream and settles → `{ id, minutes, credits }` JavaScript · the lifecycle in a browser ``` const api = (method, path, body) => fetch('https://HOST/api/v1' + path, { method, headers: { Authorization: 'Bearer lt-your-api-key', 'Content-Type': 'application/json' }, body: body && JSON.stringify(body) }).then(r => r.json()); const s = await api('POST', '/avatar/streams', { model: 'leanmodel-default' }); // 201 { id, session_id, offer, ice_servers, model } const pc = new RTCPeerConnection({ iceServers: s.ice_servers }); pc.ontrack = e => { video.srcObject = e.streams[0]; }; pc.onicecandidate = e => e.candidate && api('POST', `/avatar/streams/${s.id}/ice`, { candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid, sdpMLineIndex: e.candidate.sdpMLineIndex }); await pc.setRemoteDescription(s.offer); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); await api('POST', `/avatar/streams/${s.id}/sdp`, { answer: { type: 'answer', sdp: answer.sdp } }); await api('POST', `/avatar/streams/${s.id}/speak`, { text: 'Good morning. Here is today\'s summary.' }); await api('DELETE', `/avatar/streams/${s.id}`); // { id, minutes, credits } ``` Each workspace may hold a limited number of open streams (`429 too_many_streams` beyond it). A stream of another workspace is `404 stream_not_found`; a stream that has been closed answers `409 stream_closed`, and one still being created `409 stream_not_ready`. A provider failure is `502 upstream_error`; if billing cannot be opened the new stream is closed again and the call answers `503 billing_unavailable`, uncharged. ### Realtime audio Speech-to-speech over one WebSocket, relayed frame for frame to the realtime model bound to the tier. The frames are OpenAI Realtime JSON events in both directions, so a client written for the OpenAI Realtime API works by changing its URL and key. (WS)/api/v1/realtime?model=WebSocket upgrade; `model` is a realtime tier (the default tier when omitted) three ways to present the key ``` wss://HOST/api/v1/realtime?model=leanmodel-default Authorization: Bearer lt-your-api-key # header wss://HOST/api/v1/realtime?model=leanmodel-default&token=lt-your-api-key # query string wss://HOST/api/v1/realtime?model=leanmodel-default Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.lt-your-api-key # browsers: the subprotocol; "realtime" is echoed back as the accepted one when offered ``` JavaScript · a browser session ``` const ws = new WebSocket('wss://HOST/api/v1/realtime?model=leanmodel-default', ['realtime', 'openai-insecure-api-key.lt-your-api-key']); ws.onopen = () => ws.send(JSON.stringify({ type: 'session.update', session: { type: 'realtime', instructions: 'You are a concise assistant.', output_modalities: ['audio'], audio: { input: { format: { type: 'audio/pcm', rate: 24000 }, turn_detection: { type: 'server_vad' } }, output: { format: { type: 'audio/pcm', rate: 24000 } } } } })); ws.send(JSON.stringify({ type: 'input_audio_buffer.append', audio: base64Pcm })); // microphone chunks ws.onmessage = e => { const ev = JSON.parse(e.data); if (ev.type === 'response.output_audio.delta') play(ev.delta); // base64 PCM if (ev.type === 'error' && ev.error.code === 'insufficient_credits') topUp(); // the socket then closes with 1008 }; ``` What the relay does on the way through: it forces `session.type = "realtime"`; strips `model`, `tracing` and `prompt` from your `session.update` (the tier decides the model, and `audio.output.voice` applies only when the tier exposes a `Voice` setting); rewrites `session.model` to the tier id in `session.created` and `session.updated`; and bills every `response.done` on its `response.usage`: `audio_input_tokens`, `audio_output_tokens`, `input_tokens`, `output_tokens` and `cached_input_tokens`, each priced per 1,000,000 for the tier, committed as the conversation goes. When the balance is exhausted the relay sends `{ "type": "error", "error": { "type": "insufficient_credits", "code": "insufficient_credits", "message": "…" } }` and closes the socket with code `1008`. A session lasts at most sixty minutes. Refusals before the upgrade are plain HTTP: `401`, `402` and `403` as on every endpoint, `429 too_many_sockets` over the per-workspace socket cap, `400 websocket_required` without an upgrade, and `502 upstream_unavailable` when the model cannot be reached; nothing is charged. ### Tiers (GET)/api/v1/models/tiers?service=the tiers the key may name in `model`, filtered to those the workspace's package prices; `?service=` narrows the list to one catalogue (free) 200 ``` { "object": "list", "data": [ { "id": "leanmodel-flash", "object": "model", "owned_by": "leantools", "displayName": "LeanModel Flash", "description": "Fast and economical, for everyday work.", "service": "TextAssistant", "kind": "Text", "catalog": "LeanTools Text Gen", "isDefault": true, "isStandard": true, "provider": "…", "capabilities": { "textInput": true, "imageInput": true, "audioInput": true, "videoInput": false, "textOutput": true, "imageOutput": false, "audioOutput": false, "videoOutput": false, "transcription": true, "tools": true }, "operations": [ "chat.completions", "chat.completions.audio", "audio.transcriptions" ], "rates": { "input_tokens": { "creditPerUnit": 1.0, "unitScale": 1000000 }, "output_tokens": { "creditPerUnit": 4.0, "unitScale": 1000000 }, "audio_minute": { "creditPerUnit": 0.6, "unitScale": 1 } }, "properties": [ { "name": "Reasoning", "displayText": "Reasoning", "type": "Dropdown", "defaultValue": "Default", "options": [ { "name": "Default", "value": "Default" }, { "name": "Enabled", "value": "Enabled" }, { "name": "Disabled", "value": "Disabled" } ] } ] } ] } ``` `kind` is `Text`, `Image`, `Speech`, `Music`, `Video`, `Avatar` or `Realtime`; `catalog` is the catalogue's name; `isStandard` marks the tiers every host has. `operations` lists what the tier serves as priced by your package: `chat.completions`, `audio.transcriptions`, `images.generations`, `images.edits`, `audio.speech`, `audio.music`, `videos.generations`, `avatar.streams` or `realtime` (a text tier that hears audio also lists `chat.completions.audio`). A tier that is not bound to a model yet is listed with no `provider` and answers `model_not_configured` when used. Errors use the OpenAI envelope: `model_not_found` (404), `model_not_configured`, `capability_not_supported`, `unsupported_parameter` and `invalid_input` (400), `tier_not_in_package` (403), `insufficient_credits` (402), `too_many_streams` and `too_many_sockets` (429), `stream_not_found` (404), `stream_closed` and `stream_not_ready` (409), `upstream_error` and `upstream_unavailable` (502), `model_unavailable` and `billing_unavailable` (503), `websocket_required` (400) and `server_error`; see [Errors](https://leantools.ai/doc/errors#assistants-openai-envelope). ## Embeddings (OpenAI-compatible) Scope `Embeddings`. Turns text into vectors with the embedding model LeanTools runs, for retrieval, search and clustering; pair it with LeanMemory's vector store. One model serves every workspace, so there are no tiers: whatever `model` you name, the answer names the model that produced it. Billed per 1,000,000 input tokens, counted with the o200k tokenizer and summed over the inputs of the call. Any OpenAI SDK works by pointing its base URL at `https://HOST/api/v1`. (POST)/api/v1/embeddings`{ model, input, encoding_format }` → `{ object: "list", data: [{ object: "embedding", index, embedding }], model, usage }` curl ``` curl https://HOST/api/v1/embeddings \ -H "Authorization: Bearer lt-your-api-key" -H "Content-Type: application/json" \ -d '{"model":"default","input":["The quick brown fox","jumps over the lazy dog"]}' ``` - `input`requiredA string, or an array of up to 256 non-empty strings; `data[i].index` is the position of each input. Token arrays are not supported. - `model`stringAccepted for compatibility; the bound model answers. - `encoding_format`string`float` (default) or `base64` (little-endian float32, as the OpenAI SDKs decode it). The `usage` object carries `prompt_tokens`, `total_tokens`, `credits_charged` and `credits_remaining`. Errors use the OpenAI envelope with a LeanTools `code`: `invalid_input` (400: no input, an empty input, more than 256, a non-string input, an unknown `encoding_format`), `payload_too_large` (413, over 8 MB), `insufficient_credits` (402), `not_configured` (503: no embedding model is set up on the host), `upstream_error` (502: the model failed; nothing is charged). ## Email Part of LeanConnect; scope `Email`. Transactional email through the provider configured on the host (MailGun, MailJet, ZeptoMail, MailTrap, SMTP, …). One `email` per recipient. Also available over MCP as `email.send`. (POST)/api/v1/email/send - `to`, `subject`, `body`requiredRecipient, subject and body (HTML when `isBodyHtml` is true, the default). - `from`, `fromName`, `toName`, `cc`, `bcc`Optional addresses and display names; `from` defaults to the host's sender. - `waitForResponse`boolWait for the provider to accept the message (surfaces provider errors); default fire-and-forget. - `attachments`array`[{ name, contentBase64 }]`; forwarded by SMTP and MailTrap senders. 200 ``` { "isSuccess": true, "errorMessage": null, "provider": "MailGun" } ``` ## LeanMachine Scope `Machine`. Short-lived sandbox VMs and containers, billed per `active_minute` until stopped or disposed. Every instance key is prefixed with the workspace identity, so workspaces never see each other's instances. Pools are chosen server-side; sizing fields are accepted but the pool defaults apply. (GET)/api/v1/machine/health`{ isHealthy, enabledPoolCount, poolsWithCapacityCount }`; 503 when no pool has capacity (POST)/api/v1/machine/instance-keymint an instance key to pass on create (POST)/api/v1/machine/instances`{ key, instanceType: Session|User|Permanent, tag }` → 201 instance (GET)/api/v1/machine/instances[/{instanceKey}]list (`?includeDisposed=`) or get, refreshed from the provider (POST)/api/v1/machine/instances/{instanceKey}/start · /stop (DELETE)/api/v1/machine/instances/{instanceKey}dispose (idempotent) instance ``` { "instanceKey": "e6c2…-G-20260602-a1b2c", "instanceType": "Session", "status": "Running", "rawStatus": "running", "ip": "10.0.4.118", "image": "ubuntu:22.04", "cpus": 2, "memoryMb": 4096, "diskGb": 20, "createdAt": "…", "machineCreatedTime": "…", "machineDisposedTime": null, "tag": "agent-run-42" } ``` Statuses: `Creating`, `Starting`, `Running`, `Stopping`, `Stopped`, `Error`, `Disposed` (terminal). Session and User instances are auto-disposed after the pool's idle limit; Permanent ones are not. Errors: `400` bad body, `404` unknown key, `409` state forbids the action or `duplicate_key`, `502` upstream provider, `503` no capacity. ## LeanVision (beta) Scope `TrueSight`. Run camera analysis on LeanTools-operated GPU nodes: post a LeanVision profile (schema v1) and the stream runs on a node from the pool. Billed on `stream_minute`, priced per stream-hour, from creation until disposal; management calls are free. When credits run out the stream is disposed and marked `SuspendedForCredit`. The service answers under `/api/v1/leanvision/*`; the older `/api/v1/truesight/*` paths remain as aliases with the same bodies and codes, and the documents the API returns (`stream_url`, `snapshot_url`, `ws_path`) carry the `leanvision` prefix. (GET)/api/v1/leanvision/availabilitycaps, pool slots, price per hour, selectable `models[]` (POST)/api/v1/leanvision/instancesprofile (≤ 512 KB) with `camera.url` (RTSP, `"test"` or `upload:{id}`) → 201 stream (GET)/api/v1/leanvision/instances[/{key}]your streams (POST)/api/v1/leanvision/instances/{key}/update · /passthroughnew profile (rolled back if rejected); node command envelope (GET)/api/v1/leanvision/instances/{key}/snapshot · /stream · /ws?token=JPEG, MJPEG, live WebSocket feed (DELETE)/api/v1/leanvision/instances/{key}dispose and settle billing (POST)/api/v1/leanvision/rules/dry-run · /site · /site/dry-run · /uploadsrule validation, site (cross-camera) rules, uploaded clips for "Test it" Codes: `invalid_profile`, `too_many_streams`, `no_capacity`, `billing_unavailable`, `stream_not_running`, `stream_lost`, `stream_disposed`, `policy_refused` (422), `too_many_sockets`, `upstream_error`, `unknown_upload`, `upload_node_unavailable`, `too_large`. Provider, endpoint, key and URL fields (other than `camera.url`) are stripped from profiles before they reach a node; `ai.trigger.threshold` is derived from `ai.sensitivity`. ## LeanMemory: warehouse, vectors and events LeanMemory's warehouse, vector store and event store are three services on one data engine. Each workspace owns its own engine workspace (`acc_` + the workspace guid), created on its first call; everything the workspace creates lives in databases whose ids start with that prefix (`…_warehouse`, `…_events`, `…_vectors_`). Self-hosted deployments can point their data engine at LeanMemory and need nothing else. | Meter | Unit | Default | What is counted | | --- | --- | --- | --- | | `storage_gb_month` | GB-hours, priced per 730 | 0.04 | Bytes held, sampled hourly; accrues into overdraft, nothing is deleted | | `compute_second` | seconds, priced per 1,000 | 0.2 | Engine wall-clock time per query, load or action | | `ingest_gb` | per GiB | 0.01 | Bulk loads, document and event ingest, row writes | | `egress_gb` | per GiB | 0.05 | Query results, document and row reads | | `management` | free | 0 | Health, identity, connection, listings, DDL | (GET)/api/v1/leanmemory/connectionprovisions the workspace; `{ engineUrl, flight { host, port, tls }, workspace, databasePrefix, engineVersion, limits }` (ANY)/api/v1/leanmemory/{leanmemory path}REST relay of `v1/databases/**`, `v1/query`, `v1/feedback`, `v1/raw/{hash}?db=`, read-only `v1/workspaces/{own}` Every database named must start with the workspace prefix (`404 not_found` otherwise, and the engine is never asked). Key management, purge, connect, admin and jobs do not exist through the relay. The engine's `400 / 404 / 409 / 410 / 413 / 422 / 429` are forwarded; its `401 / 403 / 5xx` become `502 upstream_error`. An `Idempotency-Key` is namespaced per workspace and key. The high-throughput columnar data channel is an `Arrow Flight` relay (`GetFlightInfo`, `DoGet`, `DoPut`, `DoAction`, `ListActions`) that takes the same bearer in the `authorization` metadata; from .NET, the `LeanMemory.Data` ADO.NET client connects with `Host=;Port=;Tls=true;Database=_warehouse;ApiKey=`. Caps: `429 too_many_databases`, `429 too_many_calls` (`MaxConcurrentCallsPerAccount`), `504 upstream_timeout` (`MaxCallSeconds`), `429 storage_cap_exceeded` over `limits.maxStorageBytes` (writes refused, reads go through), `403 workspace_suspended`. ## LeanMemory storage Scope `Storage`. Object storage billed by data held and nothing else (`storage_gb_month`, 1 credit per GB-month by default). The bytes never travel through the API: an upload is negotiated, then PUT by you straight to the store, then committed; a download is a GET from the URL the metadata call returns. (GET)/api/v1/storage/usage`{ used_bytes, reserved_bytes, quota_bytes, object_count, credit_per_gb_month, gb_hours_billed, max_object_bytes, url_ttl_seconds, transfer }` (POST)/api/v1/storage/uploads`{ key, size, content_type }` → 201 `{ upload_id, upload_url, method, headers, expires_utc, commit_url }` (PUT)raw bytes with the headers given (the store's own answer) (POST)/api/v1/storage/uploads/{upload_id}/commit→ 200 object; the size actually stored is booked (GET)/api/v1/storage/objects?prefix=&delimiter=&cursor=&limit=listing with folded prefixes, up to 1,000 per page (GET)/api/v1/storage/objects/{key}metadata plus a `download_url` (range honoured); `?redirect=true` → 302 (DELETE)/api/v1/storage/objects/{key}`{ ok, key }` Keys are path-like names of at most 512 characters (no control characters, backslashes, leading slash or dot segments). `size` is reserved against the package quota at negotiation (`413 quota_exceeded`); a key that exists is replaced atomically on commit. Codes: `invalid_key`, `invalid_size`, `incomplete_body`, `invalid_ticket`, `upload_not_found`, `upload_incomplete`, `upload_in_progress`, `object_changed`, `too_large`, `not_configured`. --- # Errors REST API Every refusal carries a stable, machine-readable code. Branch on the code, show the message to a human, and never parse the message. ## Envelopes ### LeanConnect (web search, email, connectors), LeanData, LeanMachine, LeanVision, LeanMemory, model tiers and the tools API ``` { "isSuccess": false, "errorMessage": "The account has no credits left. Top up or renew the subscription to continue.", "errorCode": "insufficient_credits" } ``` A data query answered as a JSON page may add `usage` when rows were fetched before the failure; a NDJSON stream that fails after its header ends with an `{"error":{code,message,status},"usage":{…}}` line. A held connector write adds `details { approvalId, expiresUtc, pollUrl }`. ### OpenAI-compatible endpoints ``` { "error": { "message": "…", "type": "insufficient_quota", "code": "insufficient_credits" } } ``` ### LeanBrowser ``` { "error": "Description of the error.", "statusCode": 400 } ``` ### MCP Protocol failures are JSON-RPC errors (`{ jsonrpc, id, error: { code, message, data } }`); tool failures are results with `isError: true` whose text is `{"error":{"code","message","status"}}`. ## Admission codes (every service) | Status | Code | Meaning | What to do | | --- | --- | --- | --- | | 401 | `authentication_error` | Missing, unknown or revoked API key | Send `Authorization: Bearer lt-…`; create a key in the console | | 401 | `key_expired`, `key_revoked` | The key passed its expiry date, or was revoked (a held call approved after the fact fails with `key_revoked`) | Rotate or recreate the key in the console | | 402 | `insufficient_credits` | The workspace has no credits left (or the in-flight reserve is exhausted) | Reload credits or turn on auto-reload | | 402 | `key_cap_reached` | The key reached its monthly spend cap | Raise the cap or use another key | | 402 | `tool_budget_reached` | The tool reached its monthly budget with pausing on | Raise the budget or turn pausing off | | 403 | `service_not_allowed` | The key is not scoped to this service | Edit the key's access | | 403 | `tool_disabled` | The tool is switched off for the workspace | Enable it under Tools & Connectors | | 403 | `account_suspended`, `account_closed` | The workspace is not active | Contact support | | 429 | `too_many_requests` | Rate limited at the edge | Honour `Retry-After` | ## Connector and approval codes | Status | Code | Meaning | | --- | --- | --- | | 202 | `approval_pending` | The write is held for a human; poll `/api/v1/connectors/approvals/{id}` | | 400 | `invalid_request`, `invalid_input`, `unknown_field`, `credential_required`, `invalid_credential`, `approval_requires_stored_credential`, `oauth_not_supported`, `expressions_not_supported` | Body shape, input validation, credential problems; a held write needs a stored credential, not an inline one | | 403 | `connector_disabled`, `connector_paused`, `action_disabled`, `key_not_allowed`, `transport_not_allowed` | Host catalogue switch, workspace pause, workspace guardrails | | 404 | `connector_not_found`, `action_not_found`, `credential_not_found`, `credential_type_not_found`, `approval_not_found` | | | 413 | `payload_too_large` | | | 422 | `connector_error` | The upstream rejected the request | | 429 | `rate_limited`, `daily_cap_reached` | Per-connector limits set in the console | | 502 / 504 | `connector_upstream_error`, `connector_timeout` | | ## LeanData codes See the [LeanData status codes](https://leantools.ai/doc/data#status-codes): `invalid_filter`, `subscription_required`, `dataset_not_found`, `response_too_large`, `provider_error` and the others. ## LeanModel (OpenAI envelope) | Code | Meaning | | --- | --- | | `authentication_error` | Invalid, missing or revoked API key | | `invalid_request_error` | Malformed request | | `model_not_found` | The named tier does not exist (404), or the catalogue has no usable tier (400); use `GET /api/v1/models/tiers` | | `model_not_configured` | The tier exists but the operator has not bound a model to it yet (400), for example a standard tier (`leanmodel-flash`, `leanmodel-default`) on a new host | | `capability_not_supported` | The tier's model cannot take this request (400): image or audio parts on a tier without vision or hearing, an edit on a tier without image input, transcription on a tier that does not offer it | | `unsupported_parameter` | A field LeanModel does not pass on (400), such as `mask` on image edits | | `invalid_input` | Malformed media (400): bad base64, an audio format other than wav or mp3, a clip or upload over the size limit | | `tier_not_in_package` | The package does not price the tier (403) | | `insufficient_credits` | No credits left (402, type `insufficient_quota`). On a realtime audio socket the relay sends it as an `error` event (`error.code` is `insufficient_credits`) and closes with code 1008 | | `too_many_streams`, `too_many_sockets` | The workspace's cap on open avatar streams or realtime sockets is reached (429) | | `stream_not_found` | No avatar stream with that id belongs to the workspace (404) | | `stream_closed` | The avatar stream has already been closed (409); create a new one | | `stream_not_ready` | The avatar stream is still being created (409); retry in a moment | | `upstream_error` | The avatar provider refused or failed the call (502); a stream that could not be created is not charged | | `model_unavailable`, `billing_unavailable` | The avatar's model is no longer set up, or billing could not be opened and the new stream was closed again (503); retry | | `upstream_unavailable` | The realtime model could not be reached (502, answered before the WebSocket upgrade); nothing is charged | | `websocket_required` | The realtime endpoint was called without a WebSocket upgrade (400) | | `server_error` | The AI provider failed | Embeddings (`POST /api/v1/embeddings`) answer in the same envelope with their own codes: `invalid_input` (400), `payload_too_large` (413), `not_configured` (503, no embedding model set up on the host) and `upstream_error` (502, the model failed; nothing is charged), besides the admission codes above. ## LeanBrowser statuses | Status | Meaning | | --- | --- | | 400 | Bad request (invalid JSON, missing fields) | | 401 / 402 / 403 | Credential, credits, scope or workspace status | | 404 | Route not matched, or a session that is not yours | | 408 | Request timeout | | 413 | Payload too large | | 429 | `too_many_sessions`: concurrent WebSocket limit (default 4 per workspace) | | 503 | System overloaded | ## LeanMachine, LeanVision and LeanMemory Those services have their own vocabularies (`duplicate_key`, `no_capacity`, `too_many_streams`, `policy_refused`, `storage_cap_exceeded`, `quota_exceeded`, `upstream_error`, `upstream_timeout`, …), listed with each service in the [reference](https://leantools.ai/doc/api). --- # Workspace console Console The console at [/app](https://leantools.ai/app) is where a team runs its workspace: switch tools on, link connectors, create keys, invite members, reload credits and watch every call. This page walks through each area. ## Home **Get started** is the default tab while the workspace has no successful tool call. It shows the endpoint and default key, harness snippets for Claude, Codex, any MCP client and the CLI, a live "listening" block that turns green on the first call, and a checklist (key, harness, first call, connector). After the first call, **Dashboard** becomes the default: calls and spend over 24 hours, 7 or 30 days, a stacked chart by tool, spend by API key, the live call stream and the credit meter with runway. ## Tools & Connectors - **Native tools** are cards with an on/off switch and a configure drawer: monthly budget (with optional pausing), tool settings and the functions exposed. - **Connectors** show their link state (not linked, linked, needs attention). The drawer has four tabs: Credentials (connect, set the default, disconnect), Functions (expose or hide each action), Guardrails (rate limit, daily cap, allowed keys, require approval for writes) and Activity (recent calls and held requests for this connector). - **Datasets** list what the workspace may query with the price at its rates; subscription datasets are subscribed and cancelled here. - The **MCP manifest** view shows exactly what `tools/list` returns right now, so you can check what an agent sees before it runs. Toggles are optimistic with an undo toast; every change is written to the audit trail with the member's name. ## Activity Three tabs. **Tool calls** lists every metered call (time, function, key, status, latency, units, cost) with filters by tool, key, status and range, live refresh, paging and a CSV export; clicking a row opens its metering detail. **Approvals** is the queue of held writes with the request payload, an optional note, and Approve & run / Deny for members who may decide; decided requests stay for the audit trail. **Credit ledger** shows every non-usage credit movement: grants, reloads, refunds, adjustments, expiries. ## API keys Settings → API keys. One key per agent keeps spend and access easy to reason about. - **Create** with a name, access ("everything enabled" or selected tools), an optional monthly cap and an optional expiry (30, 90 or 365 days). The plaintext is shown once in a reveal banner. - **Edit** access and cap at any time; **rotate** to issue a fresh key with the same settings and revoke the old one; **revoke** to stop it immediately. - The table shows month spend against cap, last use and an expiry warning two weeks out. Revoked keys stay listed for the record. - Owners and Admins manage every key; Developers create and manage their own; Billing and Viewer members see the list. ## Members and roles Settings → Members. Seats are free; you only pay for what agents call. Invite by email (several at once) with a role; the invitation link expires after seven days and can be resent or revoked. When the host has no email delivery configured, the console shows the link to copy instead. | Role | Can | | --- | --- | | Owner | Everything: tools, keys, members, billing, approvals, renaming and closing the workspace. The last Owner cannot leave. | | Admin | Tools and connectors, all keys, members (except promoting to Owner), approvals. Billing is reserved to Owners and Billing members. | | Developer | Tools and connectors, their own keys, activity. | | Billing | Billing and credits, activity. | | Viewer | Read-only access to the whole console. | A member can belong to several workspaces and switch between them from the sidebar. Removing a member ends console access at once; keys they created keep working until revoked. ## Billing and auto-reload Settings → Billing & credits shows the prepaid balance, runway at the current burn rate, spend over 30 days and the credit breakdown by bucket. - **Reload** a preset or any custom amount within the host's bounds, from a saved card. The summary shows credits added and the balance after; every paid reload has a numbered receipt. - **Payment methods** are stored by the payment provider; the console keeps only brand, last four digits and expiry. Set a default, add more, remove any. - **Invoice** for amounts at or above the invoice threshold: the order waits for the bank transfer and credits arrive when an operator confirms payment. - **Auto-reload**: when the balance drops below a threshold, add a fixed amount from the default card, capped by a monthly limit. Month-to-date auto-reload spend is shown against the limit. - **Alerts**: an email to the receipt address when runway falls under seven days, and a console banner under a balance you choose. - **Credit history** lists reloads, grants and invoices with status, receipt links and a CSV export. ## Approvals When a connector requires approval for writes, an agent's write call answers `202 approval_pending` and the request lands in Activity → Approvals (a badge in the sidebar and a notification count the pending items). Owners and Admins see the payload, add a note, and approve (the action runs now with the workspace credential and is billed) or deny (the agent receives a denial and nothing is written). Requests expire after `LeanTools.Tenant.ApprovalExpiryMinutes`. Agents poll the outcome at `GET /api/v1/connectors/approvals/{id}`; see [Connectors and approvals](https://leantools.ai/doc/connectors#approvals). ## Workspace settings Settings → Workspace: rename the workspace, set its country (used for tax treatment on receipts), see the endpoint and identifiers, leave the workspace, or close it. Closing revokes every key, removes every member and forfeits unused credit; it asks you to type the workspace name to confirm. ## For operators The host (operator) portal at [/host](https://leantools.ai/host) manages accounts, packages and rate cards, the service catalogue, platform settings, billing orders (confirming invoice payments) and contact requests from the public site. Self-service sign-up can be switched off with the setting `LeanTools.Tenant.AllowSignup`; the public MCP URL and docs URL shown in the console come from `LeanTools.Tenant.PublicMcpUrl` and `LeanTools.Tenant.DocsUrl`.