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.
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:
- Intent - works out what you're actually asking and how ambiguous it is, before anything else runs.
- Uncertainty - samples the model and measures how much its candidate answers disagree in meaning (not just wording), then calibrates that into an honest confidence.
- Sycophancy - checks whether it's about to cave to pressure or flattery, and re-derives from the evidence if so.
- Self-curiosity - generates its own questions about its biggest knowledge gap, ranks them by how much they'd help, and either asks you or parks them to revisit later.
- Governor - weighs the stakes and decides the action: answer, ask, confirm, refuse, or hold.
- Self-learning - a tiny online model watches which situations tend to go wrong in
your deployment and quietly tunes the caution over time. All of it is saved in
soul_state, so the learning belongs to you.
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.
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
- Dev / testing: use the Playground, or make calls with a spare key and any GPT or Claude model. The Free plan's 100 turns/month is plenty to wire everything up.
- Production: keep your
sk_live_…key in a server-side secret (never ship it in front-end code), add an IP allowlist / expiry to the key, subscribe to escalation webhooks so a human is looped in when a turn is escalated, and move to a plan whose turn quota matches your traffic. - Before you go live, decide what a bad day looks like. Pick your
on_unavailablepolicy explicitly - fail closed (the default: raise, so nothing unchecked ships) or fail open (call your model directly, flagged degraded). Handle402and502distinctly from500. Add an ops / on-call email in Settings so incidents reach your rota rather than one inbox. An integration that never made this choice has still made it - it just finds out which one during the incident.
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.
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:
- Hosted API + official clients -
pip install leo-soul-client(ornpm install @leo-soul/client), orPOST /v1/turndirectly. The engine runs on our cloud; you pass your model key per request and we meter turns (counts only). - Self-hosted (Enterprise) - run the whole platform as a Docker container in your VPC. Nothing transits our servers; point the same client at your own URL. See Self-hosting.
- In-process engine (Enterprise, licensed) - embed the LEO Soul engine directly in your
Python process for a zero network hop. The
leo_soulpackage ships in your licensed bundle (not public PyPI); your model key and conversation never leave your machine.
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
- Cross-session memory: add
profile_id="user-42"and the durable self (calibration, beliefs, learner, user-model) accumulates across all of that user's conversations, while eachsession_idstays its own thread. - Ground-truth learning: pass
feedback=True/False(or callrecord_feedback(...)later) so calibration and the online learner train on the real outcome, not on whether the next message happened to say "thanks". - Always-on safety: an embeddings classifier screens every turn for harmful/destructive
intent - language-agnostic and offline-capable; each trace shows
safety: semantic | regex_only.
Actions
| action | Meaning | You do |
|---|---|---|
answer | Confident, calm answer | send reply |
asked | Asked a clarifying question | send reply, await user |
confirmed | Irreversible/ambiguous - asked to confirm | act only on confirmation |
held | Pushed back without new facts; held ground | send reply |
refused | Crossed a red line | send reply |
escalated | Flagged for a human | send 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:
| Card | What it means |
|---|---|
DECISION | The action the governor chose (answer / ask / confirm / refuse / hold / escalate), its calibrated confidence, lane, and latency. |
WARNING | An early-warning signal - sycophancy pressure, or confidence dropping below threshold. |
ANOMALY | Something's off - a degraded module (fail-open), an ungrounded answer, or elevated learned failure-risk. |
BOUNDARY | A rule fired - a policy pack governed the turn, a tool-call guardrail engaged, or a red-line refusal/escalation. |
RECOVERY | The agent felt pressure to change its answer and didn't - measurable sycophancy resilience. |
INFO | Operational - 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.
| Capability | What it does | Tier |
|---|---|---|
| Reliability reports | A weekly reliability score per agent - abstain/escalate rates and calibration drift, so you can see quality trending over time. | Pro + |
| Cost analytics | The split between fast-path and full-scrutiny turns, and the token spend saved on confident turns. | Pro + |
| PII redaction | Strips personal data out of a turn before it reaches the model provider. | Scale + |
| Industry policy packs | Pre-built guardrail packs tuned per industry (healthcare, finance, and more). | Scale + |
| RAG grounding check | Verifies the answer is actually supported by the sources you supplied, and flags unsupported claims. | Scale + |
| Tool-call guardrails | A safety layer over multi-agent and tool/function calls. | Pro + |
| Integrations | Connect Datadog, a webhooks catalog, and more to your workspace. | Pro + |
| Slack app | A LEO Soul Slack app that runs inside your own workspace. | Enterprise |
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+.
| Field | Type | Effect |
|---|---|---|
redact_pii | bool | Replaces 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. |
sources | string[] | 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_policy | object[] + object | Proposed 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_pack | string | An 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:
| Event | Data |
|---|---|
decision | action, 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. |
done | The full payload: reply, soul_state, trace, action, client_ref - identical in shape to the /v1/turn response. |
error | A 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. |
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.
| Header | Purpose |
|---|---|
X-Provider-Key | Required. Your model provider's API key. Used for that request only, never stored - the same posture as backend_kwargs on /v1/turn. |
X-Provider | Which provider: openai (default), anthropic, azure, bedrock, ollama, vllm, together, groq, fireworks. |
X-Provider-Base-Url | Optional. Point at any OpenAI-compatible server of your own. |
X-Soul-State | The state from your previous response, sent back to continue the learning. See below. |
X-Soul-Policy-Pack · X-Soul-Meta-Model | Optional 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".
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:
| Endpoint | What it does |
|---|---|
POST /v1/feedback | Attach 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/assurance | The 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, ... } }
- 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.
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.
openai·anthropic·azure- the major hosted providers (azurealso needsazure_endpoint).ollama·vllm·together·groq·fireworks·openai-compatible- any OpenAI-protocol server. The alias pre-fills a sensiblebase_urlyou can override (e.g. pointvllmat your own GPU node). Great for on-prem and local models.mock- deterministic and offline, handy for tests and the Playground.
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:
- Semantic entropy - uncertainty measured over the meanings of candidate answers (paraphrases cluster together), not raw token probabilities.
- Bayesian updating - beliefs held as probabilities and revised as evidence arrives.
- Conformal prediction - a distribution-free, certified answer/ask boundary that keeps the error rate on answered questions under a bound you choose (a Pro+ capability).
- Calibration - temperature scaling tracked by Brier score, so a stated 90% is right about 90% of the time.
- Expected information gain - ranks the engine's own clarifying questions by how much each would reduce uncertainty, so it asks only the highest-value one.
- Online meta-learning - a compact neural controller adapts, per deployment, to
which situations tend to fail; its weights ride inside
soul_state, which you own.
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
| Field | Type | Notes |
|---|---|---|
messages | array | chat messages (role, content) |
soul_state | object|null | what you stored last turn, or null |
persona | object | optional inline identity/values |
persona_id | number | optional - run a saved persona instead of inlining one |
backend | string | openai | anthropic | mock |
backend_kwargs | object | your provider key + model |
client_ref | string | optional - your correlation id, echoed back and included in escalation webhooks |
Request headers
Idempotency-Key- optional. A retried request with the same key is metered and processed exactly once. To honour our no-content-retention promise, a replay returns a receipt (idempotent_replay: true) - not the original reply/soul_state, which we never store. Reusing a key with a different body returns422; an in-flight duplicate returns409.X-Request-Id- optional. Echoed back on every response; if you don't send one we mint it. Quote it in support tickets.
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.
- Latency. The
fastlane adds only a few milliseconds of local math and no extra model calls, so most turns feel unchanged.standard/fulllanes may sample your model more than once to measure uncertainty - that added time is your model's latency, spent only on the turns where stakes or ambiguity justify it. - Token usage. Same story: calm turns ≈ one model call; careful turns may use a few more of your tokens in exchange for much better judgement. Nothing hidden.
- Gist-Consensus sampling. To measure how sure it is, the engine samples your model a few times and clusters the answers by meaning (semantic entropy). Those consensus samples are deliberately capped to a short "gist" - meaning is carried by a gist just as well as by a full draft - so they spend a fraction of the tokens, while the reply you receive is always written once at full length. This cuts the token cost of a scrutiny turn roughly 2-3× with no change to the answer or the decision.
- See every turn's cost. The trace carries a
tokensblock -model_calls, an estimated token total, and the tokens Gist-Consensus saved - so cost is something you can watch, not guess at. It shows live in the dashboard Playground. - Engine speed control. Under Settings → Engine & features (every plan)
you can pick
Lean,Balanced(default), orThorough- how long each consensus sample may be. Lean spends the fewest tokens; the reply and the decision loop are unchanged either way. You can also set this from the assistant - just ask LEO to "make the engine leaner" and it will apply it for you. - Model routing - not the model that answers your users. The same panel has
an optional Model routing field (
meta_model). This names a small, fast model - from your same provider and key - that the engine uses only for its own internal classification calls (intent, triage, uncertainty sampling). It is not a place to switch the model that writes the answer your user sees: LEO Soul has no model of its own, and you always bring the answering model per request. Leave Model routing blank to run everything on your one model; set it only to make the engine's internal calls cheaper/faster. So if you saw a "model" control in the dashboard and wondered why a reliability layer picks models - it doesn't; this only tunes the engine's own small helper calls. - Infra. The engine is pure-Python math (no GPU, no model of its own), so a hosted turn is a little CPU plus one metering row - it scales like any web service, not a per-turn model bill.
Errors
400- backend/config error (e.g. bad provider settings).401- missing/invalid API key, or a key past its expiry date.402- monthly quota reached; upgrade or wait for the window to reset.403- the key isn't permitted from your IP (IP allowlist), or the account is suspended.409- a request with thisIdempotency-Keyis already in progress.422- anIdempotency-Keywas reused with a different request body.429- rate limit; retry after theRetry-Afterseconds.500- a fault on our side. Retry idempotently; if it persists, quote therequest_idto support.502- your model provider failed, not LEO Soul. See below.
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 failed | Default behaviour | Where 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:
402quota - a billing state, not an outage. Failing open here would mean unmetered use.401/403- configuration errors that failing open would hide until they mattered.429- a known, self-healing state that already tells you how long to wait.502- your provider is the broken thing, so calling it directly would fail identically.
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.
- Email. Advance notice, a starting-soon reminder, a start notice, progress updates, and an all-clear. Operational mail, never marketing: there's no unsubscribe, because a service interruption isn't something you can opt out of experiencing. Whoever was told it was coming is always told when it's over.
- In-app. A banner at the top of your dashboard and an entry in the notification bell - for every member of your workspace, not just the account owner.
- Webhook.
service.degradedfires withphase,severity,impactand expected timings, and again withstatus: "resolved"at the end - so you can flip to a fallback automatically and flip back. See Webhooks. - Status page. soul.kadropiclabs.com/status runs real dependency checks - a live database round-trip, not just "is the web process answering". Being honest about its one limitation: it is served by the same infrastructure it reports on, so during a total outage it may itself be unreachable. That's what the email and webhook channels are for.
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:
- Two-factor authentication (2FA). Turn on TOTP (Google Authenticator, Authy, 1Password, etc.) in Dashboard → Settings → Security. Sign-in then requires a 6-digit code; you also get one-time backup codes in case you lose your device. The secret never leaves your browser.
- Active sessions. See every device signed in to your account, and sign out a single device, all others, or everywhere. Changing your password signs out your other devices automatically; a password reset signs out everywhere.
- Rate-limited & audited. Sign-in, sign-up, password reset, and the contact form are rate-limited against brute-force and abuse, and account-security events are recorded in your audit trail.
API key controls
Beyond create/rotate/revoke, each API key can be locked down:
- Expiry. Give a key an auto-expiry date; after it, requests return
401. Perfect for short-lived integrations, contractors, or scheduled rotation. - IP allowlist. Restrict a key to specific IP addresses or CIDR ranges (e.g.
203.0.113.5, 10.0.0.0/24). A request from any other address returns403. Leave it blank to allow any IP.
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:
api_key.created- a new API key was created on your account.usage.threshold- you crossed 80% or 100% of your monthly turns.usage.blocked- your turns are now actively being refused (402). Distinct from the threshold event above: this one means your integration is failing right now.turn.escalated- a turn was flagged for human review, so your system can page a person.turn.refused- a turn crossed a red line. Content-free: the event carries the action, the reason, and yourclient_ref- never the message.service.degraded- an incident or planned maintenance affecting your account, withphase,severity,impactand expected timings. It fires again withstatus: "resolved"when it's over, so you can switch to a fallback and switch back automatically. See Incidents & status.
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.
- Delete your account anytime from Dashboard → Settings → Delete account. It cancels any active subscription immediately and permanently removes your account, API keys, usage counters, support cases, and assistant history.
- Suspension. If an account is suspended for policy reasons, its keys stop
authenticating (
403) and the owner is emailed; billing is untouched so it can be reinstated cleanly. - Export your usage. Download your daily usage as CSV from Dashboard → Analytics →
Export CSV (
GET /api/usage/export.csv) for your own reporting. - Live status. Check the API's current health any time at soul.kadropiclabs.com/status.
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.
- Free plan. One agent, and up to 2 support cases per month (target first response within 24 hours). LEO Assistant is available with up to 15 messages a day. The monthly turn allowance is a hard limit - there is no usage beyond it on Free.
- Support response targets. Pro: typically 2-24 hours. Scale: typically minutes to 1 hour. Enterprise: a dedicated engineer with a contractual SLA. These are targets, not guarantees (except Enterprise).
- Cancel or downgrade anytime from Plan & Billing. You choose: keep access until the period you already paid for ends (no refund), or cancel now and take the refund.
- Refunds. Cancel your first subscription within 30 days and we refund what you paid, less the value of the turns you've already used - you get the full 30 days to evaluate, but consumed quota counts. Afterwards (a renewal, or past day 30) the refund covers the unused part of the current period, measured by whichever you've used more of: elapsed time or turn quota. So burning a month's turns in week one leaves nothing to refund even though most of the calendar month remains. Approved refunds go back to your original card; banks take 7-14 business days to post them. Your cancellation screen shows the exact amount before you confirm.
- Promo codes. If you have one, enter it at checkout - the discount is shown before you confirm, and it applies for the number of billing periods the code states. A code that arrives as a link applies itself when you create your account, so there is nothing to type. Codes are single-use per account and can't be combined.
- Complimentary plans. We sometimes put an account on a paid plan at no charge - for a design partner, a conference, or to make something right. It is a real plan: every feature of that tier is unlocked and there is nothing to pay. Because there is no card on file it doesn't renew: your dashboard shows the end date, we email you before it arrives, and if you don't add a payment method the account simply returns to Free on that date. Your data, API keys and settings are untouched, and there is no failed-payment grace period or reminder sequence - nothing was ever owed.
- Failed payment. Your plan stays fully active during a short grace period while we retry and remind you by email and in-app - each notice carries your specific renew-by date. Fixing your card clears it automatically; if it isn't resolved in time the plan drops to Free. Exact length: 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, orX-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
- 401 Unauthorized. Your API key is missing, malformed, or revoked. Send it as
Authorization: Bearer <key>(or theX-Api-Keyheader). Keys are shown once - if you lost it, rotate the key in API Keys and update your app. - 403 from an API key with an IP allowlist. The request came from an IP that isn't on the key's allowlist. Add the caller's IP/CIDR to the key, or clear the allowlist. Server-to-server calls use your server's egress IP, not your laptop's.
- Key “expired”. A key past its expiry date stops working. Remove or extend the expiry in the key's settings, or rotate to a fresh key.
- Rotating a key. Rotation issues a new secret and revokes the old one immediately - update every agent using it. Rotation doesn't count against your key limit.
Quotas & rate limits
- 402 / “quota reached”. You've used your monthly turns. Upgrade for a higher limit - it takes effect immediately - or wait for your 30-day window to reset. Watch usage on the Overview tab; we email at 80% and 100%.
- 429 / rate limited. You exceeded your plan's per-key rate limit. Back off and retry
(respect
Retry-After), spread load across keys, or upgrade for a higher rate.
Billing
- Payment failed. Update your card from Plan & Billing → Update payment method. You keep full access during the grace period and a successful retry clears it automatically - see Billing, plans & refunds.
- I cancelled - when do I get my refund? Once approved, refunds go back to your original card and banks take 7-14 business days to post them. You can track the status in your billing page.
- Invoice needs my company / VAT number. Add them in Settings → Profile; they appear on future receipts.
Turns & behavior
- It asks instead of answering. That's the point on ambiguous or risky turns - the engine
chose
ask/confirm. Read thetraceto see why. To tune thresholds, use Custom policy (Scale+). - It “forgets” between turns. You must persist
soul_stateand send it back on the next turn - that's the memory. If you drop it, each turn starts fresh. - A turn errored. The engine fails open (plain pass-through) rather than taking your agent
down. Check the
error/request_idin the response and retry idempotently with anIdempotency-Key. - The Playground feels slow. It runs a real model server-side; the first call in a while can
be cold. Subsequent runs are quicker. For production latency, note that only
standard/fulllanes add model round-trips.
Email & self-hosting
- Didn't get a confirmation / reset email. Check spam, confirm the address is right, and resend from the dashboard. On self-hosted instances, transactional email only sends when a provider key is configured.
- Self-hosted setup won't open. The first-run
/setupwizard hard-refuses once an owner account exists. If you've already created the owner, sign in normally; use the admin panel to manage the instance.