Skip to content
Docs

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.

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
  • queryrequiredThe search query.
  • pageint1-based result page.
  • resultCountintMaximum number of results.
  • languagestringLanguage 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#

  • 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#

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/scrapeelements[{ 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.

CatalogueScopeStandard tiersEndpoints (under /api/v1)Priced per
LeanTools Text GenTextAssistantleanmodel-flash (default), leanmodel-proPOST /chat/completions (text, image and audio parts in; text and tool calls out; streaming), POST /audio/transcriptions1,000,000 input / output tokens; audio minute
LeanTools Image GenImageAssistantleanmodel-defaultPOST /images/generations, POST /images/editsimage
LeanTools Speech GenSpeechAssistantleanmodel-defaultPOST /audio/speech1,000 characters
LeanTools Music GenMusicAssistantleanmodel-defaultPOST /audio/musicsecond produced
LeanTools Video GenVideoAssistantleanmodel-defaultPOST /videos/generationssecond produced
LeanTools AvatarAvatarAssistantleanmodel-defaultPOST /avatar/streams, then /sdp, /ice, /speak and DELETE on the streamconnected minute
LeanTools Realtime AudioRealtimeAssistantleanmodel-defaultGET /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_audio for listening.
  • input_audiopart{ "type": "input_audio", "input_audio": { "data": "<base64>", "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.
  • modelstringTier name from /models/tiers; unknown names are 404 model_not_found.
  • stream, temperature, max_tokens, top_pStandard sampling and streaming options (SSE with data: [DONE]).
  • tools, tool_choiceFunction 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).
  • propertiesobjectValues 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.

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"]}'
  • inputrequiredA 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.
  • modelstringAccepted for compatibility; the bound model answers.
  • encoding_formatstringfloat (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, bodyrequiredRecipient, subject and body (HTML when isBodyHtml is true, the default).
  • from, fromName, toName, cc, bccOptional addresses and display names; from defaults 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.
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_<dim>). Self-hosted deployments can point their data engine at LeanMemory and need nothing else.

MeterUnitDefaultWhat is counted
storage_gb_monthGB-hours, priced per 7300.04Bytes held, sampled hourly; accrues into overdraft, nothing is deleted
compute_secondseconds, priced per 1,0000.2Engine wall-clock time per query, load or action
ingest_gbper GiB0.01Bulk loads, document and event ingest, row writes
egress_gbper GiB0.05Query results, document and row reads
managementfree0Health, 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=<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.

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<upload_url>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.

Updated October 14, 2026 · This page as Markdown · All docs as one file