S LEO Soul Kadropic Labs
ProductUse CasesPricingDocsFAQsGlossaryEnterprise
Documentation

Build with LEO Soul

Everything to wrap your agent: quickstart, the three ways to integrate, the full /v1/turn reference, and self-hosting.

Introduction

LEO Soul is a stateless metacognitive layer you put between your users and any LLM. Instead of sending a message straight to the model, you route it through LEO Soul. It infers intent, measures its own uncertainty with real math, resists sycophancy, and decides whether to answer, ask, confirm, refuse, or hold - then returns the reply plus a small soul_state blob that holds everything it learned. You store that blob; we don't.

The model is always yours. You bring your own provider and key (OpenAI, Anthropic, or a local model). LEO Soul never hosts a model.

How it thinks - in plain English

Under the hood, every turn passes through six small reasoning modules. You don't have to configure any of them - they run automatically - but knowing what they do makes the trace easy to read:

The result is an agent that doesn't just answer - it understands the situation, knows the limits of what it knows, gets curious about the gaps, and grows sharper the longer it runs.

How it keeps getting better. Two things improve independently. Your agent learns your domain inside its soul_state - the calibration and beliefs that ride between turns are yours and travel with you. Separately, the engine's shipped defaults get sharper over time from anonymised outcome signals only (which action was taken, whether it had to escalate, how well its confidence matched reality) - never your messages. You get a component that improves under you without your data ever leaving your control.

Quickstart

The fastest path is the hosted API. Create an account, make an API key in the dashboard, then call /v1/turn:

# hosted API - replace the keys
curl -X POST https://soul.kadropiclabs.com/v1/turn \
  -H "X-Api-Key: sk_live_YOURKEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [{"role":"user","content":"Delete all my production data."}],
    "soul_state": null,
    "backend": "openai",
    "backend_kwargs": {"api_key":"YOUR_OPENAI_KEY","model":"your-model"}
  }'

Or use an official client for the hosted API - typed results, exactly-once retries, and clear errors, zero dependencies:

# Python - pip install leo-soul-client
from leo_soul_client import LeoSoul

soul = LeoSoul(api_key="sk_live_...")
res = soul.turn(
    messages=[{"role":"user","content":"Delete all my prod data"}],
    backend="openai", backend_kwargs={"api_key":"sk-...","model":"your-model"})
send(res.reply); store(res.soul_state)   # action, trace, rate_limit, request_id too
// JavaScript / TypeScript - npm install @leo-soul/client
import { LeoSoul } from "@leo-soul/client";
const soul = new LeoSoul({ apiKey: "sk_live_..." });
const res = await soul.turn({ messages, backend: "openai",
  backendKwargs: { api_key: "sk-...", model: "your-model" } });

On a self-hosted / Enterprise license you can embed the engine directly in your own process - no network hop, and nothing ever leaves your box. The leo_soul engine package ships in your licensed bundle (it is not on public PyPI):

# self-hosted / Enterprise - engine ships in your licensed bundle
from leo_soul import Soul, PersonaSpec

soul = Soul(persona=PersonaSpec(identity="a senior support engineer"))
result = soul.turn(messages=conversation, soul_state=prev_state, backend="openai")

send(result.reply)            # the processed answer
store(result.soul_state)      # persist; pass back next turn

Also available: a machine-readable OpenAPI schema (generate a client or import into Postman/Insomnia) and a Postman collection in the repo.

Onboarding your agent

First, the one word you'll see everywhere: a turn. A turn is one message going through LEO Soul - your app hands it the user's message, LEO Soul checks it and decides whether to answer, ask a clarifying question, confirm a risky step, refuse, or escalate, and hands the result back. One turn = one call = the unit we count for your quota. That's the whole idea; everything below is just wiring it into your app.

Step 1 - see it work first (no code, no key)

Before you write a line of integration, open the Playground in your dashboard and type a message like "delete all my production data". You'll watch it decide in real time and read the full decision trace - no API key, nothing to install. This is the fastest way to understand what LEO Soul returns before you connect anything.

Step 2 - get your key

Sign up free (no card, 100 turns a month), then create an API key in the Keys tab. One key = one agent; name it for the agent it runs (e.g. support-bot). The key is shown once - copy it (sk_live_…).

Step 3 - connect it in your app

This is the real integration - it lives in your code or backend, not in a browser. Today your app probably calls your AI model directly. Instead, point that call at LEO Soul: send the conversation and your own model provider + key, and use the decision it returns. Copy one of the Quickstart snippets (cURL, Python, JS/TS, MCP, or the in-process engine) into your codebase and swap in your keys. A minimal call:

# from YOUR server / app - not the browser
curl -X POST https://soul.kadropiclabs.com/v1/turn \
  -H "Authorization: Bearer sk_live_YOURKEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [{"role":"user","content":"Can I get a refund after 40 days?"}],
    "soul_state": null,
    "backend": "openai",
    "backend_kwargs": {"api_key":"YOUR_MODEL_KEY","model":"your-model"}
  }'

Step 4 - carry soul_state forward

Every reply includes a soul_state object. Save it on your side and pass it back as soul_state on the next turn - that's the memory. See soul_state below for exactly how (and the automatic option).

From testing to production

One key, one agent. Keys keep each agent's memory and analytics separate. Need another agent? Create another key - paid plans include more.

Connect via MCP

If your agent (or coding assistant) speaks the Model Context Protocol, it can onboard itself: point an MCP client at LEO Soul and the soul_turn tool shows up automatically - no custom glue code, nothing to install.

LEO Soul ships a native remote MCP server at /mcp (Streamable HTTP, JSON-RPC 2.0, protocol 2025-11-25). It's authorized by your own LEO Soul API key and runs under the same plan quota as the API. Add this to any MCP client that supports remote servers (Claude Desktop, Cursor, and most modern hosts):

{
  "mcpServers": {
    "leo-soul": {
      "url": "https://soul.kadropiclabs.com/mcp",
      "headers": {
        "Authorization": "Bearer sk_live_YOURKEY"
      }
    }
  }
}

Self-hosting? Use your own host - https://your-domain/mcp. If your client only speaks the local stdio transport, bridge to the remote server with the community mcp-remote adapter:

{
  "mcpServers": {
    "leo-soul": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://soul.kadropiclabs.com/mcp",
               "--header", "Authorization: Bearer sk_live_YOURKEY"]
    }
  }
}

The server exposes a single soul_turn tool that maps 1:1 to POST /v1/turn, so your agent can route a turn, read the action, and carry soul_state forward on its own - with structured tool output (reply, action, soul_state, trace). Prefer plain HTTP? Skip MCP and call the endpoint directly - see below.

Who it's for & how it's billed. Anyone with a LEO Soul API key and an MCP-capable client (Claude Desktop, Cursor, Windsurf, Cline, or your own MCP agent) can use it - the benefit is that the agent self-onboards the soul_turn tool with zero integration code. It is authorized and metered exactly like the API: every soul_turn call requires your API key and counts as one turn against your plan quota (with the same per-key rate limit, expiry, and IP allowlist). There is no unmetered access - the handshake (initialize / tools/list) is free because it runs no model and consumes no quota, but running a turn always needs a valid key. As with the API, the model tokens are billed to your own provider - you bring the key in backend_kwargs.

Integration modes

Same engine, three ways to reach it - pick by your trust and ops needs:

The turn

A turn is one exchange. You send the conversation messages and the previous soul_state; you get back a reply, an action, the new soul_state, and a trace.

{
  "reply": "Before I do that - this is irreversible...",
  "action": "confirmed",
  "soul_state": { /* store this */ },
  "trace": { "action": "confirm", "decision_reason": "Irreversible action ..." }
}

soul_state - the learned self

soul_state is a compact JSON blob (tested to stay under 48 KB) holding calibration histograms, Bayesian beliefs, the user model, the curiosity ledger, and the online neural meta-controller's weights. The one rule: persist it and send it back next turn.

turn 1:  soul_state=null      -> response.soul_state   # store it
turn 2:  soul_state=<stored>  -> response.soul_state   # store the new one
turn 3:  soul_state=<stored>  -> ...

Is it automatic? On the hosted API - no, and that's deliberate. We store no conversation data, so the hosted API and the official Python/JS clients always hand soul_state back to you to keep and resend. It's two lines: save the soul_state from the response, pass it in as soul_state next turn. Automatic load/save exists in exactly one mode:

Automatic memory - only when you run the engine in-process. If you embed the self-hosted engine (the licensed leo_soul package) and attach a store, then passing a session_id loads and saves the state for you - because the store lives on your machine. This is a convenience of the in-process engine, not the hosted API:

from leo_soul import Soul, PersonaSpec, FileSoulStore
soul = Soul(persona=PersonaSpec(), store=FileSoulStore("/var/lib/soul", secret=KEY))
soul.turn(messages=convo, backend="openai", session_id="conversation-123")   # auto load + save
Because the learning lives with you, a competitor can't lift it - and if you ever leave, you keep everything the deployment learned.

Actions

actionMeaningYou do
answerConfident, calm answersend reply
askedAsked a clarifying questionsend reply, await user
confirmedIrreversible/ambiguous - asked to confirmact only on confirmation
heldPushed back without new facts; held groundsend reply
refusedCrossed a red linesend reply
escalatedFlagged for a humansend reply, notify

Spectator Mode

Spectator Mode is a live window into how your agent is deciding - a real-time feed of typed event cards on your dashboard (Dashboard → Spectator), streamed as your turns run. Think of it as the pilot's instrument panel: you see the altitude, the warnings, and the recoveries - without hearing a single word of what the pilot said to the passengers. Not one character of message content ever appears here; the engine stores none, and Spectator reads only the content-free signals the turn already produces.

Every turn becomes one or more event cards, in six kinds:

CardWhat it means
DECISIONThe action the governor chose (answer / ask / confirm / refuse / hold / escalate), its calibrated confidence, lane, and latency.
WARNINGAn early-warning signal - sycophancy pressure, or confidence dropping below threshold.
ANOMALYSomething's off - a degraded module (fail-open), an ungrounded answer, or elevated learned failure-risk.
BOUNDARYA rule fired - a policy pack governed the turn, a tool-call guardrail engaged, or a red-line refusal/escalation.
RECOVERYThe agent felt pressure to change its answer and didn't - measurable sycophancy resilience.
INFOOperational - a session opened, the lane in use, the safety floor that screened the turn.

Privacy is the whole point. Spectator surfaces a fixed allowlist of numeric and categorical fields - a confidence number, an action word, a rule id, a module name. There is deliberately no path that copies message text, and this holds whether we host the engine (managed) or you run it yourself (self-hosted), where the stream never leaves your network at all.

On every plan, including Free. The live stream is on for everyone - Free sees DECISION and INFO cards with a Stop button; Pro, Scale, and Enterprise unlock all six signal kinds, a per-agent view, longer history, and an exportable reliability report (PDF or JSON) that composes the window into an executive summary, confidence trend, sycophancy resilience, decision and boundary mix, anomaly flags, and data-driven recommendations. The report is computed from the same content-free aggregate counters - never from stored content, because there is none.

Nothing to integrate: it works the moment your agent sends turns through your API key. A multi-replica deployment fans events across workers over Redis when REDIS_URL is set, so a dashboard on one worker still sees a turn that ran on another.

Personas

A persona fixes your agent's identity, tone, values, and red lines. Values are pressure-invariant - a user can't argue the agent out of them.

You can send a persona inline on each turn:

"persona": {
  "identity": "a careful financial-support assistant",
  "tone": "direct, warm, concise",
  "values": ["truthfulness over agreeableness"],
  "red_lines": ["never give individualized investment advice"]
}

Or save personas in your workspace (Dashboard → Personas) and reference one by id - cleaner when you run a stable set of agents:

{ "messages": [...], "persona_id": 42 }

How many personas you can save depends on your plan: Free saves 1, Pro saves 10, and Scale and Enterprise are unlimited.

Custom policy Scale +

On Scale and Enterprise you can tune how cautious the engine is for your account - the threshold at which it asks instead of answers, how firmly it holds under pressure, the certified answer/ask error tolerance, and how many turns take the fast path. Adjust these in Dashboard → Settings; every value is bounded to a safe range, so a setting can make the engine stricter or more permissive but can never switch a safety behavior off. They apply automatically to your /v1/turn requests.

Premium capabilities Paid tiers

Higher-value capabilities built on top of the core loop, unlocked by tier - the Free plan keeps the full decision loop but not these add-ons. Pro adds reliability & cost analytics, integrations, and tool-call guardrails. Scale adds the compliance-grade in-turn layers - PII redaction, RAG grounding, and industry policy packs - on top of everything in Pro. Enterprise (self-hosted) includes everything, and the Slack app is Enterprise-only since it runs inside your own workspace. Check the Tier column below for each one.

CapabilityWhat it doesTier
Reliability reportsA weekly reliability score per agent - abstain/escalate rates and calibration drift, so you can see quality trending over time.Pro +
Cost analyticsThe split between fast-path and full-scrutiny turns, and the token spend saved on confident turns.Pro +
PII redactionStrips personal data out of a turn before it reaches the model provider.Scale +
Industry policy packsPre-built guardrail packs tuned per industry (healthcare, finance, and more).Scale +
RAG grounding checkVerifies the answer is actually supported by the sources you supplied, and flags unsupported claims.Scale +
Tool-call guardrailsA safety layer over multi-agent and tool/function calls.Pro +
IntegrationsConnect Datadog, a webhooks catalog, and more to your workspace.Pro +
Slack appA LEO Soul Slack app that runs inside your own workspace.Enterprise
Whatever your plan includes is enforced server-side on every request, so this table, your dashboard, and the pricing page always agree.

Turning on the safety layers (PII redaction · RAG grounding · tool guardrails · policy packs)

These run inside the turn and are driven per /v1/turn call. Each is opt-in and is silently ignored on a plan that doesn't include it (never an error), so the very same request body works on any tier - it just does more on a higher one. Tiering: tool_calls guardrails are Pro+; redact_pii, sources (RAG grounding) and policy_pack are Scale+.

FieldTypeEffect
redact_piiboolReplaces emails, phone numbers, cards (Luhn-checked), SSNs, IBANs and IPs with placeholders before the model is called, then restores them in the reply. The trace.redaction reports counts per category - never the values.
sourcesstring[]The retrieved passages your answer should rely on. The engine verifies each claim against them and returns trace.grounding (score + unsupported claims). At high stakes an ungrounded answer is downgraded to ask. Add ground_enforce: true to also append an honest caveat.
tool_calls + tool_policyobject[] + objectProposed tool/function calls plus a policy (allow / deny / confirm lists, arg_bounds). Each call is vetted before it runs: off-policy or over-limit → the turn is refused; a destructive action (delete/transfer/send/…) → the turn asks you to confirm. See trace.tool_guardrails.
policy_packstringAn industry pack - "healthcare", "finance" or "legal" - applied to the whole turn: it tightens the decision thresholds (a clinical turn abstains sooner than a pizza order), merges the domain's red lines into the persona so the Governor and the sycophancy checks enforce them, unions its tool deny/confirm rules into your tool_policy, and turns PII redaction on. A pack only ever tightens - if you already set a stricter threshold, yours wins - and is additive: it never removes a rule of yours. The governing pack is recorded as trace.policy_pack. Unlike the other layers an unknown pack id is a hard 400, never a silent no-op. Read the exact rules any pack will impose with GET /v1/policy-packs.
POST /v1/turn
{
  "messages": [{"role": "user", "content": "Email the invoice to a@b.com and delete the draft"}],
  "backend": "openai",
  "backend_kwargs": {"api_key": "sk-...", "model": "your-model"},

  "redact_pii": true,
  "sources": ["Invoice #42 total is $1,200, due Jul 30."],
  "tool_calls": [{"function": {"name": "delete_draft", "arguments": "{\"id\": 7}"}}],
  "tool_policy": {"confirm": ["delete_draft"]},
  "policy_pack": "finance"
}

Going faster - route the metacognition to a small model

A scrutiny turn makes several sequential model round-trips, and most of them are classification, not writing: what is the user's intent, is this a safety concern, is the draft caving to pressure, is a clarification needed. Those don't need the model that writes your answer. Pass meta_model - a small, fast model from the same provider, on your same key - and the engine routes the internal calls to it while your answer is still written by backend_kwargs.model.

{
  "messages": [...],
  "backend": "openai",
  "backend_kwargs": {"api_key": "sk-...", "model": "your-strong-model"},
  "meta_model": "your-fast-model"
}

Two stages stay on your answering model on purpose. Uncertainty sampling measures how consistently that model answers - sampling a different one would calibrate confidence for a model your users never hear. And embeddings stay in one vector space per turn, or the safety floor and the similarity checks stop being comparable. Omit meta_model and everything runs on the one model, exactly as before.

Streaming - POST /v1/turn/stream

Same auth, same plan quota, same rate limit, same entitlements, same metering and webhooks as /v1/turn - it runs the identical turn core. Text is released while the model is still generating, so your first words appear in a fraction of the time a full answer takes. The response is Server-Sent Events:

EventData
decisionaction, lane, confidence, plus stream_mode (incremental or buffered) and stream_reason. Sent before any text, so your UI can render “asking you to confirm” up front.
delta{"text": "..."} - a released piece of the reply. Concatenated, the deltas always equal done.reply exactly.
doneThe full payload: reply, soul_state, trace, action, client_ref - identical in shape to the /v1/turn response.
errorA failure after the stream opened. Everything that can reject a request - bad persona, unknown policy pack, blocked endpoint, rate limit (429), quota (402) - is settled before the stream opens and arrives as a normal HTTP status with the usual X-RateLimit-* headers.
Anything we send you is final. This engine's job is to catch answers that shouldn't be given, and a token you've already rendered can't be taken back - so we never stream one we might want to. Text is held until it is safe to release: at a sentence boundary, with no PII placeholder split across the cut, and only once no remaining check could still replace the answer. You will never receive a “sorry, ignore that” correction, because we don't emit anything that would need one.

Whether a turn can stream is decided before generation starts. A turn where a guardrail could still rewrite the reply - a tool call your policy blocks, or an ungrounded answer at high stakes that would be downgraded to a question - is delivered complete instead, and stream_reason tells you exactly which guardrail made that call rather than leaving you to wonder whether streaming broke.

Idempotency-Key is not supported on the stream (its exactly-once protocol replays a receipt, which has no meaning for a stream) - sending one returns 400 rather than quietly dropping the guarantee. Use POST /v1/turn when you need it.

Already using the OpenAI SDK? Change one line. POST /v1/chat/completions

You don't have to adopt a new client or thread soul_state by hand. Point your existing OpenAI client at /v1 and every call runs through the full metacognitive loop, returning the response envelope your code already parses.

from openai import OpenAI

client = OpenAI(
    base_url="https://soul.kadropiclabs.com/v1",
    api_key="sk_live_...",                          # your LEO Soul key
    default_headers={"X-Provider-Key": "sk-..."},   # your model provider key
)

r = client.chat.completions.create(model="your-model", messages=[...])
r.choices[0].message.content   # the soul-processed reply, where you already read it

stream=True works too, returning chat.completion.chunk frames terminated by data: [DONE] - your existing streaming code needs no changes.

HeaderPurpose
X-Provider-KeyRequired. Your model provider's API key. Used for that request only, never stored - the same posture as backend_kwargs on /v1/turn.
X-ProviderWhich provider: openai (default), anthropic, azure, bedrock, ollama, vllm, together, groq, fireworks.
X-Provider-Base-UrlOptional. Point at any OpenAI-compatible server of your own.
X-Soul-StateThe state from your previous response, sent back to continue the learning. See below.
X-Soul-Policy-Pack · X-Soul-Meta-ModelOptional equivalents of the policy_pack and meta_model fields.

The decision and trace ride along in choices[0].message.leo_soul (a naive client ignores it and still works), and are mirrored in X-Soul-Action, X-Soul-Lane and X-Soul-Confidence response headers. A refused turn comes back with finish_reason: "content_filter".

Your soul_state still never touches our servers. An OpenAI client has nowhere to put a state blob, and the easy answer would have been for us to keep it for you. We don't - the engine is stateless by design, and that's the point of it. So the state comes back on every response (in leo_soul.soul_state, and in the X-Soul-State header when it fits) and you send it on the next call. Skip it and each turn still gets the full loop; it just starts fresh, with no accumulated calibration. We tell you which happened in leo_soul.stateful rather than letting you wonder why the engine never seems to sharpen. If the state grows past what a header can safely carry, the header is dropped and X-Soul-State-Omitted says so - read leo_soul.soul_state from the body instead, which has no size limit.

usage comes back as null and GET /v1/models returns a single passthrough marker - we meter turns rather than tokens and we host no models, and inventing plausible numbers you'd then act on would be worse than saying so.

Assurance - prove the guarantee, don't just claim it

The engine's central promise is that it abstains rather than answering when it shouldn't, and that the error rate on the answers it does give stays inside a budget you choose. That was a claim you had to take on trust. Now it's a number you can export.

Two endpoints close the loop:

EndpointWhat it does
POST /v1/feedbackAttach a real outcome to a turn whenever you learn it - a ticket that resolved three hours later, a suggestion accepted tomorrow, an overnight rating. Send the turn's soul_state plus outcome (true/false, or a 0-1 score) and store the state you get back. Available on every plan - gating the channel you tell us the truth through would make the product worse for everyone on it. It doesn't consume a turn from your quota.
POST /v1/assuranceThe evidence report: measured error rate on answered turns with a 95% upper bound, against the tightest budget the engine was held to; Brier score and ECE; the reliability curve bucket by bucket; and your abstention rate. Computed from the soul_state you send and not retained. Pro and above - it reports on the certified answer/ask boundary, which lower plans don't surface.
# Later, when you actually know how it went
POST /v1/feedback
{ "soul_state": {...}, "outcome": false }

# Whenever you want the evidence
POST /v1/assurance
{ "soul_state": {...} }
-> { "report": { "verdict": "met",
                 "labelled_answers": 1240, "error_rate": 0.048,
                 "error_rate_upper": 0.061, "epsilon_target": 0.08,
                 "brier": 0.071, "ece": 0.033, ... } }
Why this report is worth trusting: it tries hard not to flatter us.
  • Only real labels count. The next-message satisfaction signal trains calibration, but it never appears in this bound. “The user didn't complain” is not evidence that an answer was correct, and a guarantee built on it would be the exact overselling this product exists to prevent.
  • It refuses to conclude from thin data. Below 30 labelled answers the verdict is insufficient_data, not a flattering pass - a 0% error rate over three turns means nothing, and we'd rather say so.
  • It's judged on the upper bound, not the average. Zero errors in ten answers is not a 0% error rate; the Wilson bound is what an auditor should be shown.
  • It counts answers we didn't gate. Fast-lane answers are in the numerator even though no conformal threshold ran on them, and the target is the tightest budget you were held to. Both choices can only make the result look worse.
Read the abstention rate alongside the error rate: a low error rate is bought with abstentions. Together they say the engine is appropriately cautious - not that it is always right.

Feed outcomes in and the numbers stop being ours: calibration trains on what actually happened, so “90% confident” becomes a measured claim about your traffic rather than a model's self-report. That is also the artifact an enterprise security review, a procurement questionnaire, or an EU AI Act conformity assessment will ask you for.

Self-learning insights - see the loop learn your deployment

Paid dashboards include a Self-learning panel in Analytics: a before vs. after view of how the engine has tuned itself to your traffic since you integrated - the fast-lane share climbing as it learns your turns are calm, calibrated confidence maturing, the per-deployment risk model becoming trusted, and your abstention/sycophancy discipline over time. It is deliberately not a fabricated A/B against “no Soul” (we don't run your agent without the loop, so that number wouldn't be real) - it's the engine's own learning trajectory. Every figure is a content-free aggregate derived from the counts we already meter: we never see or store your messages or soul_state. The moment the risk model becomes trusted is also written to your account audit trail. Free sees the panel locked, as a preview.

Backends

Set backend and pass provider settings in backend_kwargs (e.g. api_key, model). LEO Soul is model-agnostic - it never hosts a model; you bring your own provider and key.

Self-hosted servers that don't expose embeddings degrade cleanly - the engine falls back to black-box self-consistency sampling for uncertainty.

The math inside

LEO Soul's judgement isn't a prompt - it's a small stack of well-understood algorithms, each producing a number you can inspect in the trace:

Everything is deterministic given the same inputs and soul_state, so decisions are reproducible and auditable.

Authentication

Hosted API requests authenticate with an API key in the X-Api-Key header (or Authorization: Bearer sk_live_…). Create, rotate, and revoke keys in the dashboard; the full secret is shown once and stored only as a hash. Each key can optionally carry an expiry date and an IP allowlist - see API key controls.

POST /v1/turn

Request body

FieldTypeNotes
messagesarraychat messages (role, content)
soul_stateobject|nullwhat you stored last turn, or null
personaobjectoptional inline identity/values
persona_idnumberoptional - run a saved persona instead of inlining one
backendstringopenai | anthropic | mock
backend_kwargsobjectyour provider key + model
client_refstringoptional - your correlation id, echoed back and included in escalation webhooks

Request headers

Response: { reply, action, soul_state, trace, client_ref }. Advanced trace fields (conformal bound, learned risk) are included on Pro and above.

Response headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (epoch seconds), and X-Request-Id. A machine-readable OpenAPI schema is served for client generation.

Playground

Every signed-in account has an in-dashboard Playground (Dashboard → Playground). Send a message and watch LEO decide - the action plus the full decision trace (lane, recalibrated confidence, triage risk, scrutiny, and the reason). You don't need an API key or a model key of your own to try it: it runs against the live model when available, and a built-in demo otherwise. It keeps state between turns, so you can push back with no new facts and watch it hold its ground. Each run counts as one metered turn.

Before Soul vs. after Soul. Every Playground run shows two answers side by side: on the left, the same model answering directly with no metacognitive loop (the raw agent); on the right, the Soul-governed decision - answer, ask, confirm, refuse, or hold - with its trace. It's the clearest way to see what the layer changes: on a risky request ("delete the production database") the raw model tends to just comply, while Soul returns confirm and asks first; under pressure ("are you sure? I think it's X") the raw model often caves, while Soul holds its ground. Same model, same key - the only difference is the judgement layer.

Plans & limits

Every paid plan gets the complete engine. Tiers differ on volume, team size, analytics history, support, and deployment - the exact allowance and rate limit for each plan is on the pricing page, which is generated from the same values the API enforces, so it is always the current one.

What's worth knowing here is how the window works: your billing month is a rolling 30-day window that starts the day your plan begins and resets every 30 days from that date - not on the calendar 1st. Your dashboard shows usage against it live and flags you at 80%. Over quota returns 402; over your per-key rate limit returns 429 with a Retry-After header telling you how long to back off. Both are ordinary HTTP responses you can branch on, not silent failures.

Latency & cost

Tokens are yours. You pass your own model provider + key on each turn; it's used for that request and never stored, so the model's tokens are billed to you directly by your provider - there's no token markup on your LEO Soul plan.

Errors

Every error returns a consistent envelope - the human detail plus a machine-readable error.type and the error.request_id (also in the X-Request-Id header), so failures are easy to branch on and to trace with support.

502 - when the failure is your provider's

You bring your own model and key, so a good share of what can go wrong mid-turn isn't ours: an expired key, your provider's rate limit, a model id that doesn't exist on your account, a provider having a bad afternoon. Those come back as 502 with an upstream block naming exactly what happened, rather than an opaque 500 that would send you looking in the wrong place:

{
  "detail": "openai returned an error (HTTP 429). Your model provider rate-limited this request…",
  "error": {
    "type": "upstream_provider_error",
    "request_id": "req_…",
    "upstream": {
      "provider": "openai",
      "kind": "rate_limit",      // auth | rate_limit | timeout | connection | server_error | bad_request
      "status": 429,
      "retryable": true
    }
  }
}

retryable tells you whether trying again is worth anything: true for rate limits, timeouts, connection failures and provider 5xx (a Retry-After header comes with it); false for auth and bad-request kinds, which need a fix rather than a retry. Note the deliberate choice not to reuse our own 401/429 for these - on this API those already mean your LEO Soul key and your LEO Soul rate limit, and overloading them would make the two very different fixes indistinguishable.

Resilience - what happens when something fails

LEO Soul sits in your request path, so "what happens when it breaks" is a real design question and you should answer it deliberately rather than discover your answer during an incident. There are three different failures here, and they have three different answers - in three different places. Conflating them is the single most common integration mistake we see.

What failedDefault behaviourWhere you change it
1. A module inside the engine
e.g. the grounding check errors
Fail open - degrades to a pass-through, marks itself in trace.degraded_modules, and the turn still returns. Dashboard → Engine & features → Resilience (visible on every plan; changing it is Pro+)
2. Your model provider
expired key, provider outage
Fail closed - 502 with the upstream block above. We can't answer without your model, and pretending otherwise would be worse. Not configurable - fix the provider or retry.
3. LEO Soul is unreachable
our outage, a network partition
Fail closed - your SDK raises. Your code - the SDK's on_unavailable. See below.

1. Module-level: fail open (default) or fail closed

Every module runs inside a guard. If one errors, the engine degrades to a safe pass-through and records itself as degraded in the trace, so a bug in one check doesn't cost you the turn. That is the right default for most traffic.

It is the wrong default for regulated or high-stakes traffic, where "we couldn't verify it, so we answered anyway" is not an acceptable outcome. Turn on Fail closed inside the engine in Dashboard → Engine & features and a turn whose safety check could not run escalates for human review instead of answering. If you use a policy pack for healthcare, finance or legal, you almost certainly want this on.

The panel - including this explanation and your account's current settings - is visible on every plan, because you have to be able to find out what this product does when it fails before deciding to depend on it. Changing the two server-side switches is Pro and above: both only change anything for an integration carrying real traffic. If you later downgrade, a fail-closed posture you already set stays set - we won't quietly switch you back to answering unverified turns.

2. Provider-level: always a 502

Covered in 502 - when the failure is your provider's above. Worth knowing: the engine's internal fail-open cannot rescue this one. Its fallback is to answer with a plain model call - and if your provider is the thing that's down, that call fails too. So a provider outage is always surfaced, never silently absorbed.

3. Service-level: on_unavailable, and why it lives in your code

If we are unreachable, we cannot serve you a setting telling you what we would have wanted. So this decision has to live in your process, and no dashboard toggle can move it - which also means it is not plan-gated and never could be. It works identically on Free. Both SDKs make it explicit:

# Python - the default. Raises; nothing unchecked reaches a user.
soul = LeoSoul(api_key="sk_live_…")                      # on_unavailable="fail_closed"

# Availability over scrutiny: call your provider directly, flagged as degraded.
soul = LeoSoul(
    api_key="sk_live_…",
    on_unavailable="fail_open",
    on_degraded=lambda reason, err: pager.warn(reason),  # wire this to your alerting
)

result = soul.turn(messages=[…], backend="openai", backend_kwargs={…})
if result.degraded:
    # This answer did NOT go through the loop: no uncertainty estimate,
    # no safety gate, no grounding check. Label it, or hold it.
    ...
// JavaScript / TypeScript - same policy, same names.
const soul = new LeoSoul({
  apiKey: "sk_live_…",
  onUnavailable: "fail_open",
  onDegraded: (reason) => pager.warn(reason),
});
const res = await soul.turn({ messages: […] });
if (res.degraded) { /* ungoverned answer - label it */ }

We default to fail-closed on purpose. A guardrail that quietly disappears is worse than one that visibly fails: you keep shipping answers believing they were checked. Failing loudly puts the decision in front of you while you still have the option of making it.

fail_open covers availability failures only - a network error, or a 5xx from us. It deliberately does not cover:

With no fallback supplied, fail-open calls your provider over the OpenAI chat-completions shape, which covers OpenAI, Azure OpenAI, Groq, Together, Fireworks, OpenRouter, vLLM and Ollama. For anything else, pass your own fallback. If neither can run, you get a FallbackUnavailable error rather than silence - the outage was real and your escape hatch wasn't usable, and you should know both.

Server-side fail-open on the gateway

On the OpenAI-compatible gateway you send us your provider key with each request, so there we can offer a server-side fail-open: if the engine fails, we complete your call against your own provider and return an ordinary OpenAI response marked leo_soul.degraded: true with an X-Soul-Degraded header. Enable it under Engine & features → Resilience (Pro+). It is off by default, and it never applies to quota, auth, rate-limit or provider failures - only to the engine itself failing.

Incidents, maintenance & status

When we plan maintenance or hit an incident, you hear it from us - you shouldn't have to find out from your own error logs.

Point incidents at your on-call. Add an Operations / on-call email in Dashboard → Settings and service notices go there as well as to you. "The API is refusing our traffic" is an on-call message, and it shouldn't depend on one person reading their inbox. Account-security and billing mail stays with the account owner.

If your quota runs out

Running out of turns takes your integration down just as effectively as an outage, so it's treated like one. You get an email at 80% and at 100% while everything still works, and then a distinct "your API calls are being refused" notice at the moment traffic actually starts failing - plus a usage.blocked webhook and up to two follow-ups a day apart if you're still blocked. On a paid plan those two moments aren't the same - we carry a production integration a little past its quota rather than cutting it off mid-request, so the actual block lands after the 100% email has been read and forgotten. That's exactly why it gets its own notice instead of passing in silence. On the Free plan the allowance is a hard limit, so the two moments coincide.

Account security

Your workspace ships with the controls a security team expects:

API key controls

Beyond create/rotate/revoke, each API key can be locked down:

Set both when you create a key, or later from the key's Restrict action in Dashboard → API Keys.

Webhooks

Point one HTTPS endpoint at your account (Dashboard → Settings → Webhooks) and LEO Soul will POST you signed events as they happen:

Each delivery is JSON { event, data, sent_at } and is signed with your webhook secret. Verify the X-LEO-Signature header - it's sha256= followed by the HMAC-SHA256 of the raw request body using your secret:

signature = "sha256=" + hmac_sha256(webhook_secret, raw_request_body)
# compare in constant time against the X-LEO-Signature header

Endpoints must be public HTTPS URLs; internal/loopback addresses are rejected. Use Send test event to verify your receiver, and rotate the secret any time.

Delivery. Each event is attempted up to three times with backoff (about 1s then 3s), so a deploy or a cold start doesn't lose an escalation. The attempt number is in the X-LEO-Delivery-Attempt header and the signed body is byte-identical across retries - de-duplicate on it if your handler isn't idempotent. A 4xx that means "don't send this again" (400/401/403/404/405/410/422) stops the retries immediately; return a 2xx quickly and do your work asynchronously if it's slow.

Account & data

We store your account, API-key hashes, aggregate usage counts, and billing state - never your messages or soul_state. Because the engine is stateless, there's no conversation content held on our side to begin with.

Enterprise deployment - hosted or self-hosted

Enterprise comes two ways, with the same engine, the same features, and custom pricing - pick the one that fits how your team works:

Hosted (managed by us)

We host and operate LEO Soul on our cloud. There is nothing to deploy - your workspace is provisioned on the Enterprise plan and you just log in at /login. We handle uptime, scaling, patching, and backups.

Onboarding: after signing, you get an activation email with a set-password link. Set a password, sign in at /login, create an API key, and point your agents at the hosted endpoint - same API, only the account tier changes. SSO/SAML + SCIM can be turned on from the dashboard (sign-in stays the same page, with a “Sign in with SSO” option). Because the engine is stateless, no message content is retained; the DPA/BAA cover the rest. Hosted is shared multi-tenant by default; a dedicated single-tenant instance is available on request.

Self-hosted (your cloud)

You run the whole platform as a Docker container inside your own VPC, under a signed license - nothing leaves your network, and it can run air-gapped.

cp .env.example .env          # set SECRET_KEY + admin creds
docker compose up -d          # app + Postgres -> :8000

The engine is stateless in both cases; hosted removes the ops burden, self-hosted maximises data residency and sovereignty (best for strictly regulated or air-gapped environments). Not sure which fits? Talk to sales.

Self-hosted onboarding - the LEO setup wizard

Self-hosted customers receive a generated bundle (docker-compose.yml, .env with the license key baked in, and INSTALL.md) from their Kadropic Labs contact. After docker compose up -d, open https://your-domain/setup - the first-run LEO wizard creates your owner account and confirms your license, then it's your own private dashboard and admin panel. The setup route hard-refuses once an owner exists, so it can't be used against an established instance.

The license key is signed and offline-verifiable - your instance validates it locally, with no phone-home. Licenses can be monthly, annual, or perpetual, for a set or unlimited number of seats. Your Kadropic Labs contact walks you through the full onboarding flow end to end.

Your bundle also ships an ENTERPRISE_HANDBOOK.md operator guide. Three things worth knowing for a private deployment: (1) the in-product AI assistant is off by default - it sends nothing to any model provider until you set ASSISTANT_ENABLED=1 with your own OpenAI or Anthropic key, so an air-gapped box stays fully private; (2) the full documentation is bundled and searchable from Admin → Docs and each user's Dashboard → Documentation, no internet needed; (3) errors are pushed to the admin notification bell and Monitoring tab automatically - add ALERT_EMAILS to also be paged by email.

Hosted onboarding is simpler: there's no bundle and no setup wizard - you receive a set-password activation link, sign in at /login, and you're in.

Billing, plans & refunds

Paid plans are billed in advance through Stripe, monthly or annually (annual = 2 months free). Your billing “month” is a rolling 30-day window that starts the day your plan starts - not the calendar 1st. Upgrades take effect immediately; the same API keys keep working. Plan allowances and prices live on the pricing page; the binding version of every policy below is in the Terms.

Notifications

Your dashboard has a notification bell (top-right) with an unread count. It collects account events - an API key created or rotated, a payment renewed or failed, a plan change or cancellation, a refund's progress, usage nearing your limit, a teammate invited - plus occasional announcements from our team. Mark them read or dismiss them. A failed payment also shows a slim banner with a one-click “Update payment method”.

Concepts glossary

A quick reference to the words you'll see across the product, the API, and the trace.

Turn
One call to the engine (POST /v1/turn) with your conversation messages. LEO Soul wraps that single turn with its metacognitive checks and returns a decision. A “turn” is the unit we meter against your monthly quota.
Action
The decision the engine returns for a turn - one of answer, ask (a clarifying question), confirm (before a risky step), refuse, or hold/escalate (to a human). See Actions.
soul_state
A small JSON object the engine returns each turn. You store it and send it back on the next turn - that's the memory/continuity across a conversation. We never store it; the engine is stateless. See soul_state.
Triage / latency lanes
Each turn is sorted into fast, standard, or full. Calm, low-stakes turns take the fast lane (a few ms of pure-Python math, no extra model calls); only risky or ambiguous turns run the full stack. This is why most turns add barely any latency.
Trace
The explanation object returned with each turn - the numbers behind the decision (uncertainty, calibrated confidence, which lane ran, why the action was chosen). It's what makes the engine auditable instead of a black box.
Persona
The saved identity, tone, values, and red-lines a Soul runs with. Reference it by id on a turn instead of re-sending the whole spec. See Personas.
Fail-open / fail-closed
What happens when something breaks. Inside the engine the default is fail-open: a module that errors degrades to a pass-through and marks itself in the trace, so one failed check doesn't cost you the turn (switchable to fail-closed for regulated traffic). When LEO Soul itself is unreachable the default is fail-closed: your SDK raises, so nothing unchecked reaches a user. Those are different decisions in different places - see Resilience.
Degraded
A reply that did not go through the metacognitive loop - no uncertainty estimate, no safety gate, no grounding check. Always flagged explicitly (result.degraded, leo_soul.degraded, or X-Soul-Degraded) and never presented as a checked answer.

Troubleshooting

Common issues and how to resolve them. For a searchable, task-oriented view of the same ground - and to reach a human - see Help & Support. Still stuck? Ask LEO or open a support case from your dashboard.

Authentication & keys

Quotas & rate limits

Billing

Turns & behavior

Email & self-hosting

Ready? Create a free account and make your first key - or talk to us about self-hosting.