REST API
Reference
Every service over plain HTTP under /api/v1, authenticated with Authorization: Bearer lt-…. The interactive OpenAPI description lives at /swagger.
Each section names the API key scope it needs. LeanData, LeanConnect connectors and the tools API have their own pages: LeanData, Connectors and approvals, Tools API.
Web Search#
Part of LeanConnect; scope Search. The concrete engine (Bing, Google, DuckDuckGo, Brave, …) is chosen server-side; callers never pick it.
request per callqueryrequiredThe search query.pageint1-based result page.resultCountintMaximum number of results.languagestringLanguage code, e.g.en.
{ "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#
sessionKeyqueryPersistent session key (1–19 characters) that reuses cookies and local storage across requests. Private to your workspace.trackingIdqueryOptional label for grouping sessions.blockAds,timeoutqueryBlock known ad domains; request timeout in ms (capped at 300000).url,gotoOptions,cookies,userAgent,authenticate,setExtraHTTPHeaders,waitForSelector,waitForTimeout,bestAttemptbodyNavigation and wait options accepted by most POST routes.
Routes#
encoding: "base64"; selector, scrollPage, options.fullPage, options.clipoptions.format, landscape, printBackground, margin, pageRangeselements[{ selector, timeout }] → text, html, attributes and box per match; optional debugOpts (html, screenshot, cookies, network)code in the page with a context object; JSON, HTML or text backbudgets[]task, url, openai_api_key or base_url, model_id, max_steps, use_vision → { result, success, steps_used }PUT /json/new?url= opens a tab/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);messagesrequiredChat messages; content may be a string or parts:text,image_url(a URL or data URI) for vision,input_audiofor listening.input_audiopart{ "type": "input_audio", "input_audio": { "data": "<base64>", "format": "wav" | "mp3" } }. Bad base64, another format or a clip over 25 MB is400 invalid_input; a tier whose model takes no audio is400 capability_not_supported. Audio parts add anaudio_minuteline to the call's tokens.modelstringTier name from/models/tiers; unknown names are404 model_not_found.stream,temperature,max_tokens,top_pStandard sampling and streaming options (SSE withdata: [DONE]).tools,tool_choiceFunction definitions; the model may answertool_callswithfinish_reason: "tool_calls", and you send the results back asrole: "tool"messages.response_format,disable_thinking{ "type": "json_object" }for JSON output; turn reasoning off for this call (wins over theReasoningsetting).propertiesobjectValues for the settings the tier offers, e.g.{ "Reasoning": "Disabled" }; the tiers listing names them underproperties.
{ "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#
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 https://HOST/api/v1/audio/transcriptions -H "Authorization: Bearer lt-your-api-key" \
-F file=@recording.mp3 -F model=leanmodel-flash -F language=en{ "text": "Hello, this is a transcription of the recording.", "usage": { "credits_charged": 0.6, "cost_usd": 0.006 } }Images#
{ model, prompt, n (1–4), size, response_format ("b64_json"), properties } → { created, model, data: [ { b64_json, revised_prompt } ], usage }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 responseOne 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=1024x1024curl 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…"}]}'{ "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#
{ 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 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#
{ 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 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}'{ "created": 1760400000, "model": "leanmodel-default",
"data": [ { "b64_json": "SUQzBAAAAAAA…", "mime_type": "audio/mpeg", "seconds": 30 } ],
"usage": { "credits_charged": 6, "cost_usd": 0.06 } }Video#
{ 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 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"}'{ "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.
{ model, stream_warmup, fluent, compatibility_mode, properties } → 201 { id, session_id, offer: { type: "offer", sdp }, ice_servers: [ … ], model }{ answer: { type: "answer", sdp } } → the provider's answer{ candidate, sdpMid, sdpMLineIndex } → the provider's answer{ text } (at most 4,000 characters) → the provider's answer{ id, minutes, credits }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.
model is a realtime tier (the default tier when omitted)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 offeredconst 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#
model, filtered to those the workspace's package prices; ?service= narrows the list to one catalogue (free){ "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.
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.
{ model, input, encoding_format } → { object: "list", data: [{ object: "embedding", index, embedding }], model, usage }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"]}'inputrequiredA string, or an array of up to 256 non-empty strings;data[i].indexis the position of each input. Token arrays are not supported.modelstringAccepted for compatibility; the bound model answers.encoding_formatstringfloat(default) orbase64(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.
to,subject,bodyrequiredRecipient, subject and body (HTML whenisBodyHtmlis true, the default).from,fromName,toName,cc,bccOptional addresses and display names;fromdefaults to the host's sender.waitForResponseboolWait for the provider to accept the message (surfaces provider errors); default fire-and-forget.attachmentsarray[{ name, contentBase64 }]; forwarded by SMTP and MailTrap senders.
{ "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.
{ isHealthy, enabledPoolCount, poolsWithCapacityCount }; 503 when no pool has capacity{ key, instanceType: Session|User|Permanent, tag } → 201 instance?includeDisposed=) or get, refreshed from the provider{ "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.
models[]camera.url (RTSP, "test" or upload:{id}) → 201 streamCodes: 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_<dim>). 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 |
{ engineUrl, flight { host, port, tls }, workspace, databasePrefix, engineVersion, limits }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=<flight host>;Port=<port>;Tls=true;Database=<prefix>_warehouse;ApiKey=<api key>.
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.
{ used_bytes, reserved_bytes, quota_bytes, object_count, credit_per_gb_month, gb_hours_billed, max_object_bytes, url_ttl_seconds, transfer }{ key, size, content_type } → 201 { upload_id, upload_url, method, headers, expires_utc, commit_url }download_url (range honoured); ?redirect=true → 302{ 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.