Every help entry

The whole help centre on one page. Not found here? Raise a ticket, or see the docs.

Getting started

Connect StateJar to your tools

One setup page per tool: MCP clients, Claude Code, Codex, OpenCode, OpenClaw, Hermes Agent, the Python and Node SDKs and the CLI.

Every integration uses the same memory and the same account. Pick the one that matches where you work; each page has the install command, the configuration, a check that it works and what it sends.

You useStart here
Any MCP client (Claude Desktop, claude.ai, ChatGPT connectors, Cursor, VS Code, Gemini CLI, Windsurf)MCP server, at https://statejar.com/mcp
Claude CodeClaude Code plugin
CodexCodex plugin
OpenCodeOpenCode plugin
OpenClawOpenClaw memory plugin
Hermes AgentHermes memory provider
Python codePython SDK (pip install statejar)
Node codeNode SDK (npm install statejar)
A terminalCLI and statejar login
Any agent that reads Agent SkillsSKILL.md

New here? Getting started takes you from a new account to a model that remembers in seven steps. Most setups need a developer key from the API Keys page (shown once; shown as sj_live_…), or sign in from the terminal with statejar login (how). The MCP server also accepts an OAuth sign-in (details). Every tool's recall and capture is written to your audit log; secrets in your messages are removed before capture.

Reading the Memory State panel

What the sections mean, what the colours mean, and why an empty panel is sometimes correct.

The Memory State tab shows every namespace the API returns, in this order:

  • facts, things that are true about you or the situation: a name, a city.
  • preferences, how you want things done: contact mode, a colour.
  • constraints, limits: a budget, a deadline, a headcount.
  • decisions, choices already made.
  • goals, what you are trying to achieve.
  • dynamic, concepts nobody wrote a schema for. A kiln schedule, a load limit per container, a hive inspection interval. See dynamic fields.
  • _unmapped, values that were declined, each with a reason. See the declined section.
  • conflicts, history, retractions, the audit trail of changes.

A value renders as its normalized form, so {"currency": "INR", "value": 5000} is what was actually stored, not the words you typed.

Fields highlighted after a turn are the ones that turn changed.

An empty panel after a message is a real answer, not a bug. If you said "that sounds good, let's do it" there is nothing in it to store. If you said something with a value in it and the panel is still empty, check _unmapped, it was probably declined, and the reason will say why.

Setting up a provider

The Playground works with no key (StateJar default model). To use your own model, add one key on the API Keys screen; StateJar sends the minimal memory subset to it.

You do not need a key to start. The Playground's first choice, StateJar default model, answers with a cloud model through StateJar's own provider pool, within an hourly limit. To answer with a model of your own instead (more replies, a model you prefer, your own billing), add a key:

  1. Open API Keys in the sidebar.
  2. Pick a provider card and paste a key. Keys are encrypted before storage and the API never returns one, the list endpoint shows only the last four characters.
  3. Press Test connection. A green result means the key works and the provider answered. A red one names the problem in plain language: a bad key, an unknown model, or an unreachable address.
  4. Go back to Playground and choose a model in the picker.

Which to choose:

  • OpenRouter is the easiest start, one key, many models, including free ones.
  • Ollama (local) keeps your reply prompts on your own computer; nothing in them goes to a reply vendor. (StateJar's server still sends your messages to its cloud providers to extract facts: see who reads my messages.)
  • Anthropic, OpenAI and Google are direct connections if you already hold a key with them.

Whatever you pick, the model is sent the retrieved subset for that question, not your whole memory, and never the raw transcript.

What StateJar is for

A memory layer that refuses to guess, records why it refused, and turns what it remembers into a portable handle.

StateJar remembers facts from a conversation so a model does not have to be told them again. That much is ordinary. Three things are not.

It refuses to guess. A value has to pass a type check before it is stored. "Max load per container is 24 tonnes" is a quantity, so it cannot land in a money field. When a value fails its check it is declined, not silently coerced, and the decline is shown to you with a reason.

One field holds one value. When you change your mind, the old value moves to history and the new one takes the field. The model is never shown both, so it cannot answer from the version you replaced.

What it remembers is portable. Every state has a handle, a content address computed from the state itself. The same state always produces the same handle. Paste a handle into a new session, or a different model from a different vendor, and you get that exact memory back.

The cost of this is that StateJar stores less than a system that guesses. That is the trade being made deliberately: in a benefits application or a clinical intake, a number remembered wrongly is worse than a number not remembered.

Start with your first session.

Your first handle restore

Copy a handle from one session and open that exact memory in another.

This is the demonstration that tends to convince people, and it takes about twenty seconds.

  1. In a session with some state, click the copy button beside the handle line. You now have something like shm_a5f9911c29fabe25… on your clipboard.
  2. Click + New session. The Memory State panel is empty, a new session starts with no memory.
  3. Paste the handle into the restore box in the toolbar and submit.
  4. The state comes back exactly as it was. Same fields, same values, same handle.

What just happened: the handle is a content address. Restoring re-derives the handle from the stored state and refuses if the two disagree, so a restore that succeeds is proof the bytes were not altered.

The same handle works across models and vendors. Restore it in a session using another provider's model (or your own local Ollama, on an install that offers it) and you get the identical state you built with a hosted one, because the state is data, not a vendor's memory feature.

What a handle does not carry: the conversation. See why transcripts are not stored.

Your first session

Sign in, send one message, and read what the six panels tell you about it.

  1. Sign in and open Playground from the sidebar.
  2. Send a message with a fact in it. Try: My budget is 5000 rupees and the deadline is March 3.
  3. Watch the pipeline strip above the chat. Seven stages light up as the response comes back, that is a real trace, not an animation on a timer. See the pipeline tracker.
  4. Open the Memory State tab on the right. You will see two fields: constraints.budget.max holding {currency: "INR", value: 5000} and constraints.deadline holding the date. Note that 5000 was stored as money with a currency, and March 3 as a date, not as the number 3.
  5. Look at the handle line under the tabs. It reads something like shm_a5f9911c29fa…. That is the address of the state you just created.
  6. Send a second message that changes something: Actually the budget is 8000. The budget field now holds 8000, the old value is in history, and the handle has changed, because the state did.

You do not need a provider key for steps 1–6: /memory/ingest runs the whole memory pipeline on its own. A key is only needed to get a model's reply.

Next: setting up a provider.

Concepts

Active state vs history

The field holds one value; everything it used to hold is kept separately.

Definition. The active state is what a field holds now. History is every value it previously held, kept in order and never shown to the model.

Why it exists. A model shown both the old and the new value will sometimes answer from the old one. Keeping them in separate places makes that impossible rather than unlikely.

Where you see it. Active values are the main tree in Memory State; superseded values appear under history and in the Handles tab as earlier versions.

Worked example. Say budget is 5000, then actually 8000. The active state has 8000. History records 5000. A question about the budget retrieves 8000 only.

Common misunderstanding. That history is a backup. It is a record. You cannot lose data by updating a field, but retrieval will never quietly serve you a historical value.

Audit entry

One row per disclosure: what was sent, from which handle, to which model.

Definition. A record written every time state is disclosed to a model: the handle used, the exact field keys sent, the provider, the model, and a request id.

Why it exists. 'Minimal disclosure' is a claim that has to be checkable. The audit entry is the evidence, and the replay endpoint reconstructs the exact subset from the handle.

Where you see it. The Audit tab in the Playground, the Audit Log screen, and GET /audit.

Worked example. Ask a question and open the Audit tab. One row appears listing the disclosed keys, typically two or three, not your whole state.

Common misunderstanding. That the audit log stores the conversation. It stores field keys, never field values and never message text. See the replay endpoint.

Canonical path

The one dotted address a concept resolves to, no matter how you phrase it.

Definition. The single dotted address a concept resolves to, such as constraints.budget.max. Aliases map onto it; it never maps onto anything else.

Why it exists. Without it, budget, max budget and spending limit would each create a field, and the state would hold three answers to one question.

Where you see it. As the key path in Memory State, and in the field column of a change acknowledgement.

Worked example. budget, max budget, budget limit and spending cap all canonicalize to constraints.budget.max. Only one field is ever written.

Common misunderstanding. That the path is chosen per message. It is fixed in the registry, which is checked at the storage boundary, two aliases resolving to the same path is an assertion failure, not a merge.

Conflict

Two values asserted for one field, recorded rather than silently resolved.

Definition. A record that two values were asserted for the same concept in a way that does not read as a straightforward replacement.

Why it exists. Some contradictions are not corrections. 'Call me instead of emailing' rejects one option and picks another, and that is worth recording rather than flattening.

Where you see it. The conflicts list in Memory State.

Worked example. Email me then actually call me instead records a conflict on contact_mode with the reason user rejected email in favour of phone. See that reason.

Common misunderstanding. That a conflict means the state is inconsistent. The state is never inconsistent, the field holds exactly one value. The conflict is a note about how it got there.

Decline and _unmapped

A value StateJar refused to store, kept with the reason it was refused.

Definition. A value that failed a guard and was parked in the _unmapped section along with a reason code, rather than being stored or dropped.

Why it exists. Silently dropping a value and silently storing it in the wrong place fail the same way, the user cannot tell. A decline is visible and has a stated cause.

Where you see it. The declined list in the Memory State panel. See the declined section.

Worked example. A value the registry has no field for is declined with unknown_key. A value a field's normalizer rejects is declined with rejected_value.

Common misunderstanding. That a decline means StateJar is broken. It means a guard did its job. The reason tells you whether the fix is a rephrase, a new alias, or nothing at all.

Dynamic field

A field for a concept nobody wrote a schema for. First-class, not a leftover.

Definition. A field created for a concept the registry has no entry for, stored under dynamic with a slug derived from what you called it.

Why it exists. StateJar has never been taught what a kiln schedule or a freight manifest looks like. Without dynamic fields, everything outside a fixed schema would be lost, and it was, before they existed.

Where you see it. The dynamic section of Memory State.

Worked example. Each kiln firing takes 14 hours creates dynamic.kiln_firing holding a duration of 14 hours. No rule for kilns exists anywhere in the code.

Common misunderstanding. That dynamic fields are second-class or temporary. They are hashed into the handle exactly like registry fields, which is why concept reuse has to be deterministic rather than similarity-based.

Extraction tier

Three extractors run in order of cost; the cheapest that succeeds wins.

Definition. One of three extractors: rules, GLiNER2, LLM. They run cheapest-first, and a later tier can only fill fields an earlier one left empty.

Why it exists. Rules are free and exact but narrow; a language model is broad but costs money and latency. Running them in order gets the coverage without paying for it on every message.

Where you see it. The tier chips under the tabs, one per tier that ran, with the number of fields it contributed.

Worked example. My budget is 5000 is handled entirely by rules, one chip, rules 1. A rambling paragraph with a buried commitment may need tier 3, and you will see an llm chip.

What statejar.com runs. Extraction v3 (version 3.6), not this cascade: the rules, GLiNER2 spans and the server's own model propose facts, and deterministic checks decide. GET /api/v1/health names the extractor running (extractor: v3 or rules); production can be switched back to the rules within a minute. In the cascade, tiers 2 and 3 are off; see GLiNER2 and LLM for why.

Common misunderstanding. That a higher tier is more trusted. It is not: everything a tier proposes goes through the same type guard and the same registry. A model's guess is checked exactly as hard as a regex's.

Field

One named slot holding one value, in one section.

Definition. One named slot in the state that holds exactly one value, living in one of the sections (facts, preferences, constraints, decisions, goals, dynamic).

Why it exists. Contradiction is prevented structurally. If a concept has exactly one home, two values for it cannot both be current, one must supersede the other.

Where you see it. Every leaf in the Memory State tree is a field.

Worked example. constraints.budget.max is a field. So is facts.name. So is dynamic.load_limit_per_container once you mention one.

Common misunderstanding. That similar names are different fields. budget, my budget, and the budget all resolve to the same field through the alias registry, that is what stops the same number being stored three times.

Handle

A content address for one exact state: same bytes in, same handle out.

Definition. A short string, shm_ followed by a hash, that addresses one exact state. It is computed from the state's own canonical bytes.

Why it exists. So memory can be moved and verified. If the address is derived from the content, then an address that resolves proves the content was not altered, and the same content anywhere produces the same address.

Where you see it. Under the tabs in the Playground, with a copy button. Also on every row of the Handles tab and in every audit entry.

Worked example. Ingest a fact and note the handle. Ingest the identical fact into a fresh session, the same handle comes back, because the state is the same. Change one value and it changes completely.

Common misunderstanding. That a handle is an id the server made up, like a row number. It is not: nothing assigns it. It is what the state hashes to, so two servers that never met agree on it.

Normalization and base units

Equivalent values are stored identically, so the handle does not depend on phrasing.

Definition. Converting a value to one canonical form before storing it: a currency and an amount, a date as ISO, a measure in its base unit.

Why it exists. Two ways of writing the same thing must produce the same bytes, or the handle would depend on wording rather than meaning.

Where you see it. In the rendered value in Memory State, which shows the stored form rather than your words.

Worked example. 1,240 kg and 1.24 tonnes both normalize to 1240000 grams. Both produce the same field value and therefore the same handle.

Common misunderstanding. That normalization loses your original phrasing. The raw text is kept alongside the normalized value for display; it is the normalized form that is compared and hashed.

Qualifier

The part of a concept that says which end, which side, or which bound.

Definition. A modifier attached to a concept that distinguishes one variant from another: start, end, min, max, per, total.

Why it exists. 'Push the end date' has to find the end date, not the start date. Without a qualifier the update lands on whichever date field the matcher happened to like.

Where you see it. In path names, constraints.budget.max, dynamic.delivery_window.start.

Worked example. Booking starts March 3 and ends March 9 creates two fields with start and end qualifiers. Push the end to March 11 then updates only the second.

Common misunderstanding. That a qualifier is decoration. It is part of the identity of the field: a fact qualified start will not be written into a field whose path implies end, and the mismatch causes a decline rather than an overwrite.

Reinforcement

Saying the same thing again strengthens it without changing anything.

Definition. Restating a value that is already stored. The field is untouched; the repetition is noted.

Why it exists. A user repeating a number is usually emphasising it, not editing it. Treating a repeat as a write would churn the handle for no change in meaning.

Where you see it. In the reinforced list. The handle does not move, because the state did not.

Worked example. Budget is 5000 then later remember, 5000 is the limit. No supersession, no new version, no change line.

Common misunderstanding. That repeating a value is required for it to stick. It is not, one clear statement is enough.

Retraction

Taking a value back, leaving the field genuinely empty.

Definition. Removing a stored value because you said to, forget the deadline, scratch that budget, leaving the field empty rather than holding a stale value.

Why it exists. Without it, the only way to correct a wrong memory is to overwrite it with another wrong value. 'I never said that' has to be expressible.

Where you see it. By the field disappearing from the active state, and in history as an entry with resolution: "retracted" that keeps the old value.

When it is not carried out. Only a plain instruction retracts. don't forget the budget, a question such as should I forget the budget? or can you forget the budget?, and a quoted example (if I type "forget the budget"…) change nothing, say forget the budget. instead. Contact words (call, email, phone) cannot be withdrawn by name.

Worked example. My deadline is March 3 then actually forget the deadline. The deadline field is cleared; the retraction is recorded with what was removed.

Common misunderstanding. That a retraction erases the past. It does not, the audit trail still shows the value was once held and then withdrawn. It only stops it being served.

Scope: session, user, namespace

Which slice of memory a read or write applies to.

Definition. Which slice of memory an operation addresses. Session is one thread. User is one end user across their sessions. Namespace is your whole StateJar account.

Why it exists. An application has many end users, and each of them has many conversations. Without a scope, either everyone shares one memory or nobody keeps one across sessions.

Where you see it. Today: session scope is what the Playground uses, and the account is the namespace. A new session starts from your newest state, and its first message builds on it; after that each session keeps its own chain. The built-in demo's sessions (demo-…) never seed or answer for your own sessions, and yours never seed the demo. To carry a specific state into a session, restore its handle.

Worked example. In the Playground, each session dropdown entry is one session scope, and everything you have is one namespace.

Common misunderstanding. That namespace is something you pass in. It is never accepted from a client, it is resolved from your credential, so one account can never address another's memory.

Session

A named conversation thread. State is scoped to it unless you move a handle across.

Definition. A named thread of conversation. Every state StateJar writes is tagged with the session it came from.

Why it exists. Two conversations about different things should not contaminate each other. Planning a wedding and filing an insurance claim both have a budget, and they are not the same budget.

Where you see it. The dropdown in the Playground toolbar. + New session makes another one. See the session selector.

Worked example. Send my budget is 5000 in session-1, then switch to a new session that continues your memory and open Memory State: it shows the 5000 carried over, because a session with no version of its own is answered from your newest state and its first message builds on it. Start the session with Fresh memory instead and it is empty; nothing crosses (see New session).

Common misunderstanding. That a session holds your chat. It does not, the server stores state, never the transcript. The words are kept by your browser, which is why history is per device. See the FAQ.

State

The structured memory for a session at one point in time.

Definition. The structured set of everything StateJar currently holds for a session: sections, fields, and normalized values.

Why it exists. A model cannot be handed a conversation and be expected to reason reliably about what is still true. State is the answer to 'what is true now', separated from the words that established it.

Where you see it. The Memory State tab. Also returned by GET /memory/state/{handle} and by every ingest response.

Worked example. After my budget is 5000 rupees, state contains constraints.budget.max = {currency: 'INR', value: 5000} and nothing else.

Common misunderstanding. That state is a summary of the conversation. It is not a summary, nothing was compressed or paraphrased. Every value in it was extracted, type-checked and normalized.

Supersession

A new value replaces the old one; the old one moves to history.

Definition. Replacing the current value of a field with a new one, moving the previous value into history and advancing the state version.

Why it exists. So that changing your mind produces one current answer rather than two competing ones.

Where you see it. As a change line after a turn (budget: 5000 to 8000), and as a new version in the Handles tab.

Worked example. Budget is 5000 then make it 8000. The field now holds 8000, history holds 5000, and the handle is different because the state is.

Common misunderstanding. That supersession is the same as a conflict. Supersession is an orderly replacement. A conflict is recorded when two values are asserted without one clearly replacing the other.

Token savings and its baseline

Three different baselines ship, they answer different questions, and a percentage without its baseline means nothing.

Definition. How much smaller what was actually sent to the model is than what would have been sent without minimal disclosure.

Why it exists. The claim being made is that disclosing a subset is cheaper than the alternative. A percentage with no stated baseline is not a claim, it is a decoration.

Where you see it. Three places, and they use different baselines. This is the most misread number in the product, so it is worth being exact.

WhereBaselineCan it go negative?
Query savings badge, and the Dashboard's Tokens saved (last chat)the disclosable state, every field that was eligible to be sentNo. It is clamped at 0.
The 17-turn relief demoa full-transcript replay, every message so far, resentYes, and it is early on.
The Playground's Tokens tabthe full chat history of that session against the last 6 messages plus the memory_context, counted per reply with tiktoken (how)Yes.

The first answers "of what I could have disclosed, how little did I?", it measures the retriever. The second answers "against a system with no memory layer, am I ahead?", it measures the product. Both are honest; they are not interchangeable, and neither one is "the real number".

Worked example. Ask one question of a ten-field state and the badge may say 80%, two fields sent out of ten eligible. On turn two of the demo the same mechanism shows more than replay, because two short messages are cheaper to resend than a state plus its scaffolding. The demo says so on screen rather than hiding the turns where it loses.

Common misunderstanding. That the baseline is "sending nothing". It never is. Compared to sending nothing, any disclosure costs more; the question is always cheaper than what.

Related: the savings badge, the Dashboard.

Value type

What kind of thing a value is: money, quantity, date, duration, percent, ratio, identifier, count, text.

Definition. The classification a value gets before it is stored: money, quantity, date, duration, percent, ratio, identifier, count, text, or unknown.

Why it exists. This is the whole trick. StateJar hardcodes what a value can be, not what a field can be called, which is why it works in domains it has never seen.

Where you see it. Implicitly, in how a value renders. A money value shows a currency; a quantity shows a unit.

Worked example. 24 tonnes classifies as a quantity. A money field will not accept it, so it is not stored as twenty-four rupees. That single check is the difference between a correct memory and a confidently wrong one.

Common misunderstanding. That the type comes from the field. It comes from the value and its surrounding clause. The field only says which types it will accept.

The interface

About page

What StateJar is, in three minutes, including what does not work yet.

A single page covering the problem, the three guarantees, the pipeline, why there is no domain code, where this matters, current status, and an explicit list of gaps.

The gaps section is deliberate. Read it before you decide whether StateJar suits what you are building.

Accepting an invitation

The page an invitation e-mail links to: choose a password for the account an admin made for you.

When an admin adds you to StateJar, an e-mail brings you here with a link that works once, for 7 days. Choose a password and you are signed in: opening the link proved the address. Nothing is stored about you from this page beyond the password's hash. If the link says it is not valid or has expired, ask the person who invited you to send a new one; you can also sign in with an e-mailed code from the sign-in page using the same address. Details: POST /auth/invite/accept.

API Docs screen

The endpoint reference, with samples in curl, Python, JavaScript and Node.

Every endpoint with its request shape, its response, and a copyable sample. Pick your language once at the top and every block on the page follows.

Use it when you are integrating. Use the API section of this help centre when you want to know why an endpoint behaves the way it does, the reference tells you the shape, help tells you the reasoning.

API Keys screen

Provider keys in, StateJar keys out.

Two independent halves.

Provider keys, one card per provider you can connect (OpenRouter, OpenAI, Anthropic, Google, and, only on installs that turn SHOW_OLLAMA on (not statejar.com), Ollama local and Ollama remote). Paste a key, press Save, then Test connection to confirm it actually works before you need it in front of an audience. Keys are encrypted at rest and are never returned by the API, the list shows only the last four characters.

StateJar API keys, keys you generate to call StateJar from your own code. Generated keys are shown once, at creation. There is no way to retrieve one later; generate a new key and delete the old one.

Related endpoints: provider keys, connection test, your API keys.

Audit Log screen

Every disclosure, chat, queries, manual records and tier-3 extraction calls, across every session, filterable, with replay.

The account-wide version of the Playground's Audit tab. Each row is one disclosure: when, which handle, which fields, which provider and model.

Replay reconstructs exactly what was sent for that request by re-reading the handle and re-applying the recorded field keys. If the state behind the handle had been altered, the re-derived handle would not match and the replay says so, which is what makes the audit trail evidence rather than a log.

No message text is stored here, only field keys. See audit entry and the replay endpoint.

Audit tab

This session's disclosures, newest highlighted.

The per-session view of the audit trail: one row per disclosure with the handle used, the field keys sent, and the provider and model they went to.

Every disclosure writes a row, and each row says which kind it is: chat, query_audited (a query sent with audit: true), manual, query (a plain query: the fields went back to the calling client) and extraction (a tier-3 call: the message and the names of known concepts went to your provider, the message itself is never stored). The once-only "changed by your message" note counts only the first three, the turns.

The account-wide version is the Audit Log screen.

Billing page

Your plan, the plans you can buy once billing opens, and every order with its receipt; while billing is closed (today) it offers no purchase, and the Android app never does.

Billing in the console sidebar (/billing) shows:

  • Your plan: the one your limits follow now, and the date a paid month ends. Nothing renews by itself: renew from here when you want another month.
  • Plans: each paid plan with its price as the pricing page lists it. While billing is closed, which is every environment today, the page says so and offers no purchase. Once a payment provider takes payments, Buy makes an order on our server, which reads the price from the plans table (your browser never sends one), and takes you to the provider's own page to pay. While test mode is on, a note says checkout runs on a stand-in and no money moves.
  • Monthly or Yearly: a year is twelve months for the price of ten, worked out by our server from the plan's monthly price (your browser never sends a price). The period is on the order and its receipt.
  • Join waitlist: while paid plans are coming soon, a paid plan's button stores your e-mail and the plan so we can tell you when it opens (how).
  • After paying the page shows one of three results: payment received, payment did not go through, or waiting for the payment. Your dates, use and order history are on My orders.
  • Orders: every order with its status. An unpaid order can be paid or cancelled (cancel); a paid one has a Receipt, which prints or saves as a PDF (receipt).

After paying, the provider sends you back to /billing?order=<id>, which asks our server about the order every few seconds and shows it once decided. Coming back does not start the plan: the server starts it only when the provider confirms the payment to it (how).

In the Android app nothing is for sale and nothing links to a payment page (Google Play's payments policy): the app shows your plan and your receipts. An account whose plan StateJar set buys nothing here either. Refunds follow the refund policy.

Region and gateways. The India / International switch on this page is the one on the pricing page: it sets the currency a price is written in and puts the Indian gateways first (or the international ones, then crypto), grouped under their own headings; every gateway the server has switched on stays selectable. A receipt is laid out for GST (the receipt): supplier, "GSTIN: not yet registered" until one is set, SAC, taxable value, and tax lines only once a supplier GSTIN exists.

Chrome extension (StateJar Memory)

A popup that shows and adds to a session's memory, restores a handle and copies the memory context. Not yet in the Chrome Web Store.

What it does. A popup, no content scripts, nothing runs on the pages you visit. In it you can:

What it needs. A developer key (sj_live_…). The key is kept in this browser's extension storage only and is never synced. The only permission it asks for is storage, and it can talk only to the StateJar API.

Status. Version 0.1.0 is built but not yet published in the Chrome Web Store. When it is, this entry will link to the listing.

Clear memory

Empty the memory of a scope for testing, without deleting any history.

Clear memory in the Playground toolbar asks before it does anything, and offers two extents:

  • This memory: the sessions on the same memory scope as the current one. For a normal session that is all your normal sessions; for a fresh session it is that session only; for the demo, the demo sessions. The other scopes are left alone.
  • Everything in my account: every session of every scope.

Memory versions are append-only, so nothing is deleted. Each cleared session gets a new, empty version as its head; every earlier version stays in the version list and can still be opened or restored by its handle. Because the empty version is now the newest, a new session that continues your memory starts from empty rather than from the old facts. Confirmations and held facts waiting in those sessions are dropped, so a cleared value cannot come back.

Each cleared session adds one row of kind clear to the audit trail, whose handle is the new empty head. This is the route POST /memory/clear.

Confirm your e-mail address

Profile asks an account made with a password to confirm its address with a code we e-mail it, so a later sign-in by e-mail never resets it.

Where. Profile, under Your e-mail address, while the address has not been confirmed and this server e-mails codes. Send me a code mails the account's address an 8-digit code; type it in and press Confirm.

Why. Signing up with a password does not prove that the address is yours. So the first time an address is proven by a sign-in, an e-mail code, a sign-in link, or a Google or GitHub account that has verified it, StateJar resets the account: whoever made it may not own the address. The password, every other session, the developer keys, apps connected through StateJar's MCP server, two-factor sign-in, linked Google or GitHub sign-in and saved provider keys are removed; the memory, the audit log and the profile stay. A sign-in mail for such an account says this before you use it.

Confirming the address from inside the account, while signed in, proves that the person who holds the account also holds the mailbox: nothing is removed, then or later, and a later e-mail sign-in still asks for your two-factor code.

If you did not make the account. A confirmation mail you did not ask for means someone registered your address: sign in to StateJar with a code e-mailed to you, and the account becomes yours, with everything they set removed.

No password. An account made by an e-mail code, Google or GitHub, or reset as above, has no password. Where other accounts give their password, deleting the account, downloading your data, turning two-factor sign-in off or making new recovery codes, it gives a code we e-mail it, or a code from its authenticator app when two-factor sign-in is on. API: GET /auth/account, POST /auth/account/code, POST /auth/account/confirm-email.

Connect with OpenRouter

Let OpenRouter make a key in your own account for StateJar instead of pasting one; its usage is billed to your OpenRouter credits.

The OpenRouter card on the API Keys page has a Connect with OpenRouter button under the paste form. It uses OpenRouter's own sign-in flow (OAuth with PKCE), and it ends the way a pasted key does: with an OpenRouter key saved for your account.

  1. Connect with OpenRouter takes you to openrouter.ai. Sign in there and approve the key; its label is prefilled as StateJar.
  2. OpenRouter sends you back to /connect/openrouter with a one-use code. StateJar checks that the answer belongs to the connection your account started in this browser, trades the code at OpenRouter for the key (how), and the page shows the key's last four characters.

The secret part of the flow (the PKCE verifier) is made and kept on StateJar's server, never in your browser.

The key lives in your OpenRouter account, where you can see its activity and revoke it. StateJar stores it encrypted with AES-256-GCM, exactly like a pasted key, and never shows it again. It is used for the OpenRouter models you pick in the Playground. It is also used first to read your messages for extraction while OpenRouter is one of the enabled extraction providers, so what those calls cost is billed to your OpenRouter credits.

Connected. The card then says connected and shows the key's last four characters and the date. Disconnect deletes the key from StateJar at once; revoke it in your OpenRouter account to end it there too. Pasting a key, or connecting again, replaces the saved one.

When it does not work. A connection must finish within 10 minutes (OpenRouter's codes expire after 10), in the browser that started it, signed in to the same account. If you took longer, came back in another browser, or cancelled at OpenRouter, the page says so and nothing is saved: press the button again. The button is not shown in the Android app (OpenRouter could not send you back into it) or on a server that does not offer the flow; paste a key from openrouter.ai/keys there instead.

Connected apps and the consent page

The page an MCP client sends you to when it wants to use your memory, what you are allowing, and the list on API Keys where you disconnect an app.

Two screens belong to the MCP server's OAuth sign-in.

The consent page (/oauth/authorize?req=…) opens when an app that speaks MCP (Claude, ChatGPT, Cursor, Codex, Gemini CLI, OpenCode, VS Code, Devin) asks to use StateJar as your memory. The server has already checked the app and its redirect address (how); the page shows what it checked: the app's name, where your browser is sent back to (a warning when that is your own computer), your account, and what the app may do:

ScopeLets the app
memory:readsearch, get, list, history, audit, verify, export
memory:writeremember, update, delete
offline_accessstay connected without asking again (the app gets a refresh token either way)

Allow issues the app a one-use code; Deny sends it access_denied. Either way you are sent back to the app. Only allow an app you are connecting yourself: a link someone else sent would connect their app to your memory. Nothing of your memory is shown or sent on this page.

Connected apps on the API Keys page lists every app that holds tokens for your account, with its scopes, when it connected and when it last used a token. Disconnect revokes every token at once (DELETE /oauth/clients/{id}); the app's next call fails and it may ask to connect again. Deleting your account removes the codes, tokens and the log of these connections with everything else.

Contact page

How to reach StateJar by email; phone and address are still to be added.

/contact is public and gives the contact email. The phone number and postal address are not published yet, and the page says so rather than showing placeholders as if they were real. To remove your data, see deleting your account.

Dashboard

Account-level counts and the most recent savings figure.

Four cards:

  • Sessions, how many session tags you have used.
  • Memory states, how many states you have written. Every accepted turn writes one, so this is roughly your turn count with facts in it.
  • Audit entries, how many disclosures have been made.
  • Tokens saved (last chat), the retriever's figure for your most recent query, measured against the disclosable state, not against a transcript replay. The card says so underneath. See token savings.

All four come from GET /memory/stats. See that endpoint.

Deleting your account

Profile → Delete account removes everything, permanently. The public /delete-account page explains what goes.

Where. Profile, at the bottom: Delete my account…. The panel asks you to type the account's email, enter your password and tick a box acknowledging that deletion is permanent. Only then does Delete my account permanently enable. An account without a password (made by an e-mail code, Google or GitHub) presses Send me a code and types the code we e-mail it instead, or, with two-factor sign-in on, a code from its app (why).

If the form is missing. The form appears only when the server you are connected to offers DELETE /account (the page checks the server's route list). An older server shows an email address instead: write from the account's address and the deletion is done for you.

What goes. Your account, every memory version in every session, the audit log, saved provider keys, developer API keys (they stop working immediately) and your profile. This browser is signed out and its chat history cleared.

What it cannot reach. Anything already sent to a model provider you chose is held under that provider's policy. Chat history kept in other browsers stays there until you log out of them.

The public page at /delete-account describes the same thing without needing a sign-in, and is where you land after a deletion. API: DELETE /account.

Docs (the docs home)

/docs opens with the integrations showcase, then getting started in seven steps, each with a recording of it running, then every docs page.

Docs (/docs) opens with the integrations showcase: one tab for each way in (SDK, agent harness, plugins, MCP and the command line), each with a snippet to copy, what it does for you in one line, and a link to its own page.

Then Getting started, step by step: sign up, verify your e-mail, create an API key, install the package, sign in from the terminal with statejar login, remember and recall, and see your memory in the console. Each step carries a recording of it running:

  • Terminal steps are real sessions recorded with asciinema, playing the same code the step shows. Full size opens one at its own size, and The recording (.cast) downloads the recording itself, which asciinema play replays. Pauses longer than 3 seconds are shortened to 3 seconds; nothing else is changed.
  • Browser steps (sign-up, the e-mail code, the API keys page, approving the terminal, the dashboard) are screenshots of the real pages, taken as the run went and played in order; waits for the server are shortened. The one key the API keys page shows is blurred.

All of them were made in one run, in one throwaway account, against a StateJar server and this site running on one machine. Each caption says what it shows, when it was recorded, and which version of a package ran. Under reduced motion each recording shows its last frame, still.

The same seven steps are one page, Getting started, readable without scripts and as Markdown. Every code block is a file (frontend/src/content/docs/blocks/) that a test script runs against staging (workflow docs-blocks): a block that stops working fails a check, not a reader.

Every docs page is listed at the end. The complete reference (every endpoint, field and status) stays on API reference.

Docs pages (one per surface)

Each docs page (/docs/<page>.html) uses one template, and each also exists as Markdown at /docs/<page>.md for agents.

Under the guided page, Docs lists one page for each place you can use StateJar, and the guides to how something works for your account, such as Pricing and payments. Every page uses the same template: what it covers, its sections, an "On this page" list, and the pages before and after it.

Each page exists in three forms:

  • The page itself: /docs/<page>.html. It is built with its text already in it, so a reader or a tool that runs no script still sees the whole page.
  • Markdown: /docs/<page>.md, for agents and anyone who wants the plain text.
  • A line in /llms.txt: the site's index for language models lists every page's Markdown address.

The figures on a comparison page come from one measured run, named on the page. The repository's tests hold every number on the page to that run's report.

Getting started, the pages for the Python SDK, the Node SDK, the statejar command and the MCP server, and Any LLM provider show recordings of their examples running: real runs against a local StateJar server, in a terminal or in this site, with the date each was made and the package version it ran in its caption.

Downloading your data

Profile → Download my data saves a JSON copy of everything your account holds, after your password.

Where. Profile, above Delete account: Download my data…. Enter your password and choose Download; the browser saves statejar-export-YYYY-MM-DD.json. An account without a password (made by an e-mail code, Google or GitHub) presses Send me a code and types the code we e-mail it instead, or, with two-factor sign-in on, a code from its app (why).

What is in it. Everything the account holds, table by table: every memory version in every session, the audit log, your profile, plan and usage, your two-factor and terminal sign-in records, values waiting for your confirmation, and the names and dates of your keys. What is left out is listed in the file: the password's hash, the keys themselves and their hashes, the two-factor secret and the recovery codes' hashes, none of which you need and all of which would help someone else into the account.

If the panel is missing. It appears only when the server you are connected to offers POST /account/export (the page checks the server's route list).

API: POST /account/export.

Handles tab

Every version of this session's state, newest first.

One row per state version, each with its handle. Click a row to inspect that historical state, the panel switches to Inspecting historical state and a back to latest control returns you.

Inspecting is read-only and changes nothing. To actually adopt an old state, copy its handle and restore it.

See handle and active state vs history.

Investors page

What StateJar is, the problem it solves, how it works, what is measured so far, the roadmap, and a form to register interest.

The Investors page (/investors) explains StateJar for investors: the problem, how it works, measured facts only, and the roadmap. Its form (how it is handled) records interest only. It is not an offer or solicitation to buy securities.

Light and dark theme

The sun/moon button switches between the light and dark themes; this browser remembers the choice, and until you choose, your device setting decides.

Where. The sun/moon button in the top bar, the console sidebar and the sign-in screen.

What it does. Switches every page between the light theme (warm paper) and the dark theme (the same design on a dark ground). The animated graphics switch with it.

Remembered per browser. The choice is kept in this browser only, not in your account; another browser or device starts from its own device setting. Until you press the button, StateJar follows your device's light or dark setting and changes with it.

Memory State tab

Everything currently remembered, by namespace.

The active state, rendered as a tree. Fields changed by the last turn are highlighted, and hovering a field shows which tier produced it.

Every namespace the API returns is rendered, including dynamic, which is where concepts nobody wrote a schema for live. See reading the Memory State panel and dynamic fields.

Below the tree sits the declined section, which is where to look when something you said is not in the tree.

After turn. When the session has more than one version, the row of version buttons above the tree shows the state as it was after each one; the latest is selected by default. Picking an earlier one opens it read-only, as the Handles tab does.

Message box

Where you type. Every message goes through the full pipeline.

Enter sends. Each message runs /memory/ingest first, extraction, canonicalization, handle, store, and then /memory/query and /chat to produce a reply.

Because ingest is separate, the memory pipeline works without a provider key. With no key configured you will see the state update and the panels fill; only the model's reply will report that no provider is set.

A message with no facts in it is normal and stores nothing. See reading the Memory State panel.

Model selector

Choose which model answers, grouped by provider.

Models are grouped by the provider they belong to, with free ones listed separately from paid ones where a provider offers both. Only providers you have configured appear.

Local Ollama models (only on installs that turn on SHOW_OLLAMA) are enumerated by your browser, not by our server. A hosted StateJar cannot see http://localhost:11434; your tab can. So the list is whatever you have actually pulled. See Ollama local.

Custom model lets you type a model id the catalog does not list, and pick which provider should route it. Useful for a model released after the catalog was last refreshed.

If a model you had selected disappears from its provider's catalog, StateJar falls back to the first available one and tells you rather than failing at send time.

My orders page

Your plan and how long it runs, this month's use against the limit, the next renewal and its price, Renew, Upgrade and Top-up, and every order with its receipt.

My orders in the console sidebar (/billing/orders) shows:

  • Your plan: when the paid period started, the date it is valid until, the days left (marked when seven or fewer remain) and this month's memory adds and recalls against the plan's limits. On the Free plan there is no start or end date.
  • Next renewal: the date to pay by and the amount of your last order. Paid plans never renew by themselves and nothing is charged without you paying, so you also get an e-mail seven days and one day before the end and a notice when it ends. When it ends the account is on the Free plan and nothing is deleted.
  • Renew, Upgrade (or Change plan) and Top-up buttons. Renew and Upgrade go to Billing, where you pick the period and the payment method; a top-up adds adds for this month only (1,000 extra adds, $1 / ₹85). While paid plans are coming soon the button says Join waitlist instead (how it works).
  • Order history: each order's number (with a Copy button for its reference), date, plan, amount, currency, payment method and status (pending, paid, failed, refunded or expired). A paid order has a Receipt, which opens on its own page to print or save as a PDF; with a GSTIN on the order and GST registered, the same page is laid out as a GST invoice (the receipt).

The data is your own account's only (the endpoint).

My tickets page

Ask the StateJar team for help, read its answers and close a ticket; replies also arrive by e-mail.

My tickets (in the sidebar) lists the requests you filed with their status: open, in progress, resolved or closed. Open one to read the thread, answer it, attach files (png, jpg, pdf or txt; up to 3, 5 MB each) or close it. New ticket takes a subject, category, priority and description, and optionally a session tag or handle of your own so we can look at the right memory. We answer here and by e-mail; internal notes the team keeps are never shown to you. Details: support tickets. Deleting your account deletes your tickets and their files.

New session - Continue my memory or Fresh memory

Choose whether a new Playground session continues your account memory or starts as a separate, empty test user.

+ New session asks which memory the new session uses.

  • Continue my memory is how a returning user is treated. The session has no version of its own yet, so it is answered from, and its first message builds on, the newest state of your other sessions (the same rule as cross-session consistency). Your demo sessions and fresh sessions are never part of that.
  • Fresh memory (separate test user) starts empty and stays empty of everything else. Nothing from your other sessions appears in it, seeds it or answers a question for it, and it seeds none of them. Its own messages build only on its own versions. Use it to test what a brand-new user sees, without old facts turning up in replies.

The choice is made when the session is created and shown by the memory label beside the session picker. A fresh session is listed as Fresh 2 - date in the picker.

Behind it, a fresh session is a session tag that starts with fresh-. The server treats that prefix as its own memory scope, the same way it treats the built-in demo's demo- prefix. A session you name fresh-... yourself through the API is isolated in the same way.

To empty a scope instead of starting a new one, use Clear memory.

Playground

The working screen: chat on the left, the memory inspector on the right.

The Playground is a full StateJar client. Everything it does is available through the API.

Left column: the toolbar (sessions, model picker, restore, demo buttons), the transcript, and the message box.

Above both columns: the seven-stage pipeline tracker.

Right column: four tabs, Memory State, Told the model, Handles, Audit, with the tier chips and the handle line above them.

Your transcript is stored in this browser, not on the server. Switch sessions and come back and it is exactly as you left it; open the same account on another device and the memory is all there but the conversation is not. See the FAQ.

Pricing page

Launch pricing: Free, Starter, Pro and Business with their prices, adds and recalls a month; billing is coming soon until a payment provider takes payments, every account is on Free, and the limits are enforced.

/pricing is public and shows the live price list (GET /plans). StateJar is in early access: every account is on the Free plan, billing is coming soon (the page asks billing config, and only once a payment provider takes payments does a paid plan link to the Billing page, never in the Android app) and the plan's limits are enforced: adds a month (429 plan_add_limit: messages a model read; a message the rules handle alone is free and unlimited), recalls a month (429 plan_recall_limit: query, chat, restore), fair use (429 fair_use_throttle: a very high rate of adds in an hour or a day), developer keys (403 plan_key_limit), requests a minute per developer key (429 plan_rate_limit), seats (403 plan_seat_limit) and history retention (a daily job, see run retention now). Audit export is the one listed limit not yet enforced.

Launch pricing, per month, + GST as applicable: Free $0 (3,000 adds, 20,000 recalls), Starter $15 / ₹1,299 (25,000 adds, 200,000 recalls), Pro $199 / ₹16,999 (300,000 adds, 2,000,000 recalls, fair use), Business custom (audit exports, an SLA, data in India). A year costs ten months' price. A top-up is 1,000 extra adds for $1 / ₹85.

A plan belongs to your account, so upgrading applies to the developer keys you already have; none is reissued. Model usage is never part of a plan: a model you choose in the Playground runs on your own provider key; StateJar's cloud providers read your messages and answer in the Playground (the default model) at StateJar's cost, within the monthly and hourly limits of your plan. No payment is taken today. See the refund policy, and pricing and payments in the docs for what each limit counts and what the API answers at one.

India or International. A switch above the plans picks the currency and which gateways come first: India shows rupees (INR) and the Indian gateways first; International shows US dollars (USD), the international gateways and then crypto. It starts from the region hint, else your browser's time zone and language, and your own pick wins and is remembered in that browser only. Every price carries its currency and "per month", and "+ GST as applicable". Once a gateway takes payments the page lists the ways to pay for the gateways that are on, with each brand's official mark where its owner's terms allow it and a plain name where not; no gateway is named while billing is closed.

Privacy page

Your privacy controls, what the service stores, what it never stores, who else sees data, how long it is kept, how to get a copy and how to delete it.

/privacy is public. It lists every kind of row the server keeps, email and password hash, memory versions, audit entries (field names, not values), encrypted provider keys, hashed developer keys, the optional profile, and states that message text and transcripts are not stored on the server.

It opens with Privacy controls: what you can do about your data and where (download a copy, delete the account, remove a fact, read every disclosure in the audit log, choose who reads your messages, revoke keys, two-factor sign-in, chat history kept in the browser).

How long it is kept says what the daily history-retention job does: memory versions and audit entries older than your plan's history window are deleted, except the newest version of each session, which is kept whatever its age. The window shown is the starting plan's, read from the live plans list (pricing; run retention now). A version is never edited; account deletion removes every version at once.

It also says what is not known yet: what the hosting provider itself keeps. See deleting your account and where chat history is stored.

Recovery codes

Ten one-time codes shown once when two-factor sign-in is turned on. Each replaces one authenticator code, once.

What they are. When you turn on two-factor sign-in, StateJar shows ten recovery codes, once. Each one can be typed instead of an authenticator code at sign-in, and works one time only.

Keep them away from the phone. They exist for the day the phone is lost. Copy or download them and store them somewhere else, a password manager or paper.

How they are stored. StateJar keeps only a SHA-256 hash of each code, so nobody, including the operator, can read them back. That is also why they are shown only once.

New codes. Profile → Two-factor sign-in → New recovery codes (needs your password and a current code). The old ten stop working at once.

Lost the phone and the codes? There is no email-based recovery yet: write to the contact address.

Refund policy

How cancellations and refunds will work once paid plans go on sale; no payment is taken today.

/refund-policy is public. No payment is taken yet, so nothing can be owed today. From the day paid plans go on sale: cancelling stops the next charge and the plan runs to the end of the paid period; charges made in error are refunded in full if reported within 30 days; a first payment is refunded in full if asked within 7 days. Approved refunds are processed to the original payment method within 5–7 business days, through the provider that took the payment. Charges from your own model provider key are not StateJar's. See contact.

Reporting a reply

The Report button under each model reply in the Playground sends that reply to the StateJar team, with a reason, without leaving the page or the app.

Every reply a model writes in the Playground has a Report link under it. Choose why (offensive, harmful, sexual, hateful, wrong about a fact, or something else), add a note if you like, and press Send report. The reply, the provider and model that wrote it, and your reason go to the StateJar team (POST /reports); nothing else from the conversation does. It works the same on the website and in the Android app, where Google Play asks every app whose replies are written by AI for this.

A report does not change your memory, and the team reads it in the admin console.

Restore a handle

Adopt a previously sealed state into the current session.

The box in the Playground toolbar. Paste a handle, submit, and that state becomes the current state of the session you are in.

Restore re-derives the handle from the stored state and refuses if the two do not match, so a successful restore is a verification, not just a lookup.

This is how memory crosses a session boundary without a shared user id. See your first handle restore and the endpoint.

If a restore appears to return different state than you expected, see the troubleshooting entry.

Session selector and + New session

Pick a thread, or start an isolated one.

The dropdown lists your sessions; + New session adds one and asks whether it continues your memory or starts with fresh memory (a separate, empty test user): see New session. The Memory chip beside the picker shows which, and Clear memory empties a scope.

Switching swaps everything belonging to that session together: the transcript, the tier chips, the per-field origins, the pipeline tracker, the last retrieval and the memory state. Nothing from the session you left stays on screen, showing the previous session's run beside this session's messages would be worse than showing nothing.

The session you were last in is remembered, so a reload returns you there rather than to the first session in the list.

To carry memory across sessions deliberately, copy a handle and restore it. See session.

Sessions

Profile's Sessions panel: every browser signed in to your account, and Log out everywhere.

Under Profile → Sessions is every browser currently signed in to your account (GET /auth/sessions): when it signed in, when it was last used, its browser, and this browser on the one you are reading from. A session ends when you log out, after 7 days unused, or 30 days after it opened.

Log out everywhere ends all of them at once (details); with two-factor sign-in on it asks for a fresh app code first. Use it when a device is lost or a session is not yours, and then change what let it in: a leaked password, or a mailbox someone else can read.

statejar login (sign in from the terminal)

`pip install statejar`, then `statejar login` opens statejar.com/device; approve once and your code has a key.

Since 2026-10-08 the Python package carries a command, the way gh auth login works:

pip install statejar
statejar login

It prints and opens https://statejar.com/device?code=ABCD-EFGH. Sign in there as usual (two-factor included), check the device's name and the code, and press Approve. The terminal, which has been polling, receives a one-year developer key and stores it, in the OS keychain when the optional keyring package is installed (pip install "statejar[keyring]"), else in ~/.config/statejar/credentials.json with mode 0600 (%APPDATA%\statejar on Windows). Your password never passes through the terminal, and the key never appears in the browser.

From then on StateJar() with no api_key uses STATEJAR_API_KEY when it is set, else the stored key. statejar whoami says which account the key belongs to; statejar logout forgets it on this machine (revoke it on the API Keys page to stop it everywhere). The key is an ordinary developer key: it reaches the memory API and nothing else, it cannot issue more keys, read provider keys or change the account.

The JavaScript package reads STATEJAR_API_KEY the same way (Node only, never in a browser); there is no npx statejar login yet, paste a key from the API Keys page, or copy the one statejar login stored.

Terms page

Links the StateJar SDK License (proprietary) and marks the service terms as not yet written.

/terms is public and keeps two documents apart. The StateJar SDK License (Version 1.0) is written, ships as LICENSE in both SDK packages, and is served in full at /legal/sdk-license.txt. It is proprietary, not open source: it permits installing and using the unmodified SDK with the StateJar service and redistribution of unmodified copies by package registries and mirrors, and it reserves every right it does not grant (section 3 of the licence).

The service terms for statejar.com and api.statejar.com are marked as a placeholder: not yet written, so nothing on the site promises uptime, support or a price.

The declined section

Values StateJar refused to store, each with the reason.

Below the state tree. Each row is a value that failed a guard, with the reason it failed.

This is a feature. The alternative to declining is guessing, and a guess that lands in the wrong field is indistinguishable, to the user, from a correct memory. A decline is the system saying I did not store that, and here is why.

How to read a reason:

  • unknown_key, nothing in the registry claims that name. Rephrase using a more common word, or it may belong as a dynamic field.
  • rejected_value, the field exists but the value is the wrong kind. This is the guard doing exactly its job.
  • low_confidence, the tier that proposed it was not sure enough for that field.
  • not an assertion, the clause was a question or a negation, not a claim.

See decline and _unmapped.

The demos

Two scripted runs that exercise the real pipeline with no provider key.

Short demo, a six-step scripted run that shows extraction, a handle, a session hop and minimal retrieval in about a minute. Space advances it; R restarts; Escape exits.

Run 17-turn demo, the longer disaster-relief scenario. See the demo scenario.

Both call only /memory/ingest and /memory/query. The user turns and the replies are fixed in the client, so no provider key and no LLM call are involved, but the memory pipeline underneath is entirely real: real extraction, real handles, real minimal retrieval, real audit rows.

That is the point of them. They work on a brand-new account with zero keys, which is the account a judge or a reviewer will have.

The handle line and copy button

The address of the state you are looking at.

Sits under the tier chips: handle: shm_… with a copy button.

The handle is the content address of the current state. It changes whenever the state changes and is identical whenever the state is identical.

Copy it to move this exact memory somewhere else, another session, another model, another vendor. Paste it into restore.

It is safe to share in the sense that it is a hash, but it is a key to your state: anyone who can call your account's API with it can read that state.

The integrations showcase

Tabs on the landing page and on /docs for every way to use StateJar (SDK, agent harness, plugins, MCP, command line), each with a snippet to copy, a one-line benefit and its docs page.

The landing page's developer section and the top of Docs show the same five tabs:

  • SDK: Python and Node.js;
  • Agent harness: OpenClaw and Hermes Agent;
  • Plugins: Claude Code, Codex and OpenCode;
  • MCP: the configuration for Claude Code, Cursor, VS Code, Codex, Gemini CLI and Windsurf, and the official MCP Python SDK in your own code;
  • CLI: statejar login.

Each choice shows its name, what it does for you today in one line, a snippet with a Copy button, and a link to its docs page; where a recording of it running exists, Watch a recorded run goes to it. The snippets are the docs pages' own code: the first-turn examples, or the first code block of a page's Install section, so the tabs and the pages never say different things.

The tabs work from the keyboard: Tab reaches the selected tab, the arrow keys move between tabs, Home and End jump to the first and last. Your last choice is remembered in this browser only.

Each tool is named in type. Where a one-colour mark of it is available under an open licence (Simple Icons, CC0), it is shown beside the name, in the page's text colour. The row says "Works with" and nothing more: the tools' owners have not reviewed StateJar, and the marks belong to them. Sources are in frontend/public/brand/NOTICE.md.

The memory label beside the session picker

Which memory the current session uses - your account memory, a fresh separate one, or the demo's.

The small Memory chip beside the session picker says which memory the current session reads and writes.

  • Continues your memory: a normal session. With no version of its own it is answered from, and seeded by, the newest state of your other normal sessions.
  • Fresh memory: a session started with Fresh memory (separate test user). It is isolated: other sessions never seed it or answer for it, and it seeds none of them. See New session.
  • Demo memory: the built-in demo's own line. It never touches your sessions.

Hover the chip for the full sentence. The scope comes from the session tag (fresh-..., demo-..., anything else), so it cannot drift from what the server does.

The seven-stage pipeline tracker

A live trace of the seven stages, with a real sublabel on each.

The strip above the chat shows the seven stages a message passes through. Nothing here runs on a timer of its own, every badge is filled from an actual API response, so the strip cannot claim a stage the panels did not reach. The only timed part is the reveal stagger, which paces stages that all completed in one round trip so the eye can follow them.

StageSublabelMeans
Ingestsentthe message reached the API
ExtractN fieldshow many fields the tiers produced
Canonicalizesortedkeys sorted at every level, the exact bytes the handle is computed from
Handlefirst 8 hex charactersthe content address that was derived
Storev1 or v+1a first state, or a new version of an existing chain
RetrieveN of M fieldshow many of the eligible fields were disclosed
Auditloggeda disclosure row was written

Click any stage to see its payload, including the canonical string the handle was actually computed from.

Retrieve and Audit only light up on a turn that asked something. An ingest-only turn legitimately stops at Store.

The sidebar

Seven destinations, and what each is for.

The left rail is the whole console.

  • Dashboard, counts and the last chat's savings. See Dashboard.
  • Playground, where you actually use StateJar. See Playground.
  • API Keys, provider keys and your own StateJar API keys. See API Keys.
  • API Docs, the endpoint reference with runnable samples. See API Docs.
  • Audit Log, every disclosure (chat, queries, manual records, tier-3 extraction calls) across every session. See Audit Log.
  • About, what StateJar is and what it does not do yet. See About.
  • Help, this centre. See Help.

Log out sits at the bottom. It clears the chat transcripts stored in this browser as well as your token, because those transcripts are held client-side and a shared machine must not hand them to the next person.

Under it, three small links: Privacy, Terms and Delete account, see Privacy, Terms and deleting your account. They are public pages, also linked from the site footer.

This help centre

Search, categories, deep links, and a coverage test that keeps it current.

Every entry has a stable id and its own anchor, so any entry can be linked directly, #concept.handle jumps to the handle entry.

Search matches titles, summaries, keywords and bodies. Try "declined", "why wasn't this stored", or "tickets". The FAQ is at the top of the page, and Raise a ticket opens My tickets when you cannot find an answer.

Last updated on each entry comes from the file itself, not from a hardcoded string, so it cannot claim to be fresher than it is.

Coverage is enforced by a test. Every API endpoint, setting, provider, tier, decline reason, value type and UI surface in the shipped product must have an entry here, or the build fails naming exactly what is undocumented. Deliberate exclusions are listed in the test with a written reason.

Tier chips

Which extractors ran, how many fields each contributed, and which failed.

One chip per tier that produced something on the last turn, with a count beside it.

ChipMeaning
rulesDeterministic patterns, the same answer on every machine
GLiNER2Schema-guided neural extraction, for what the patterns missed
LLMStrict-JSON extraction via your own provider key, messy input only

The number counts the fields that tier contributed, not the fields in the state. rules 2 means rules produced two of them.

A chip that is absent means that tier contributed nothing, usually because it was not needed. Rules answering completely is the normal, cheap case.

An amber "unavailable" chip is different and important: that tier was attempted and did not answer. A missing key, a timeout, an unreachable provider. Without this chip a failed tier and a skipped tier would look identical, and "rules only" would be ambiguous. See no LLM chip appearing.

"The model was busy" under the chips (extraction v3): the server's model was answering someone when this message arrived, so the message was read at once without it, and the model reads it again when it is free; memory then takes the model's reading (how). Nothing is lost.

Token savings badge

How much smaller the disclosed subset was, against a stated baseline.

Appears after a question. It reports how much smaller what was sent was than the disclosable state, every field that was eligible to be disclosed.

Two consequences of that baseline, both deliberate:

  • It cannot go negative. It is clamped at zero.
  • It is not a comparison against replaying the conversation. The 17-turn relief demo makes that other comparison, and that one can go negative and says so on screen.

Neither is a promise that you pay fewer tokens: on a short chat StateJar's memory block costs more than the history it replaces (measured figures).

A small number early on is honest, not broken: with three fields stored, there is not much to withhold. Read token savings and its baseline before quoting any figure from here.

Told the model tab

The exact subset, and the memory_context text, sent to the model for the last question.

Not your state, the slice of it that was disclosed for the most recent query, plus how many fields were dropped and why the selector chose these, and the memory_context text itself: the words the model received. (Before 2026-10-08 this tab was called Retrieved Context.)

This is the tab that makes minimal disclosure checkable. If your state has twelve fields and this shows two, then ten were withheld, and the audit entry records the same two keys.

Each reply in the chat also carries its own copy of this, under What the model was told, so an earlier answer can be checked after later ones.

Try StateJar (the sandbox)

/try lets anyone try the real pipeline without an account; everything in it is deleted at 00:00 UTC.

Try it (/try) is StateJar without an account. Tell it something about yourself, then ask about it.

  • It is the real pipeline. Each message goes through the same extraction, checks and content-addressed handle an account's message gets, and each question through the same retrieval. The page shows what each fact became: stored, held until said again, or declined with its reason. For a question, it shows exactly what a model would be told.
  • Each visitor's memory is apart. The browser keeps a random session id for this tab. The server makes an account for that session alone, so no other visitor's memory can reach yours.
  • Everything is deleted at 00:00 UTC. That covers every sandbox account and everything in it. Do not type anything private.
  • Secrets are removed first. API keys and tokens, private keys, passwords written after their name, and card numbers are removed before a message is read.
  • Limits.
  • Start over forgets this tab's session. The server deletes it, with the rest, at midnight UTC.

The calls behind it: session, remember, jobs, ask. For a memory of your own, sign up free.

Turn strip

The line under each of your messages: what it did to memory.

The small line under each message you send, read from that message's ingest response:

  • vN, the version it created in this session, or no new version when it changed nothing (a question, or a fact already stored: the handle stays the same and nothing is written).
  • the first characters of the handle the session now has;
  • changed, the field paths whose values it set or replaced;
  • declined, values it refused to store, with the reason in the declined section;
  • by, the extraction tiers that ran.

It is kept with the chat in this browser only.

Two-factor sign-in

Optional authenticator-app codes at sign-in, ten one-time recovery codes, and the sign-in lockout. Required for admin accounts.

Where. Profile → Two-factor sign-in.

Turning it on. Choose Set up, scan the QR code with an authenticator app (Google Authenticator, Microsoft Authenticator, Authy, 1Password…) or type the key shown under it, then enter the 6-digit code the app shows. You get ten recovery codes once: save them somewhere other than the phone. Each works once, in place of an app code.

Signing in. After your password, StateJar asks for the current code. A code is accepted only once.

Lockout. Ten wrong passwords or codes for one email address within 15 minutes lock sign-in for that address for 15 minutes. The answer is the same whether or not the address has an account. Sign-in is also rate-limited per IP address.

Admin accounts must have two-factor sign-in on, and the session must have been opened with a code, to see the admin panel.

An account without a password (made by an e-mail code, Google or GitHub) turns it off with a code from the app alone, or with a recovery code beside a code we e-mail it; new recovery codes take the app code alone (why).

Your address and the second factor. An account made with a password should confirm its e-mail address: until it does, the first sign-in by e-mail code, link, Google or GitHub resets the account, two-factor sign-in included. Once confirmed, every sign-in asks for the code.

There is no email-based recovery of the second factor: if you lose both the phone and the recovery codes, write to the contact address.

Ways to sign in

The login page's methods: a code or a link by e-mail, a password, Google or GitHub.

The login page offers every method the admin has on (GET /auth/methods):

  • Send me a code, type your e-mail; an 8-digit code arrives and you type it in. It works for 10 minutes, once, and five wrong tries kill it. New to StateJar? The same step creates your account: the address is proved before anything is stored. (details)
  • Send me a link, a one-time link arrives; open it in this browser and press Sign in. It is bound to the browser that asked, so a link that reaches someone else is useless to them. (details)
  • Password, as before, with two-factor sign-in if you turned it on.
  • Google / GitHub, live on statejar.com (the login page lists them whenever the owner has connected them; GET /auth/methods says which). Your account is matched by the address the provider has verified; a new address gets a new account. An address the provider has not verified signs nobody in and makes nothing: verify it with the provider, or use a code.

Whichever way you sign in, an account with two-factor sign-in on asks for its code next, once its address is confirmed. The first sign-in that proves the address of an account made with a password resets that account, in case someone else registered your address (Confirm your e-mail address). A sign-in opens a session: a cookie this browser keeps, which Log out ends on the server.

What the model was told

Under each reply: the handle, the fields, the retrieval mode and the exact context text.

Under every reply in the Playground. Open it to see what left for the model on that turn: the handle the answer was built from (or that there was no memory yet), the retrieval mode, the field paths sent, and the memory_context text exactly as sent. The audit link opens the Audit log filtered to the row that recorded it.

The size shown is an estimate: the context text's characters divided by four, the same rule the dashboard uses. It is not a tokenizer count.

The panel is kept with the chat in this browser only, like the rest of the transcript; the server keeps the audit row, never the message.

Providers

Anthropic

Claude models, direct.

Where to get a key. console.anthropic.com, API keys.

How to configure. API Keys screen, Anthropic card, paste, Save, Test connection.

Recommended models. Claude Haiku for extraction work, where cost and latency matter and the task is structured; a larger Claude for the conversation itself if the answers need reasoning.

Base URL. ANTHROPIC_BASE_URL if you need to point elsewhere.

What leaves your machine. Your question, plus the minimal memory subset StateJar retrieved for it, typically two or three fields. Never your whole state, and never the raw transcript. Check exactly what was sent in the Told the model tab and the audit trail.

When it is unavailable. Same handling as every other provider: a clear message naming the cause, never the key. If Anthropic is your tier-3 fallback, StateJar tries the next model in the chain before giving up and marking the tier unavailable.

Google (Gemini)

Google's models, direct.

Where to get a key. Google AI Studio.

How to configure. API Keys screen, Google card, paste, Save, Test connection.

Recommended models. A Flash-class model for extraction and for most chat; they are the cost and latency match for what StateJar asks of a model.

Base URL. GEMINI_BASE_URL.

What leaves your machine. Your question, plus the minimal memory subset StateJar retrieved for it, typically two or three fields. Never your whole state, and never the raw transcript. Check exactly what was sent in the Told the model tab and the audit trail.

When it is unavailable. Reported as a readable message. Note that Google's API returns some quota errors with a 200 status and an error body, so StateJar inspects the body rather than trusting the status code, a "success" that contains an error is still treated as a failure.

Ollama (local)

Your own Ollama on your own machine, for replies; only on installs that turn SHOW_OLLAMA on, not on statejar.com.

Not offered on statejar.com. The Ollama cards appear only where an install turns SHOW_OLLAMA on (a StateJar you run yourself); the hosted site shows OpenRouter, OpenAI, Anthropic and Google. StateJar itself runs no Ollama or other model on its servers (since 2026-10-30).

LOCAL · browser-direct. This is the only provider where nothing about your conversation reaches any server, including ours.

Where to get it. ollama.com. Install, then pull a model:

ollama pull llama3.2

How to configure. API Keys screen, Ollama (local) card. The base URL defaults to http://localhost:11434. There is no key field, a local daemon does not need one. Save, then Test connection.

How it actually works. A hosted StateJar backend can never reach http://localhost:11434; that address exists on your machine, not on ours. So your browser talks to Ollama directly. StateJar's server still does the memory work, retrieval, handles, audit, but never sees the conversation. The model list in the picker is enumerated by your browser too, so it is whatever you have actually pulled.

What leaves your machine: nothing. Not the prompt, not the reply. StateJar supplies only the minimal memory subset, and it supplies it to your own browser, which passes it to your own daemon.

Origin policy, the one configuration that is required. Ollama refuses cross-origin browser requests unless it is started with OLLAMA_ORIGINS set to the page's origin. Without it the request fails as an opaque browser error. Restart Ollama with:

# macOS / Linux
OLLAMA_ORIGINS=https://statejar.com ollama serve

# Windows (cmd)
set OLLAMA_ORIGINS=https://statejar.com
ollama serve

# PowerShell
$env:OLLAMA_ORIGINS="https://statejar.com"; ollama serve

Substitute the origin you are actually loading StateJar from, use http://localhost:5173 when running the dev server. The card generates the exact command for your current origin and gives you a copy button, so prefer that over retyping.

Base URL forms. Ollama's own docs write endpoints as http://localhost:11434/v1/ and https://ollama.com/api/chat, so a pasted base often already carries a path. StateJar strips a trailing /api or /v1 rather than appending to it, both forms normalise to the host.

When it is unavailable. A dead daemon and a blocked origin both surface to scripts as the same opaque error, so StateJar distinguishes them by whether anything is listening at all and tells you which one you have. See connection refused and blocked by origin policy.

Ollama (remote / cloud)

An Ollama host we call on your behalf. Requires a key.

Not offered on statejar.com. The Ollama cards appear only where an install turns SHOW_OLLAMA on (a StateJar you run yourself); the hosted site shows OpenRouter, OpenAI, Anthropic and Google. StateJar itself runs no Ollama or other model on its servers (since 2026-10-30).

REMOTE · via StateJar server. Deliberately a separate card from Ollama local, because the privacy story is different and collapsing them into one card made that difference invisible.

Where to get a key. From whoever runs the host, Ollama Cloud at https://ollama.com, or your own deployment.

How to configure. API Keys screen, Ollama (remote) card. Enter the base URL and an API key. The base URL has no default: a remote host is exactly the case where guessing an address would be wrong. Save, Test connection.

Why a key is mandatory here. A local daemon is reachable only from your own browser, which is its own access control. A remote host is reachable by our backend, and that is the whole reason a key exists, so this card refuses to save without one.

What leaves your machine. Your question and the minimal memory subset, via StateJar's backend, which forwards them to the host you named. Unlike the local card, our server does see the prompt in transit. The card says so.

When it is unavailable. Errors are translated: a 404 at the address says the base URL is wrong, an unrecognised model says so by name, and a 200 response carrying {"error": "Unauthorized"}, which some hosts return, is treated as the failure it is rather than as success.

OpenAI

A direct connection if you already hold an OpenAI key.

Where to get a key. platform.openai.com, API keys section.

How to configure. API Keys screen, OpenAI card, paste, Save, Test connection.

Recommended models. gpt-4o-mini for both chat and tier-3 extraction; it is the cheapest model that reliably returns strict JSON.

Base URL. Defaults to the public API. Override with OPENAI_BASE_URL if you route through a gateway or a compatible endpoint.

What leaves your machine. Your question, plus the minimal memory subset StateJar retrieved for it, typically two or three fields. Never your whole state, and never the raw transcript. Check exactly what was sent in the Told the model tab and the audit trail.

When it is unavailable. Auth failures, rate limits and unknown models are translated into plain messages. StateJar never echoes a provider error body back to you verbatim, because those bodies have been observed to contain the request, including the key.

OpenRouter

One key, many models including free ones. The easiest place to start.

Where to get a key. Sign up at openrouter.ai and create a key in your account settings.

How to configure. API Keys screen, OpenRouter card, paste, Save, then Test connection. Or press Connect with OpenRouter and approve a key in your OpenRouter account, with no copying and pasting (how it works).

Recommended models. StateJar's default is openrouter/openai/gpt-4o-mini, cheap, fast, and reliable at the strict-JSON extraction tier 3 needs. OpenRouter also lists free models, which is what makes it the practical choice for a first run.

The model id is namespaced deliberately: a bare openai/gpt-4o-mini is both a valid OpenRouter id and an OpenAI routing prefix, so StateJar spells out which one it means rather than depending on precedence.

What leaves your machine. Your question, plus the minimal memory subset StateJar retrieved for it, typically two or three fields. Never your whole state, and never the raw transcript. Check exactly what was sent in the Told the model tab and the audit trail.

When it is unavailable. A bad key, a rate limit and an unknown model each produce a distinct, readable message, never a raw provider payload and never your key. If tier 3 is configured to use OpenRouter and it fails, the chip goes amber (unavailable) and the deterministic tiers still run, so extraction degrades rather than stopping.

StateJar's default model

The model the Playground answers with when you have no key of your own, a cloud model through StateJar's provider pool, within an hourly limit; a 503 "busy" when no provider answers.

No key needed. The Playground works with no provider key of your own: its first choice in the model picker is StateJar default model (cloud/default). A reply is written by one of the cloud providers an administrator has enabled (BACKUP_PROVIDERS, paid Gemini first, then Mistral, then OpenRouter today, in the order set in the provider table in the admin console) with StateJar's own keys. If you have saved a key of your own for one of those providers, yours is used first and is billed to you.

What it is for. Replies in the Playground, so you can see memory work without signing up anywhere else. memory_context is the same whichever model answers. StateJar runs no model of its own on its servers (owner's decision, 2026-10-30): the earlier free built-in model is gone, and a client that still has it saved is answered by this model.

Limits. CLOUD_REPLIES_PER_HOUR replies per account per hour (HTTP 429 after that: add your own key for more). When no cloud provider and key answers, /chat answers HTTP 503 with a Retry-After and the message "busy, retry in a moment"; no reply is made up, and nothing is stored or lost by it.

Where the prompt goes. The message and the memory the reply needs go to the provider that answers, as for extraction; /privacy lists the providers and each one's terms. The disclosure is audited like any other reply: provider cloud, with the model that answered named in the audit row.

Working with the API

A background ingest job

The status, and when done the result, of a message read in the background (async, or long).

GET /api/v1/memory/ingest/jobs/{job_id}

A message is read in the background when it is sent with "async": true (reply first, update memory right after), or, with extraction v3 switched on, when it is longer than EXTRACTOR_V3_BACKGROUND_CHARS (3,000 characters by default; at most 50,000). POST /memory/ingest answers 202 with job_id, status: "queued" and status_url. A session's jobs run one at a time in the order they arrived: a job stays queued while an earlier message of its session is being read, and reads its parent version when it starts.

This endpoint returns status, queued, running, done or failed, and, once done, result: exactly the body a direct ingest would have returned. Only the account that sent the message can read its job (404 for anyone else). The SDKs wait for it with a timeout (ingest(..., wait=True, timeout=...)).

The message itself is never stored: it lives in the server's memory while it is read. If the server restarts meanwhile the job reports failed within a minute ("the server restarted while reading this message"); send the message again. Finished jobs are kept for 2 days.

A code for your own account

POST /api/v1/auth/account/code e-mails the signed-in account an 8-digit code for one purpose (confirm the address, delete, export, 2FA changes).

POST /api/v1/auth/account/code with {"purpose": "…"} (a console session; a developer key gets 401)

Sends the account's own address an 8-digit code for one purpose, from the same machinery as a sign-in code: it works for 10 minutes, once, and dies after five wrong guesses. It is kept only as a keyed hash, and its purpose is part of it: a code for one purpose does nothing for another, a sign-in code confirms nothing, and this code never signs anyone in.

purposeWhat the code is for
confirm_emailConfirm the account's address from inside it (any account whose address is not confirmed yet)
delete_accountDELETE /account, as email_code, for an account without a password
exportPOST /account/export, as email_code, for an account without a password
2fa_disablePOST /auth/2fa/disable, as email_code beside a recovery code
recovery_codesPOST /auth/2fa/recovery-codes, as email_code beside the app code

Answers. 202 {"sent": true, "detail": "A code is on its way to your address. …"}. 409 when the purpose does not apply: the address is confirmed already, the account has a password (give it instead), or two-factor sign-in is off. 429 past five codes for the address in 15 minutes (sign-in codes count too) or five requests from one network address in 15 minutes (twenty an hour). 503 when this server does not e-mail codes (EMAIL_SIGN_IN off, AUTH_LINK_SECRET unset or no mail server that sends), or the message could not be sent.

The mail says what the code is for. If you did not ask for it, someone is signed in to your account: sign in and use Log out everywhere under Profile.

A payment QR image

A payment QR image.

GET /api/v1/billing/manual/{method}/qr

Signed in only. The owner's QR image for manual_upi or binance_personal, with no-store caching. 404 when the method is not on offer.

Test mode only (2026-11-05): these methods are not offered on the production deployment and never activate a plan there. Nothing is ever credited on a UTR alone.

The providers listed in PAYMENTS_ENABLED_PROVIDERS are unaffected; these two methods are separate, test-only, and paid plans stay "coming soon".

A token for the cookie's session

Hands a browser that holds only the session cookie the JSON token the console runs on.

POST /api/v1/auth/session/token, with the __Host-sj_session cookie, no other credential.

After a Google or GitHub sign-in the browser is redirected back with the cookie alone; the console calls this once to get the access_token it uses for every other call. The cookie authenticates the request, so it must pass the CSRF check every cookie-only change passes: the header X-Requested-With: statejar and a same-origin Sec-Fetch-Site or an allowed Origin; otherwise 403 csrf_rejected. Without a live session: 401.

Accept an invitation

POST /api/v1/auth/invite/accept {token, password} opens the invitation link an admin sent, sets the account's password and signs in.

POST /api/v1/auth/invite/accept {"token": "…", "password": "…"}, no sign-in needed; the token is the one in the invitation e-mail's link (/invite?token=…), sent when an admin added the account.

The link works once and for 7 days. Opening it sets the password (8 to 72 bytes, like sign-up), proves the e-mail address (the link reached the mailbox) and answers an ordinary sign-in (access_token, the session cookie). An unknown, used, expired or replaced link is 404; a suspended account is 403; password sign-in switched off in Login methods is 404. Rate-limited per address like sign-in. Written to the login audit log as a password sign-in.

Account stats

Counts for the Dashboard, plus the last chat's savings figure.

GET /api/v1/memory/stats

Session count, state count, audit count, and token_saved_pct for your most recent query.

That percentage is measured against the disclosable state, not a transcript replay, and is clamped at zero. See token savings and its baseline.

Integrity. Counts are reported as stored; the savings figure is computed from a state only if its bytes still hash to its handle, otherwise it is null and token_saved_basis says why. The integrity block lists any state that failed.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Allow or deny an OAuth request

Your decision on the consent page: allow issues a one-use ten-minute code bound to the app, deny sends it access_denied; either way you are sent back to the app.

POST /api/v1/oauth/authorize/decide, body {"req": "…", "allow": true|false}, signed in.

Allow issues an authorization code (sjo_…, one use, ten minutes) bound to the app, its redirect address, its PKCE challenge, the scopes, the MCP resource and your account; the server keeps only its SHA-256. Deny issues nothing. The answer is {"allowed": …, "redirect_to": "<the app's address>?code=…&state=…&iss=…"} (or error=access_denied), and the page sends the browser there. The request is spent either way: a second decision on the same id is 404. Every decision writes an event row the account can read in its export and that deletion removes.

Approve a terminal sign-in

Issue a one-year developer key for the device and park it for the terminal to collect; the browser never sees the key.

POST /api/v1/device/approve { "user_code": "ABCD-EFGH" }

Signed-in only, two-factor included where the account has it. Creates an ordinary developer API key, data plane only, one year, labelled statejar login: <device name>, listed and revocable on the API Keys page like any other, and parks it, encrypted with the server's provider-key AES key, for the terminal's next poll. The response carries only the last four characters: a key shown on a page gets pasted into chats, and the browser has no use for it. 409 when the code was already decided, 410 when it expired.

Ask for a sign-in code

Sends an 8-digit one-time code to an e-mail address. Always answers 202, whether or not the address has an account.

POST /api/v1/auth/code/request with {"email": "you@example.com"}.

Answers 202 with the same body in every case, the address has an account, has none (one is created when the code is verified), is suspended, asked too often, or the server could not send, so nobody can learn from this route which addresses have accounts. When a mail goes out it carries an 8-digit code that works for 10 minutes, once, and for five wrong guesses. Limits: 5 codes per address per 15 minutes (further requests are answered but nothing is sent), 5 per IP per 15 minutes and 20 per hour (429). A wrong code counts toward the sign-in lockout like a wrong password. The server stores only a keyed hash of the code (AUTH_LINK_SECRET); without that setting, a mail server that sends, or the EMAIL_SIGN_IN switch the route answers 503. The admin can turn this method off from Login methods, after which it answers 404. For an account whose address was never proven, the mail says that using the code resets what the account's creator left (Confirm your e-mail address).

A browser that has signed in to the address before carries the cookie __Host-sj_known (a keyed hash of the address, for a year): its requests count against a per-address budget of their own, the same size, and its wrong codes against a lockout counter of their own, so someone who only knows your address cannot use up your sign-in by asking for codes or guessing them from elsewhere. The cookie signs nobody in; a code or a link is still needed. Signing out clears it.

Ask for a sign-in link

Sends a one-time sign-in link, bound to the browser that asked for it. Always answers 202.

POST /api/v1/auth/link/request with {"email": "you@example.com"}.

Answers 202 with the same body whatever the address's state, like a code request. The mail carries a link to /login/link?token=… on this site that works for 10 minutes, once, and only in the browser that asked: that browser receives the cookie __Host-sj_link when it asks, and the link is consumed only when the cookie matches. Opened on another device, the link is refused and the page says to open it where it was requested, or to use a code instead. Same limits as a code: 5 per address per 15 minutes, 5 per IP per 15 minutes. The token is stored as a keyed hash only. A link for an address without an account is refused at consumption (409): sign up with a code, which creates the account. Like a code request it answers 503 while EMAIL_SIGN_IN is off, and its mail warns an account whose address was never proven (Confirm your e-mail address).

A browser that has signed in to the address before carries the cookie __Host-sj_known (a keyed hash of the address, for a year): its requests count against a per-address budget of their own, the same size, and its wrong codes against a lockout counter of their own, so someone who only knows your address cannot use up your sign-in by asking for codes or guessing them from elsewhere. The cookie signs nobody in; a code or a link is still needed. Signing out clears it.

Audit facets

The distinct values available to filter the audit trail by.

GET /api/v1/audit/facets

Powers the filter controls on the Audit Log screen: which sessions, providers and models actually appear in your trail, so the filters offer real options rather than a guess.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Audit trail

Every disclosure, chat, queries, manual records and tier-3 extraction calls,, filterable by session and scope.

GET /api/v1/audit

One row per disclosure: handle used, field keys sent, provider, model, request id, timestamp.

Field keys, never values, and never message text. The trail records what was disclosed, not what it said. See audit entry.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Billing (your plan and orders)

Your plan, the paid period in force, whether payments are open, and your orders newest first, each paid one with its receipt.

GET /api/v1/billing, a console session only; a developer key gets 401, as for deleting the account.

What the Billing page shows:

  • plan, the plan the account is on now (slug, name), worked out as the limits are: an admin-assigned plan first, then a paid period in force, then the default (Free).
  • managed, true when an admin assigned the plan; such an account buys nothing online (orders answer plan_managed).
  • paid_until, paid_plan, the end of the paid period in force and its plan, or null.
  • config, the same object as billing config: which payment providers take payments, and whether in test mode.
  • orders, the account's orders, newest first, at most fifty: plan, status (created, pending, paid, closed, expired, refunding, refunded, failed), the price as the plans table had it, what the provider charged, the payment reference, the paid period, test_mode, and checkout_url while the order is open.

Nothing about how you paid is here or anywhere in StateJar: you pay on the provider's own page. A paid order has a receipt. Nothing takes payments until PAYMENTS_ENABLED_PROVIDERS names a provider.

Billing config (are payments open?)

Which payment providers take payments, their names, the currency each is asked to charge in, and the mode (test or live); no account needed.

GET /api/v1/billing/config, no account needed; the pricing page reads it.

{"enabled": false, "providers": [], "names": {}, "currency": {}, "mode": "test"}
  • enabled, whether any provider takes payments. While it is false, the pricing and Billing pages offer no purchase and say billing is coming soon, and creating an order answers 503 payments_disabled. That is every environment today.
  • providers, names, currency, each provider that takes payments, the name a page shows for it, and the currency of the plan price it is asked to charge (the provider may convert it, and the order then says what it charged).
  • mode, test: the provider is a stand-in on the server's own machine and no money moves (PAYMENTS_MODE); live: real payments; off: the mode setting is neither, so nothing takes payments.

A provider is listed only when it is switched on (PAYMENTS_ENABLED_PROVIDERS) and its configuration fits the mode; otherwise the server logs why and lists nothing.

Billing orders (buy a plan)

Create an order for a paid plan (the server takes the price from the plans table and asks the payment provider for a checkout), and read an order back; nothing here activates a plan.

POST /api/v1/billing/orders with {"plan_id": 2, "provider": "binance"}, and GET /api/v1/billing/orders/{order_id}, a console session, your own orders only (another account's order is 404).

The browser sends a plan and a provider, never a price. The server reads the plan's price from the plans table (the one GET /plans lists), records the order, asks the provider for a checkout, and answers with the order and its checkout_url, the provider's own page, where you pay. A second click while that order is open gets the same order back (200, "created": false), never a second one.

Refusals, each with error and a message:

AnswerWhen
503 payments_disabledno provider takes payments (billing config)
404 plan_not_foundno active plan has that id
409 plan_not_for_salea free or contact-only plan
409 plan_managedan admin set this account's plan
409 plan_in_forceanother plan is paid for until until; change plans when it ends
409 order_openan unpaid order for another plan is open: pay or cancel it
502 provider_errorthe provider refused to create the order; nothing was charged, try again

Reading an order activates nothing. The provider sends your browser back to /billing?order=<id>, which reads this endpoint until the order says paid. Only the provider's signed notice to the webhook, or the server asking the provider itself, marks an order paid. A paid order buys one month of the plan, from the end of the same plan's paid period if one is in force; nothing renews by itself. Nothing takes payments until PAYMENTS_ENABLED_PROVIDERS names a provider.

Blog images (public)

GET /blog/img/{name} serves an uploaded blog image variant with a one-year immutable cache.

GET /blog/img/{hash}-{480|960|1600}.{webp|avif}, {hash}-og.jpg or {hash}.svg, no sign-in. The name carries the first 16 hex characters of the file's SHA-256, so a changed image has a new name and the response is Cache-Control: public, max-age=31536000, immutable, with X-Content-Type-Options: nosniff and a CSP that allows nothing (an SVG is sandboxed and shown only through an image tag). 404 for any other name.

Blog pages (public)

GET /blog lists published posts (filter by category or tag, pages of 9); GET /blog/{slug} is one post. Finished HTML, no sign-in.

GET /blog (query category, tag, page) and GET /blog/{slug}: no sign-in. The pages are finished HTML with no script (the site is static, so the backend renders them): Open Graph and Twitter tags, Article JSON-LD, a canonical URL and Cache-Control: public, max-age=60. A post that is not published is 404. The text is the markdown an admin wrote, escaped and rebuilt from an allow-list, so HTML typed into a post is shown as text.

Blog RSS feed (public)

GET /blog/rss.xml is the RSS 2.0 feed of the 30 newest published posts.

GET /blog/rss.xml, no sign-in: RSS 2.0, newest first, title, link, excerpt, date and categories for each post. Drafts and unpublished posts are never listed.

Blog sitemap (public)

GET /blog/sitemap.xml lists /blog and every published post for search engines.

GET /blog/sitemap.xml, no sign-in: the sitemap of /blog and every published post with its date. robots.txt points to it. Drafts and unpublished posts are never listed, and it is cached for a minute.

Buying a top-up

An order for a top-up pack, which adds adds (the pack's 1,000 extra adds, $1 / ₹85) to this month's limit for an account on the pack's plan.

POST /api/v1/billing/topups with {pack_id, provider}, a console session only. It can also take currency, gstin, and the firstname and phone that one gateway's form needs.

The price comes from the pack, never from the request. The account's plan in force must be the pack's plan; if it is not, the answer is 409 topup_not_for_plan. Once paid (the same rule as every order: a verified notice and a server-side status check), the pack's operations are added to this month's limit. A top-up never starts or extends a plan period.

Pack prices are the owner's, set on the admin console (top-up packs). None is sold until one is set there.

Cancel an unpaid order

Closes an open, unpaid order at the payment provider and then here, so it can no longer be paid; a paid order is not cancelled, it runs to the end of its month.

POST /api/v1/billing/orders/{order_id}/cancel, a console session, your own orders only.

An open order (created or pending) is closed at the provider first, so its checkout page stops taking payment, and then marked closed here; a new order can be made after it. If the provider will not close it, the answer is 409 cannot_close and the order stays open: a payment may already be under way, and the provider's notice or the server's own check decides.

An order that is not open is returned as it is. A paid plan is never cancelled here: each order buys one month and nothing renews by itself, so it simply ends. A refund goes through StateJar, by the refund policy. Nothing takes payments until PAYMENTS_ENABLED_PROVIDERS names a provider.

Cancelling a subscription

Cancel a monthly subscription at the end of the month already paid for; that month is kept.

POST /api/v1/billing/subscriptions/{id}/cancel, a console session, for the account's own subscription only.

The gateway is asked to cancel at the end of the current cycle, so the month already paid for runs to its end and nothing more is charged. If the gateway refuses, the answer is 502 and the subscription is left as it was.

Chat

Query, then send the subset to a model and return its reply.

POST /api/v1/chat

Composes query and a provider call: retrieve the minimal subset, build a prompt from it, call the model you named, write an audit row, return the reply.

The raw transcript is never sent. What the model receives is the retrieved subset and the current turn.

Requires a provider key. Upstream failures are translated into readable errors with an appropriate status, a provider 5xx becomes a 502, a timeout becomes a 502, never a leaked payload.

The state is re-derived before the model sees it. Edited bytes are refused with HTTP 409 before any provider is called. A change made by the latest message is announced to the model once, with its new value only.

With no memory yet, chat still answers; the model is told that no user state is stored.

Memory updated after the reply. Like a query, chat first waits for the session's unfinished background ingests, at most INGEST_WAIT_S; pending_ingest: true in the response says the reply used the version from before them. On the server's own model a reply goes before any message being read (EXTRACTOR_V3_MAX_IN_FLIGHT).

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Checkout choices

Which payment gateways are switched on, the currencies each takes, whether it sells subscriptions, the top-up packs on sale and the GST note every price carries.

GET /api/v1/billing/checkout. No account is needed. The answer has the same fields as /billing/config, and adds:

  • currencies: the currencies each switched-on gateway takes. INR is first for every Indian gateway; USD is offered where the gateway takes it.
  • subscriptions: whether each gateway sells a monthly subscription here.
  • topups: the top-up packs on sale.
  • gst_note: "+ GST as applicable".

The billing page reads this to let the payer pick a gateway among those switched on. Paid plans stay "Coming soon" while no gateway is switched on.

Clear memory

Empty a memory scope by writing a new empty head; history is kept and the call is audited.

POST /api/v1/memory/clear

Body: {"session_tag": "...", "scope": "scope" | "account"} (default scope).

Versions are append-only, so this never deletes. For every session in the chosen extent it writes a new, empty version (the empty state, handle shm_719a8f7d2aa1b2262d87eade013fc7360f11a194) whose parent is the session's previous head, and writes one audit row of kind clear with that handle. Earlier versions remain, verifiable and restorable by their handles.

  • scope: "scope" clears the memory scope of session_tag: the caller's normal sessions, or that one fresh- session, or the demo- sessions.
  • scope: "account" clears every session of the account, every scope.

The response lists cleared (session, previous head, how many held items were dropped), empty_handle and history_kept: true. A scope with nothing in it clears nothing and writes nothing. It acts only on the caller's own account.

Because the empty version is the newest in its scope, a session that continues the account memory (see cross-session memory) is seeded from it. A fresh- session never reads another session's head; see New session.

Close a ticket

POST /api/v1/tickets/{id}/close closes one of your tickets; a closed ticket takes no more replies.

POST /api/v1/tickets/{id}/close sets your ticket's status to closed. 404 when it is not yours. Open a new ticket if you need help again. See support tickets.

Cloud extraction health check

Whether extraction's cloud providers can read a message now; 503 with the reason when none of the enabled ones can.

GET /api/v1/health/extraction/cloud

No authentication, no user data, and this request calls no provider: it answers from the outcomes of the server's own recent cloud calls and of the cloud watcher's probes. Each enabled provider is listed with its state (ok, failing or unknown), a short code (ok, unparseable, http_<status>, timeout, cost_limit, no_usable_key, not_configured, error), when it was seen (at, age_s), whether a real call or a probe saw it (source) and whether one of its keys could be used now (usable_key). It names provider ids and states only: never a key, a key's label or account, a model or a provider's own error text.

  • 200 with "reason": "ok": at least one enabled provider's newest outcome is a success.
  • 200 with "reason": "not_checked_yet": no provider has an outcome yet (just after a start; the watcher looks within seconds).
  • 200 with "cloud": "off": no provider is enabled, or BACKUP_PROVIDERS is empty. Then no model reads a message: the rules and the span proposer do, and weak facts are held.
  • 503 when no enabled provider can read: cost_limit (every one at its monthly cap), no_usable_key (every key disabled or cooling down), or cloud_calls_failing.

While the cloud cannot read, there is no local model behind it (owner's decision, 2026-10-30): messages are read by the rules and the span proposer alone, their weak facts held, and read again from the queue when a provider is healthy; a chat reply answers 503 "busy, retry" until one is. The model-server check, the extraction health check, answers 200 "no_local_model" on a server that runs none. Thirty requests a minute per address. StateJar's own check of production asks both every six hours and opens an issue that says which path failed on a second failure.

Collect the key (terminal poll)

The terminal asks every few seconds whether you approved; the key comes back exactly once.

POST /api/v1/device/poll { "device_code": "…" }

What statejar login repeats every interval seconds. 428 authorization_pending until you decide; 410 once the code has expired (ten minutes) or the key was already collected; 403 when you denied it; 200 once, with the key, its last four characters, its expiry and your e-mail. The server then wipes its parked copy, so a second poll, or anyone who later reads the database, gets nothing. Limited to 60 calls a minute per address.

Compare input cost

The input tokens of the full chat history against the last N messages plus StateJar's memory_context, and what that input costs for one reply model at the listed price. No model call, nothing stored.

POST /api/v1/memory/cost-compare

Body: the same as POST /memory/token-compare (messages, memory_context, window), plus provider and model, the model that answers the chat.

It answers with token-compare's counts and, when that model has a listed price, price_usd_per_m (input, output), input_usd_without (the full history), input_usd_with (the last N messages plus the memory context) and input_usd_saved.

The price. Taken from the paid-tier list StateJar keeps for its own cost estimates, dated in price_date. A model that is not on the list has no price: the dollar fields are null, never a guess, and the token counts are still given.

What it leaves out. The reply the model writes (it is the same either way), any system prompt of your own, and StateJar's own extraction calls, which are shown on the dashboard. Like token-compare it can be negative on a short chat.

Nothing is stored, no memory is read, no model is called and no audit row is written.

Compare input tokens

Count the input tokens of the full chat history against the last N messages plus StateJar's memory_context. No model call, nothing stored.

POST /api/v1/memory/token-compare

Body: messages (the chat so far, role = user or assistant, ending with the message being answered), memory_context (the string returned by POST /memory/query for that message) and window (how many of the latest messages you would send, default 6).

It answers with full_history_tokens, recent_tokens, context_tokens, tokens_with_statejar (= recent + context) and saved_pct.

How it counts. With tiktoken cl100k_base, the tokenizer the published benchmark uses. Each message is rendered role: text and the lines are joined by newlines. Not counted: any other system prompt of your own, the reply the model writes, or the few tokens a provider's chat format adds per message. The numbers are an estimate of input tokens, not an invoice.

It can be negative. On a short chat the memory block is larger than the history it replaces, and saved_pct says so. See token savings and its baseline.

The text you send is counted and forgotten: it is not stored, no memory is read or written, no model is called, no audit row is written, and the call is not a plan operation. If the server cannot load the tokenizer it answers 503 rather than a guessed number.

Confirm a value before it is stored

Answer a question StateJar asked about a value it was unsure of; yes writes it as a new version, no discards it.

POST /api/v1/memory/confirm

Body: {"id": 12, "accept": true}.

When a message carries a value the rules cannot place with confidence, money with no stated purpose ("i have 5000 rs"), a ceiling on a bare number ("under 50000"), or a concept that only exists because a misspelling was corrected ("my slary was 80000rs"), StateJar does not guess and does not silently drop it. Ingest returns a question under confirmations, each with an id, the path it would fill, the value and the reason it is pending:

{"id": 12, "path": "constraints.budget.max",
 "value": {"value": 5000, "currency": "INR"},
 "reason": "funds_on_hand", "question": "Is ₹5,000 your budget?"}

A pending value is not state: it is never part of a handle, never sent to a model, never counted on the dashboard. Only the account that owns it can answer, and only once.

  • accept: true writes the value as a new append-only version through the same canonical gate as any message, and returns the new handle (or stored: false when the state already held it).
  • accept: false marks it rejected and stores nothing.
  • A second answer gets HTTP 409; an id that is not yours, 404.

The Playground shows each question as a chip under your message with Yes and No. The Python client exposes the same call as client.confirm(id).

Pending rows keep the proposed value and the reason only, never the message it came from, and are deleted with the account.

Confirm with a fresh code (step-up)

POST /api/v1/auth/stepup, a fresh code from your authenticator app gives a 5-minute token for one sensitive area, such as the owner's database download.

POST /api/v1/auth/stepup {code, scope}, for a signed-in account with two-factor sign-in on. code is a fresh code from your authenticator app: a code already used (to sign in, say) is refused, and so is a recovery code, which proves a printed sheet, not you at the keyboard. Wrong codes count toward the sign-in lockout; 10 a minute per address.

The answer {stepup_token, scope, expires_at} is good for 5 minutes, for that scope only (owner-data: the database download and the SQL console). It is not a session: no other route accepts it. "Log out everywhere" ends it.

Confirm your account's address

POST /api/v1/auth/account/confirm-email takes a code e-mailed for confirm_email and marks the address confirmed, removing nothing.

POST /api/v1/auth/account/confirm-email with {"code": "12345678"} (a console session)

Ask for the code first with POST /auth/account/code {"purpose": "confirm_email"}. A right code marks the account's address confirmed (email_verified_at) and changes nothing else: you are signed in to the account and you read a mail sent to its address, so you are its owner.

Why it matters. A sign-up with a password does not prove the address. The first time an address is proven by a sign-in, an e-mail code, a sign-in link, or a Google or GitHub account that has verified the address, the account is reset, because whoever made it may not be the person who owns the address: its password, sessions, developer keys, connected apps, two-factor sign-in, linked Google or GitHub sign-in and saved provider keys are removed, and the memory is kept. Confirming the address from inside first means that never happens to you.

Answers. 200 {"email_verified": true, "email_verified_at": "…"}. 403 wrong or expired code (a wrong code also counts toward the sign-in lockout). 409 the address is confirmed already.

Create an account

Create a StateJar account with an email and a password.

POST /api/v1/auth/signup

Returns the created user. Passwords are hashed with bcrypt before storage.

An account is the namespace: everything you store lives under it, and no credential can address another account's memory. See scope.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Dashboard insights

Aggregates behind the dashboard charts: fields per namespace, declines by reason over time, and handle lineage.

GET /api/v1/memory/insights

Everything the dashboard charts are drawn from, in one response, scoped to the calling account. Three blocks:

  • namespaces, how many fields each section currently holds (active) and how many values it has retired (superseded). One field holds one value; a replaced value leaves active state entirely and survives in history, which is what the superseded count reads.
  • declines, refusals grouped by reason, bucketed by day, plus totals and the reasons ranked most common first. Both kinds of refusal count: a value the field could not hold (quarantined in _unmapped) and a field nobody supplied a value for (unresolved).
  • lineage, the ordered chain of handles per session, with the field count at each step.

A decline is counted on the version that introduced it. Quarantine accumulates down a session, so reading the block as it stands on every version would report the same refusal again on each later turn and turn a flat line into a rising one.

This exists so the dashboard makes one request instead of walking /memory/versions and then fetching every handle, N+1 requests for anyone with real history. It is read-only and touches no state.

Scoping. The account comes from the credential, exactly as everywhere else here; there is no parameter that widens it. Works with either a console session or an API key.

Integrity. Charts are drawn from states whose bytes still hash to their handle. A failed version appears in the lineage marked corrupt, contributes nothing, and is listed in the integrity block.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Delete your account

DELETE /api/v1/account removes the account and every row keyed to it, in one transaction. Permanent.

DELETE /api/v1/account

Removes the signed-in account and everything it owns: every memory version in every session, every audit entry, saved provider keys, developer API keys and the profile. One transaction, either all of it goes or none of it does. There is no soft delete and no recovery, for you or for the operator.

{ "confirm_email": "you@example.com", "password": "…" }

An account without a password (made by an e-mail code, Google or GitHub) sends "email_code", a code we e-mail it for delete_account (POST /auth/account/code), in place of "password", or, with two-factor sign-in on, "code" from its app.

Answers.

  • 200 {"deleted": true, "removed": {"memory_states": 7, "audit_logs": 2, …, "users": 1}} , row counts per table, never content.
  • 401 no console session. A developer key (sj_live_…) is not accepted: a leaked memory-API key must not be able to destroy the account.
  • 403 the password does not match (or, without a password, the code is missing, wrong, used or expired). Not 401, so the console shows the error instead of logging you out.
  • 422 confirm_email is not the signed-in account's email (case and surrounding spaces are ignored).
  • 429 rate-limited like login, because it checks a password.

After deletion the console token, every developer key and the password stop working at once. The same email can sign up again and starts empty. A token issued to the deleted account is refused even if a new account is given the same numeric id.

Versions and deletion. A memory version is never edited, and retracting a field keeps it in history, that holds while the account exists. Deleting the account deletes every version. Chat history was never on the server; the console clears this browser's copy when the deletion succeeds.

In the console: Profile → Delete account. See the deletion page.

Deny a terminal sign-in

Refuse a pairing you do not recognise; nothing is issued and the terminal is told.

POST /api/v1/device/deny { "user_code": "ABCD-EFGH" }

Signed-in only. Marks the pairing denied: no key is created, and the terminal's next poll answers 403 "denied in the browser". Use it for a code you did not start, someone who can show you a code cannot sign in with it unless you approve it.

Download a copy of your account

POST /api/v1/account/export returns everything the account holds as one JSON file, table by table, without any credential.

POST /api/v1/account/export

Returns one JSON file with everything the signed-in account holds, read from every table keyed to it: every memory version in every session (the state as an object, with its handle), every audit entry, the profile, the plan and usage counters, two-factor and terminal sign-in records, the values held for your confirmation, extraction v3's held facts, cached readings and background jobs, and the names and dates of your keys. A table with nothing in it is there as an empty list, so the file always shows the whole shape.

{ "password": "…" }

An account without a password (made by an e-mail code, Google or GitHub) sends {"email_code": "12345678"}, a code we e-mail it for export (POST /auth/account/code), or, with two-factor sign-in on, {"code": "123456"} from its app.

Answers.

  • 200 the file, sent as a download (Content-Disposition: attachment, statejar-export-YYYY-MM-DD.json) and never cached (Cache-Control: no-store):

    {"format": "statejar-account-export/1", "exported_at": "2026-10-20T17:30:00+00:00",
     "account": {"id": 7, "email": "you@example.com", "created_at": "…", "disabled_at": null},
     "tables": {"memory_states": [{"handle": "shm_…", "state_json": {…}, "session_tag": "trip", …}], …},
     "omitted": {"api_keys": ["key_hash"], …}, "omitted_why": {"api_keys.key_hash": "…"},
     "not_on_the_server": "Chat history: …"}
    
  • 401 no console session. A developer key (sj_live_…) is not accepted: a leaked memory-API key must not be enough to take a copy of the whole account.

  • 403 the password does not match (or, without a password, the code is missing, wrong, used or expired). Not 401, so the console shows the error instead of logging you out.

  • 429 rate-limited like login, because it checks a password.

What is left out, and why. The columns that hold a credential or what one is checked against: your password's hash, each developer key's hash, your provider keys (stored encrypted), your two-factor secret, your recovery codes' hashes and a terminal sign-in's codes. The file lists them under omitted. A copy left in a downloads folder must not be enough to sign in or to decrypt a key; a key's label, last four characters and dates are kept, so you can still see which keys exist.

What is not on the server at all. Chat history: the server never stores transcripts, and each browser keeps its own.

Signed in, the console does this from Profile → Download my data (how). To delete the account instead: DELETE /account.

Extraction health check

Whether extraction can read a message with its model right now; 503 with the reason when it cannot.

GET /api/v1/health/extraction

No authentication, no side effects, no user data. StateJar runs no model of its own (owner's decision, 2026-10-30): on statejar.com and staging this answers 200 with "reason": "no_local_model" (extraction v3) or "no_model_needed" (the rules extractor), and the cloud path that reads messages has its own check, the cloud extraction health check. Where a local model has been built by hand for development (BUILTIN_MODEL), this says whether that model can take a message now:

  • 200 with "reason": "ok": the model is loaded and answers at once.
  • 503 with one of these reasons: no_model_configured, model_server_unreachable, model_missing, model_not_loaded (a cold load takes longer than a message waits for the model), other_model_loaded, model_loading, or model_server_http_<code>.

Each backend process looks every minute and loads a model that is present but not loaded, so a restarted model server is warm again within about two minutes; warming and last_warm say when that is happening. While the model cannot read, messages are read without it at first and read again with it from the queue when it is back. Point your monitor at this when the model matters to you; StateJar's own check of production runs every six hours and opens an issue on a second failure. Thirty requests a minute per address. The cloud providers that read before this model have their own check, the cloud extraction health check.

Finish Connect with OpenRouter

Hand over the code and state OpenRouter sent back; the server trades the code for a key in the user's own OpenRouter account and saves it.

POST /api/v1/keys/provider/openrouter/oauth/finish with {"code": "…", "state": "…"}

The second half of Connect with OpenRouter, posted by the /connect/openrouter page with the code and state OpenRouter put in its address.

  1. The server finds the connection by its state. It refuses it unless it is this account's, started in this browser (its cookie) and under ten minutes old.
  2. The connection is then used up, whatever happens next, so a state and its verifier work once.
  3. Only then does the server send {code, code_verifier, code_challenge_method: "S256"} to OpenRouter's key endpoint: /auth/keys under OPENROUTER_BASE_URL. It never sends them to an address from the request, and follows no redirect.
  4. The key OpenRouter returns is saved the way POST /keys/provider saves a pasted one: encrypted, one per account, replacing an earlier key. The listing shows it with "source": "oauth".

The answer is {"provider": "openrouter", "key_last4": "abcd", "saved": true, "source": "oauth"}. The key itself is never returned or logged.

From then on the key is used for the OpenRouter models you pick in the Playground. It is also used first to read your messages while OpenRouter is one of the enabled extraction providers (the call record says byok: true). What those calls cost is billed to your OpenRouter account.

StatusWhen
201The key is saved.
400Start again: this state is not a connection the account has in progress (it is finished, or another account's), it was started in another browser, it is over ten minutes old, or OpenRouter refused the code (its 400 or 403). Nothing is saved, and a key saved earlier stays.
401No console session. A developer key is refused too.
422The code is empty, over 512 characters or not printable ASCII, or the state is not 43 base64url characters. Nothing is sent to OpenRouter.
429More than 10 a minute or 30 an hour for the account.
502OpenRouter could not be reached, answered another status, or sent no key. Nothing is saved.

To disconnect, use DELETE /keys/provider/openrouter, which deletes the key.

Generate a StateJar API key

Create a key for calling StateJar from your own code. Shown once.

POST /api/v1/apikeys/generate

The full key is returned exactly once, in this response. It is stored hashed, so it cannot be retrieved later. If you lose it, generate another and delete the old one.

Use these rather than a login token for anything running unattended.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Health check

Liveness probe.

GET /api/v1/health

Returns a small JSON body when the service is up. No authentication, no side effects. Point your uptime monitor at it.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Ingest a message

Run the memory pipeline on a message: extract, canonicalize, hash, store.

POST /api/v1/memory/ingest

The main write endpoint, and the one to reach for first. Given a session tag and some text it runs extraction through the tiers, canonicalizes what they produced, derives a handle, and stores the new state as a child of the previous one.

The response carries the new state, its handle, its parent, any conflicts, and metadata that is deliberately outside state: which tiers ran, what each contributed, and any notice. Keeping that outside the state is not tidiness, anything inside state reaches the hash, and a handle that moved because a tier happened to be installed would not be a content address at all.

No provider key is required. Ingest never calls a model unless tier 3 is enabled and triggers.

Two refusals, both of which store nothing. HTTP 409 if the session's latest state no longer hashes to its handle, a new version is never built on edited bytes. HTTP 422 with "error": "inconsistent_extraction" if the message was read as two different values for one field; say it again differently.

A message that changes nothing writes nothing. "hi", a restatement, or a message whose only values were declined stores no version: stored is false, and handle is the session's current head, null while the session has no state yet. Anything declined is still returned under state._unmapped with its reason.

With extraction v3 (EXTRACTOR_VERSION = v3) the response also carries extraction: what this message stored, what it left pending (held until said again, never stored, never sent to a model), what it declined and why (about_someone_else, hypothetical, question, past, evidence_not_in_message, verifier_no …), and what it removed. A fact about the user that no catalogue attribute holds (a favourite team, a shift, an EMI, a birthday) is stored as a dynamic field named in English (dynamic.favourite_team, its stored entry marked "open": true) after the same checks as every attribute; sensitive categories (religion, caste, political views …) are declined. A budget keeps what it is for ("for": "bananas"), and the model sees it that way ("INR 150 (for bananas only)"); a salary keeps its period ("period": "month"). No confirmation is asked by default; send "review": true to turn each pending fact into a question for /memory/confirm. A message over 3,000 characters is answered with 202 and a job; over 50,000 characters is refused (422).

Reply first, update memory right after. Send "async": true and the answer is 202 with a job at once, at any length: recall and reply to the user, then ingest; the session's next query or chat waits for the job at most INGEST_WAIT_S (5 s) and says pending_ingest: true when it answered without it. A session's ingests run one at a time in the order they arrived, so a correction never lands before the message it corrects; that is also why a plain ingest sent while an earlier one of the same session is still being read becomes a job too (202). No update is refused however many of an account's messages are still being read (until 2026-10-14 an account could have 5 unfinished, and the next was a 429). Without "async" nothing changes for existing clients.

A busy model never holds an update back. Replies go first on the server's model, so while a reply runs or waits, or another update holds the model, a message is read at once without the model (the rules, GLiNER and every check; extraction_tiers.llm is "deferred", model_rerun is "queued", and the notice says so). The model reads it again as soon as no update waits and the model is free; memory then takes the model's reading as a new version: what the model adds goes in, and a fact only the first reading stored goes out when the model's reading does not keep it. A field a later message has changed since is left as that message left it. A job's result then carries model_rerun with what happened (done and the fields it changed, failed, or given_up). A message the model was asked about and did not answer is read again the same way. Review mode ("review": true) always waits for the model, since its questions come from the model's reading.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Investor interest form

POST /api/v1/investors/interest records an investor's interest. It only records interest; it is not an offer or solicitation to buy securities.

The form on the Investors page sends this request. No account is needed. Fields: name, e-mail, phone (optional), firm or fund (or individual), country, LinkedIn or website (http or https only), investor type, what you are interested in (investing, advising, partnership), an indicative cheque band, stage, timeline, a message, and consent to be contacted (required).

  • You get a confirmation e-mail, and the founder gets a notification.
  • The form is limited to a few sends per address per hour, and is refused when sent faster than a person could fill it in.
  • This form only records interest. It is not an offer or solicitation to buy securities.

Join the waitlist for a paid plan

While paid plans are coming soon, leave your e-mail and a plan to hear when it opens; no account needed, no plan starts and nothing is charged.

POST /api/v1/billing/waitlist with {"plan_slug": "plus", "email": "you@example.com", "currency": "INR"}. No account is needed; a signed-in caller's own address is used whatever the body says. The plan must be one that is sold (not Free, not a contract plan): 404 plan_not_found; an address that is not an address: 422 bad_email. Asking again for the same address and plan answers "already": true. Limited to 5 a minute and 30 an hour per address of the caller (429).

It stores the address, the plan, the currency and the date, and nothing else. It does not start a plan and takes no payment. Deleting your account removes the rows made with your account and those made with its address while signed out (deleting).

List state versions

Every handle in a session's chain, oldest to newest.

GET /api/v1/memory/versions

The lineage behind the Handles tab. Each entry is one version; the newest is the session's current state.

State is append-only, a version is never rewritten, so this list only ever grows. See active state vs history.

A session with no version of its own yet answers versions: [] and carried_over: the handle of the account's newest state, which a question in that session is answered from and its first message builds on (null when the account has no memory; a demo session only ever carries over a demo state). The Playground's Memory State shows that state, labelled as carried over.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Log in

Exchange email and password for a bearer token.

POST /api/v1/auth/login

Returns an access token signed with JWT_SECRET. Send it as Authorization: Bearer <token> on every other call.

For server-to-server use, prefer a StateJar API key from /apikeys/generate, it does not expire on a token rotation and can be revoked individually.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Log out

Ends this browser's session on the server and clears its cookie.

POST /api/v1/auth/logout, signed in.

Revokes the session the cookie __Host-sj_session names (it cannot be used again, from anywhere) and clears the cookie; sessions_revoked says whether there was one. A caller that signed in with a JSON token only gets 200 with nothing revoked: a token cannot be revoked and lasts its 24 hours, which is why the console also forgets it. The answer carries Clear-Site-Data so the browser drops this site's cookies and storage.

Log out everywhere

Revokes every session of the account at once, this one included.

POST /api/v1/auth/logout-everywhere, signed in; body {"code": "123456"} when two-factor sign-in is on (a fresh app code, not a recovery code; 403 without one).

Revokes every live session of the account, this browser's, every other device's, and clears this browser's cookie. sessions_revoked counts them. Use it when a device is lost or a session on the Sessions list is not yours. JSON tokens issued before it keep working until they expire (24 hours at most); developer API keys are not sessions and are revoked from API Keys.

Look up a pairing code

What the approval page shows before you decide: the device's name, the code's state and the key's lifetime.

GET /api/v1/device/lookup?code=ABCD-EFGH

Signed-in only (the console's JWT). Answers the device's name, the pairing's status (pending, approved, denied, claimed or expired), when the code expires and how long the key would live (365 days), so the approval page can show you what you are about to allow. 404 for a code that was never issued, 410 for one that has expired.

Model catalog

Which models are available to you, grouped by provider.

GET /api/v1/models

Only providers you have configured appear, with free and paid models listed separately where a provider offers both.

Local Ollama models are not in here (and the Ollama cards exist only where SHOW_OLLAMA is on; StateJar runs no model of its own). A hosted backend cannot see http://localhost:11434; the browser enumerates those itself. See the model selector.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Monthly subscriptions

Start a recurring monthly plan at a gateway that sells subscriptions; each charged month activates by the same rule as an order.

POST /api/v1/billing/subscriptions with {plan_id, provider}, a console session only. It can also take currency. The answer's checkout is the gateway's own page, where the payer authorises the mandate.

Each charged month reaches our server as a signed notice. That month starts the plan only after our server asks the gateway for that charge's status and gets "paid" for the plan's price. An account has one subscription at a time; a second gets 409 subscription_open.

Only one integrated gateway sells subscriptions today; /billing/checkout says which (subscriptions). The others sell one month at a time.

My subscription and orders

Your own plan, the paid period in force, this month's use, what renewing costs and your order history; it never returns another account's data.

GET /api/v1/billing/subscription, a console session; a developer key is refused (401). It answers for the signed-in account only.

  • plan: the plan your limits follow now (slug, name, and paid, true while a paid period is in force).
  • start, valid_until, days_left: the paid period in force, or null on the Free plan. A paid plan does not renew by itself; at valid_until the account is on the Free plan again and nothing is deleted.
  • usage: this month's adds and recalls used against the plan's limits.
  • renewal: when the paid period ends and what the last order cost, with auto: false.
  • orders: your orders, newest first (up to 100): the order number, its reference, date, plan, amount, currency, provider, status and whether it has a receipt. The status is one of pending, paid, failed, refunded or expired; raw_status is the server's own word.
  • billing, waitlist, is_owner: whether paid plans are open, whether the page shows Join waitlist instead of a checkout (paid plans are "coming soon" and the account is not the owner's), and whether this is the owner account.

The My orders page shows this. A paid order's receipt is here.

New recovery codes

Replaces all recovery codes; needs your password and a code from the app.

POST /api/v1/auth/2fa/recovery-codes with {"password": "…", "code": "123456"}

Console session only. Every old recovery code stops working and ten new ones are returned once. The code must come from the authenticator app, a recovery code cannot be used to mint more of them.

An account without a password sends the app code alone, {"code": "123456"}; a code we e-mail it (email_code, purpose recovery_codes, POST /auth/account/code) may go beside it, never instead of it.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

OAuth token endpoint (MCP)

An app exchanges its code (with the PKCE verifier) for an access token and a refresh token, and later rotates the refresh token. Form-encoded, RFC 6749.

POST /api/v1/oauth/token, application/x-www-form-urlencoded.

grant_type=authorization_code: code, client_id (and client_secret for a confidential client), code_verifier (checked against the code's S256 challenge), redirect_uri (if sent, the one the request had), resource (if sent, the MCP server's URL). Answers an opaque access_token (sja_…, one hour by default: OAUTH_ACCESS_TOKEN_MINUTES), token_type: Bearer, expires_in, a refresh_token (sjr_…, OAUTH_REFRESH_TOKEN_DAYS) and the scope. A code presented twice is refused and the tokens its first exchange issued are revoked.

grant_type=refresh_token: refresh_token, client_id (client_secret), optionally a narrower scope. Each refresh token works once: the answer carries a new pair and the old pair is revoked. A refresh token presented a second time means someone holds a copy, so every token of its family is revoked and the app signs in again.

Errors follow RFC 6749 §5.2: invalid_client (401), invalid_grant, invalid_scope, invalid_target, unsupported_grant_type (400), never cached. The server stores only token hashes; the MCP server verifies a token by its hash, its expiry, its revocation and its resource.

Order receipt

A paid order's receipt (number, date, account, plan and period, price, amount charged, payment reference, refunds), also e-mailed once when the payment arrives.

GET /api/v1/billing/orders/{order_id}/receipt, a console session, your own orders only; 404 no_receipt until the order is paid.

The receipt holds what StateJar has, and nothing about how you paid: the receipt number (the order's reference), the date paid, your account's address, the plan and the month it covers, the plan's price, what the provider charged and in which currency, the provider's payment reference, any refund and its date, and test_mode when the order was paid in test mode (no money moved). It is a payment receipt, not a tax invoice until StateJar is registered for GST.

GST-ready layout. The receipt carries a gst block: the supplier's name and GSTIN: not yet registered while GST_SUPPLIER_GSTIN is empty, the SAC (GST_SAC_CODE, "to be confirmed" when unset, never guessed), the taxable value, the buyer's GSTIN if one was given at checkout, and tax lines. With no supplier GSTIN there are no tax lines and the status reads "GST not charged: supplier not registered". With one, an order priced in rupees gets CGST and SGST (half the rate each, GST_RATE_PERCENT) when the buyer is in the supplier's state, or IGST when the buyer's GSTIN is in another state; with no buyer GSTIN the supplier's state is used. An order priced in another currency gets no Indian GST line. The amount a gateway charges is not raised by this tax yet.

By e-mail. The same receipt is mailed to the account's address once, by the call that activated the plan, after the payment is saved; a repeat of the same payment sends nothing. After a refund the provider confirms, a mail with the updated receipt follows. A mail that cannot go out changes nothing about the payment, and the receipt stays here and on the Billing page, which prints it or saves it as a PDF. Nothing takes payments until PAYMENTS_ENABLED_PROVIDERS names a provider.

Pattern 1: Sidecar

You keep your model call; StateJar supplies the memory subset beside it.

Choose this when you already have a working model integration and you want memory added without rewriting the call.

How it works. Before you call your model, ask StateJar what matters for this turn. Build your prompt with the subset. After the turn, hand the user's message to StateJar to remember.

POST /api/v1/memory/query   -> the minimal subset for this question
  (your own model call, your own prompt, your own SDK)
POST /api/v1/memory/ingest  -> remember what the user said

Why it is the default recommendation. Nothing about your model call changes. Your prompts, your streaming, your tool use, your vendor, all untouched. StateJar is a lookup you make, not a layer you route through.

What you keep responsibility for. Building the prompt, and calling /memory/audit/manual if you want the disclosure in the trail, StateJar cannot log a call it did not make.

Related: query, ingest, manual audit.

Pattern 2: Proxy

StateJar makes the model call for you and returns the reply.

Choose this when you want the shortest path from zero to working memory, and you are content for StateJar to hold the provider key and make the call.

How it works. One endpoint does everything: retrieve, prompt, call, audit, reply.

POST /api/v1/chat  -> subset retrieved, model called, audit written, reply returned

What it does not do, stated plainly so you do not discover it later:

  • No streaming. The reply is returned complete.
  • No tool or function calling. The model is asked a question and answers.
  • No control over the prompt. The prompt is built from the retrieved subset; you cannot inject a system prompt of your own through this path.
  • No vendor-specific parameters. Anything outside model, message and key is not passed through.

If any of those matter, use the sidecar.

Related: chat.

Pattern 3: MCP

Expose StateJar as tools to an MCP-speaking client.

Choose this when your client already speaks the Model Context Protocol and you want memory as tools the model can call rather than as plumbing you write.

Status: not shipped. There is no StateJar MCP package yet. It is described here because the shape is a decision people ask about while choosing a pattern, and finding out later is worse than reading it now.

The shape it would take. memory_query and memory_ingest as tools, with the same scoping model, the namespace resolved from the credential, never passed by the client.

What to do today. Use the sidecar from inside your MCP server. It is the same two calls, and it works now.

Payment notices (allapi webhook)

Where allapi tells our server an order was paid; a notice is signature-checked and then confirmed by a server-side status query before a plan changes. Off until the adapter is switched on.

POST /api/v1/billing/allapi/webhook, called by allapi, never by a browser. Answers 404 while the owner has saved no credentials for it. allapi.in publishes no API documentation, so the adapter is written against an assumed API (docs/payments-allapi-research.md); it is off by default and takes no real money until the owner switches test mode off.

A notice is a prompt, not a proof. It changes a plan only if: its X-Timestamp is within 10 minutes; its X-Signature (HMAC-SHA256 with the saved webhook secret, or the secret key when none is saved) matches the exact body; its event_id was not seen before; it names an order of ours with the provider id we stored; its amount and currency are what the order was given; and then our server asks allapi itself (a status query) and that answer says PAID for the same merchant reference, amount, currency and payment reference (utr). A status query that cannot be made is answered 503, so allapi sends the notice again. Refused notices are recorded by reason (bad_signature, replayed_nonce, amount_mismatch, status_check_not_paid, …) with a SHA-256 of the body, never the body, and appear in the reconciliation report.

The environment-wide list that keeps providers off is PAYMENTS_ENABLED_PROVIDERS.

Payment notices (Binance Pay webhook)

Where Binance Pay tells our server that an order was paid, closed or refunded; every notice is checked against Binance's signature and our own order before anything changes. Off until a provider is switched on.

POST /api/v1/billing/binance/webhook, called by Binance Pay, never by a browser or by you. Nothing takes payments until PAYMENTS_ENABLED_PROVIDERS names the provider; in test mode the caller is a stand-in on the server's own machine.

A notice changes something only if every check passes, in this order: its headers are there; its timestamp is within 10 minutes; its RSA (SHA-256) signature verifies with Binance's certificate for the serial it names (an unknown serial is fetched from Binance at most once a minute); its nonce was not seen in the last 24 hours; it names an order of ours with the provider id we stored; the amount and currency are what the provider said it would charge; and its payment reference paid no other order. Then, in one transaction, the order becomes paid and the plan starts for one month, and only after that is saved does the server answer {"returnCode": "SUCCESS", "returnMessage": null}, so a crash makes Binance send the notice again. The same payment told again changes nothing and sends no second receipt.

A refused notice is answered 400 or 401 with its reason (bad_signature, replayed_nonce, amount_mismatch, unknown_order, …) and recorded by that reason with a SHA-256 of the body, never the body. A body over 64 KB is refused before it is read (413), and one address may call 120 times a minute (429). Refund notices end the plan when the provider confirms the money went back. Notices that never arrive are caught by the reconcile pass.

Payment notices (Razorpay, Stripe, PayU)

Where Razorpay, Stripe and PayU tell our server about a payment, refund or subscription; a notice is signature-checked, counted once by its event id, and confirmed by a server-side status check before a plan changes.

POST /api/v1/billing/{provider}/webhook, where {provider} is razorpay, stripe or payu. Called by the gateway, never by a browser. It answers 404 for any other name, and while the owner has saved no credentials for that gateway.

A notice is a prompt, not a proof. A plan changes only when every check below passes:

  1. The signature holds over the exact body. For Razorpay that is X-Razorpay-Signature, an HMAC-SHA256 with the webhook secret. For Stripe it is Stripe-Signature, with a 5-minute tolerance. For PayU it is the reverse hash with the key and salt. A bad signature gets 401 and nothing in the body is read.
  2. The event id has not been seen before. Razorpay sends it as x-razorpay-event-id; Stripe as the event's own id; PayU's is the payment id and its status. A repeat gets 200 with "duplicate": true and changes nothing. A notice whose payment is still pending does not keep its id, so the gateway's retry is checked again.
  3. Our server then asks the gateway itself for the order's status, server to server. That answer must say paid for our merchant reference, for the amount and currency the order was priced in, and with a payment id that matches the notice. The payment id must not have paid any other order. If the status check cannot be made, the answer is 503 and the gateway sends the notice again.

A failed attempt marks the order on the billing page. It stays open, so the payer can try again. Refund notices end the paid period only once the gateway confirms the refund. Refused notices are recorded by reason (bad_signature, amount_mismatch, status_check_not_paid, …) with a SHA-256 of the body, never the body. They appear in the reconciliation report.

The environment-wide list that keeps providers off is PAYMENTS_ENABLED_PROVIDERS; the owner's switch per gateway is on the Payment providers page.

People and relations

A session's everyday facts as rows: the people in your life and what passes between you and them.

GET /api/v1/memory/graph?session_tag=… (include_closed=true for ties a later version dropped)

The everyday facts extraction stores, the people in your life with their relation to you, money you lent or borrowed (amount, who, what for, due when), reminders and events with their time, live in your memory state as lists, in your own words, and are versioned, hashed and audited like every other fact. This endpoint reads the same facts as rows: one entities row per person, one relations row per tie, each naming the version (handle) that first held it and, once a later version dropped it (a repaid loan, a cancelled plan), the version that closed it.

The rows are an index built from each stored version, never a second copy of your memory: they can never say more than the state does, and the server can rebuild them from your versions at any time. Nothing about them changes a handle. Deleting the account deletes them; the account export includes them.

Request and response shapes are in the API Docs, this entry explains the reasoning, not the schema.

Plans (public price list)

The active plans as the admin last saved them; /pricing reads this on every load, so an edit is live without a deploy.

GET /api/v1/plans, no account needed.

The active plans in order: id (what an order names a plan by), name, monthly price in INR and USD (null = contact us), adds and recalls per month (null = custom), history retention, seats, developer keys, requests per minute, feature lines, which one is the default. The pricing page renders exactly this, and the same rows drive the server-side limits, so what you see is what is enforced. billing says "coming soon" while no payment provider takes payments (every environment today), "test mode" while one does on a stand-in, and "open" once one takes real payments (billing config); nothing here takes a payment, and an order reads its price from these same rows.

POST /sandbox/ask

What a model would be told for a sandbox visitor's question: the same query, with the same audit row, as an account's.

POST /api/v1/sandbox/ask with {"session_id": "…", "question": "…"}, at most 500 characters.

It is the query of the session's own account. The answer has:

  • memory_context: the memory a model would be given;
  • handle_used: the handle it came from;
  • the fields sent, and the mode: the whole state while it is small, or the fields the question needs;
  • the audit_id of the disclosure.

Nothing is sent to a model. Each question counts as one of the day's sandbox actions.

POST /sandbox/jobs/{job_id}

How a sandbox message's reading went: queued, running, done with what was stored, held and declined, or failed.

POST /api/v1/sandbox/jobs/{job_id} with {"session_id": "…"}. It is a POST so that the session id travels in the body: the web server's access log keeps a request's path, never its body.

It is the ingest job of the session's own account. When it is done, result holds the ingest's answer: what each fact became (stored, held until said again, or declined with its reason), the state and its handle. Another session's job answers 404, as does a session the daily wipe has deleted.

POST /sandbox/remember

A sandbox visitor's message, secrets removed, read in the background by the same ingest as an account's message.

POST /api/v1/sandbox/remember with {"session_id": "…", "text": "…"}.

  1. Secrets are removed first: API keys and tokens, private keys, passwords written after their name, and card numbers. The answer says how many were removed, and of which kinds (secrets_removed, kinds), never their values.
  2. The message then goes to the same ingest an account's message goes to, as a background job. The answer is 202 with a job_id and a status_url. Read the result there with POST /sandbox/jobs/{job_id}.

A message over SANDBOX_MAX_CHARS characters is refused with 422 (message_too_long). Each message counts as one of the day's sandbox actions.

POST /sandbox/session

Starts a sandbox session for a visitor without an account; the answer's session_id names it in every other sandbox call.

POST /api/v1/sandbox/session, with no body and no key: the sandbox's start.

It makes an account for this visitor alone. The account is disabled, so it never signs in, and nobody can sign up with its address. It answers:

  • session_id: a random id. Keep it, and send it with every other sandbox call. Only its holder can reach the session.
  • deleted_at: the next 00:00 UTC, when every sandbox account and everything in it is deleted.
  • max_chars: the longest message the sandbox takes (SANDBOX_MAX_CHARS).

A new session counts as one of the day's sandbox actions (SANDBOX_DAILY_CAP), and every sandbox call counts toward its address's hourly limit (SANDBOX_PER_IP_PER_HOUR). Both answer 429 when spent. With SANDBOX_ENABLED false, every sandbox call answers 404.

Profile

Read and update the signed-in account's profile. Four optional fields, scoped to the caller.

GET · PATCH /api/v1/profile

Four optional fields describing who the account belongs to: display_name, organization, role, use_case.

Scoped to the caller, with nothing to enumerate. No id is accepted from the client on either verb, the row is selected by the user id on the verified credential, so there is no path to anyone else's profile and no id to probe. Sending user_id in the body does nothing.

An unset profile reads as empty, not 404. Never having filled one in is a normal state. The response carries exists: false so a client can tell "never filled in" from "filled in and then cleared".

PATCH is partial. An absent key is left alone; an explicit null or a blank string clears that field. That distinction is why it is a PATCH and not a PUT.

Rejected, never coerced. A value that fails validation comes back 422 with a per-field reason, and nothing is written:

{
  "detail": {
    "error": "profile rejected",
    "fields": { "display_name": "must be 80 characters or fewer (got 210)" }
  }
}

One bad field rejects the whole request, so a partial write can never leave you wondering which half of an edit survived. This is the same fail-closed principle the extractor applies to a value, see decline. The only liberty taken is trimming surrounding whitespace, because a trailing space is a typing artefact rather than something you meant.

Deliberately small. No avatar bytes, no uploads, nothing sensitive, and every field is capped and single-line. A profile that cannot hold a file cannot leak one, and the length caps also keep the profile from becoming a back door for the one thing StateJar refuses to store, see where chat history is stored.

Provider keys

Save, list and remove the model-provider keys StateJar calls on your behalf.

GET · POST /api/v1/keys/provider · DELETE /api/v1/keys/provider/{provider}

Keys are encrypted with AES_KEY before they touch the database. The listing returns only the last four characters and a has_key flag, never the key itself, and never in an error message.

Where an install shows it (SHOW_OLLAMA; not on statejar.com), the local Ollama entry stores a base URL and no key at all, because a local daemon needs none. Saving one provider never disturbs another.

Each listed key says how it arrived: "source": "paste" (saved through this route) or "source": "oauth" (made in your own OpenRouter account by Connect with OpenRouter). Both are stored the same way and used the same way. DELETE removes either; for a connected OpenRouter key that is Disconnect on the card.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Query memory

Retrieve the minimal subset relevant to a question, plus a prompt-ready context string. No model call.

POST /api/v1/memory/query

Read-only. Returns the fields a question actually needs, plus metadata: how many fields were dropped, the retrieval mode, and the savings figure.

Use it to see what would be disclosed without spending a model call, or to build your own prompt around the subset.

Bringing your own LLM. The response also carries memory_context: the same subset already formatted for a system message, built by the same function POST /chat uses. Drop it in as your system message, send your own request to your own provider with your own key, and you get the context the POST /chat path uses, identical by construction, rather than by two places happening to agree. subset is still there if you would rather format it yourself. A runnable example lives at examples/sidecar_openai.py.

Call order. Reading a message with the server's own model takes seconds to minutes on a CPU, so the recommended turn is reply first, update memory right after: query with the user's message, reply (your model reads the message itself, so a fact stated in it is used at once), then ingest with "async": true. The next query of the session waits for that ingest at most INGEST_WAIT_S (5 s), so what turn N said is in memory for turn N+1; when the wait runs out the answer comes from the last finished version and metadata.pending_ingest is true, never silently. Ingesting before the query still works and gives a context that already holds this turn's facts, at the cost of the reading time in front of every reply.

Every call writes an audit row and returns its audit_id. Without audit the row's kind is query: the fields went back to your client. Pass audit: true when your client is about to hand them to a model, the row is then query_audited and counts as a turn (it ends the once-only change announcement). See the manual audit endpoint.

The state is re-derived before it is used. If the stored bytes no longer hash to the handle, the request is refused with HTTP 409 and nothing is returned or audited. A change made by the latest message is announced in memory_context once, to the first audited disclosure of that handle, and only its new value is sent; the old value stays in history.

No memory yet is an answer. A user with no state gets handle_used: null and an empty subset (retrieval mode no_state), not an error.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Read a pending OAuth request

What the consent page shows for a request id: the app's name, its redirect host, the scopes in words and your account. Signed-in only.

GET /api/v1/oauth/authorize/request?req=…

The consent page's one read, as the signed-in user (a bearer session; a cookie session later through the same check): client_name, client_kind (dcr, cimd, manual), redirect_host, redirect_is_local (the app runs on your own computer: the page warns), the scopes with a sentence each, your account and expires_at. A request id in a link tells a stranger nothing: without a session it answers 401; an unknown or expired id 404.

Read a state by handle

Fetch the exact state a handle addresses.

GET /api/v1/memory/state/{handle}

The read side of content addressing. Given a handle, return that state, the same bytes that hashed to it.

Scoped to your account: a handle belonging to someone else is not readable, even though the handle itself is just a hash.

Verified on read. A state whose stored bytes no longer hash to its handle is refused with HTTP 409 rather than shown under that handle.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Record a client-side disclosure

Log a disclosure your client made without going through /chat.

POST /api/v1/audit/manual

Exists for one real case: browser-direct Ollama. The prompt goes from your browser straight to your own daemon and never touches our server, so our server cannot log it, but the disclosure still happened and still belongs in the trail.

The client reports the handle it used and the keys it sent. Marked as client-reported, because that is what it is.

Provenance only for intact state. The handle must be one of yours and its stored bytes must still hash to it; otherwise HTTP 404 or 409, and no audit row is written.

With no memory yet, send handle_used: null; the row records that nothing was disclosed.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Region hint (India or elsewhere)

A hint about the visitor's country, from a header a CDN or proxy may add; the pricing page uses it to show rupees and national gateways first. Never a gate.

GET /api/v1/region, no account needed.

{"country": "IN", "region": "IN", "source": "cf-ipcountry"}
  • country, a two-letter country code, read from the first of the headers CF-IPCountry, CloudFront-Viewer-Country, X-Vercel-IP-Country, X-Country-Code that a CDN or proxy in front of the server sets; null when none is present. Today's edge sets none, so the answer is usually null.
  • region, IN for India, INTL for any other country, null when unknown.

It is a hint only. The pricing page and the Billing page use it, then the browser's time zone and language, to decide whether to show rupees and the national gateways first or dollars, the international gateways and crypto; the visitor's own switch on the page always wins and is remembered in that browser. Nothing is blocked by it, and no address is read or stored.

Register an MCP client (OAuth)

Dynamic Client Registration (RFC 7591) for the MCP server's OAuth sign-in: an app registers its name and redirect addresses and gets a client id.

POST /api/v1/oauth/register

The first thing an MCP client without a client id does (Claude's "Register automatically", Codex, Gemini CLI, OpenCode, VS Code). JSON body per RFC 7591: redirect_uris (1–10; each https://…, or http:// on localhost/127.0.0.1 for an app on your own computer; no fragment), optional client_name, token_endpoint_auth_method (none, the default: a public client; or client_secret_post), grant_types (authorization_code, optionally refresh_token), response_types (["code"]), scope. Answers 201 with the client_id (sjc_…) and the metadata as stored; a confidential client also gets its client_secret once (the server keeps its SHA-256). No sign-in; rate-limited per address. Redirect addresses are stored exactly and matched exactly at /oauth/authorize, the loopback port aside.

Clients with a published identity need no registration: a client_id that is an https:// URL is a Client ID Metadata Document, which the server fetches (public hosts only, the same guard as a remote Ollama URL) and caches for an hour. The MCP server page says which client does which.

Replay a disclosure

Reconstruct exactly what was disclosed for one audited response.

GET /api/v1/audit/{request_id}/replay

Fetches the state by its handle and re-applies the recorded field keys, reproducing the exact subset that was sent.

It also re-derives the handle and reports verified. If the stored state had been altered, the re-derived handle would not match and the replay would say so. That check is what makes the audit trail evidence rather than a log of claims.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Reply to a ticket

POST /api/v1/tickets/{id}/replies adds your message and up to 3 attachments to one of your tickets.

POST /api/v1/tickets/{id}/replies (multipart form: body up to 5,000 characters, optional files). 409 when the ticket is closed; 404 when it is not yours. A reply to a resolved ticket reopens it. The team is told by e-mail. Sixty replies an hour. See support tickets.

Report a reply

POST /api/v1/reports sends the StateJar team a model's reply you found offensive, harmful or wrong, without leaving the app.

POST /api/v1/reports with {"reason": "…", "excerpt": "…", "note": "…", "provider": "…", "model": "…"} (a console session or a JWT; signed in).

reason is one of offensive, harmful, sexual, hateful, false (wrong about a fact) or other. excerpt is the reply you report, as you saw it (1 to 4,000 characters); note is optional (up to 500); provider and model say which model wrote it. The Playground's Report button under each reply fills these in for you (Reporting a reply).

Answers. 201 {"id": …, "received": true, "detail": "…"}. 422 for an unknown reason or an empty or over-long excerpt. 429 past 20 reports an hour from one network address.

StateJar keeps no conversation; a report is the one piece of one it keeps, because you sent it: the reply, its provider and model, your reason and note, never the message that led to it. It is yours: in your data export, and deleted with your account.

Restore a handle

Adopt a sealed state into a session, verifying it on the way in.

POST /api/v1/memory/restore

Re-derives the handle from the stored state and refuses if the two disagree. A restore that succeeds is therefore a verification: the bytes are what they claimed to be. A refusal is HTTP 409 and writes nothing into the target session; a handle that is not in your account is HTTP 404.

This is how memory crosses a session boundary. See your first handle restore.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Revoke an OAuth token

An app gives a token back (RFC 7009): a refresh token takes its whole family with it; an access token goes alone. Always 200.

POST /api/v1/oauth/revoke, form fields token, optional token_type_hint, client_id (client_secret).

RFC 7009: the client that holds a token gives it back. A refresh token revokes every token of its family (the access tokens issued beside it and the ones it rotated to); an access token is revoked alone. The answer is 200 whether the token was live, already revoked, unknown or another client's, so nothing can be learnt by probing. A revocation of a live token writes an event row. The user's own way to do the same for a whole app is DELETE /oauth/clients/{id}.

Seats (organisation members)

The people an account owner lets into their organisation, counted against the plan's seats; adding one past the count is refused with 403 plan_seat_limit.

GET /api/v1/org/members · POST /api/v1/org/members · DELETE /api/v1/org/members/{id}

Console session (JWT) only. The owner of the account is the first seat; every member added is one more. The list answers with the plan's seat count, how many are in use, the owner's address and the members:

{"seats": 5, "used": 2, "owner": "you@example.com",
 "members": [{"id": 7, "email": "colleague@example.com", "created_at": "2026-10-09T10:00:00+00:00"}]}

Adding a seat. POST with {"email": "colleague@example.com"} (201). The address is stored lower-cased and once; the owner's own address is refused (409), as is a duplicate (409). Past the plan's count the request is refused with 403 and {"error": "plan_seat_limit", "plan": "pro", "limit": 1, "used": 1, "message": …}. A plan with no seat figure (contract) has no limit. An admin's edit to a plan's seats is live at once (plans manager).

Removing a seat. DELETE /api/v1/org/members/{id} (200), only for your own members; another owner's row is 404.

What a seat is today. A counted membership by e-mail address. It gives the member no access to the owner's memory: shared memory across an organisation is a later step (docs/plans.md §4). The rows are the owner's and are deleted with the account.

Session turns

Per-turn provenance for a session. Contains no message text, by design.

GET /api/v1/sessions/{session_id}/turns

Returns, for each turn: the handle that was current, its parent, the state version, when it happened, and what was disclosed from it.

It is not a transcript endpoint and never will be. StateJar does not store raw transcripts, the storage layer refuses them at write time, so there is no message text to return. The response says so explicitly with contains_message_text: false.

The division is the point: your client holds the words, the server holds the provenance, and joining them is the caller's job. See the FAQ.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Sign in with Google or GitHub

Starts the provider's sign-in flow (state, PKCE, nonce) and sends the browser to the provider.

GET /api/v1/auth/oauth/{provider}/start (the browser navigates here: a 302 to the provider) or POST (the console's fetch: answers {"url"} to navigate to). provider is google or github.

The server builds the provider's authorization URL, state against cross-site forgery, a PKCE challenge (S256), and for Google the OpenID nonce, and keeps those three values for the callback in the cookie __Host-sj_oauth (encrypted, HttpOnly, ten minutes). Google is asked for openid email, GitHub for user:email (no profile scope: the public profile is read without one): enough to know who you are and which address the provider has verified, nothing more. A provider the admin has not switched on, or has switched on without a client id and secret, answers 404 (Login methods).

Sign in, second step

Exchanges the mfa_token from /auth/login and a code for a session token.

POST /api/v1/auth/login/2fa with {"mfa_token": "…", "code": "123456"}

When two-factor sign-in is on, /auth/login answers {"mfa_required": true, "mfa_token": "…"} instead of a session. The mfa_token lasts five minutes and is accepted by this route only. Send it with a 6-digit code from the app, or one unused recovery code. A code is accepted once: the same code a second time is refused. Wrong codes count toward the lockout (10 failures in 15 minutes locks sign-in for that address for 15 minutes) and the route is also rate-limited per IP.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Site content (public)

GET /api/v1/site/content returns the published text blocks of every public page, never a draft. No sign-in.

GET /api/v1/site/content, no sign-in: {pages: {<page>: {<key>: {text, html}}}} with the published copy of each block an owner has published in the admin console. Drafts are never included. html is rendered on the server from an allow-list (paragraphs, bold, italic, lists, links to http, https, mailto or this site). Cached for 60 seconds in each server process and sent with Cache-Control: public, max-age=60. A block absent here shows the page's built-in copy.

Site images (public)

GET /api/v1/site/images/{id} serves an uploaded share image with its detected type and nosniff.

GET /api/v1/site/images/{id}, no sign-in: the image an admin uploaded, with the type detected from its bytes, X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox and a one-day cache. 404 for an unknown id.

Start a manual payment

Start a manual payment.

POST /api/v1/billing/manual/orders

Body {"plan_id", "method"}. The amount comes from the plan and the method's settings (extra fee; for Binance a unique cents suffix in USDT), never from the request; 409 amount_out_of_range outside the method's limits. The order stays pending until it is checked.

Test mode only (2026-11-05): these methods are not offered on the production deployment and never activate a plan there. Nothing is ever credited on a UTR alone.

The providers listed in PAYMENTS_ENABLED_PROVIDERS are unaffected; these two methods are separate, test-only, and paid plans stay "coming soon".

Start a terminal sign-in

What `statejar login` calls first, a pairing code for the browser and a secret for the terminal.

POST /api/v1/device/start

The first call of statejar login. No sign-in needed and nothing is issued yet: the server records a pairing and answers with a device_code the terminal keeps secret (stored hashed, never shown), a short user_code such as ABCD-EFGH (an alphabet without 0/O/1/I), the verification_url to open, expires_in (600 s) and the polling interval (3 s). Rate-limited per address like a sign-in. device_name is what the approval page shows, the CLI sends the host's name.

Start an OAuth sign-in (MCP)

Where an MCP client sends your browser: the request is checked (PKCE S256, exact redirect, the MCP resource), stored for ten minutes, and the consent page opens.

GET /api/v1/oauth/authorize?response_type=code&client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&state=…&scope=…&resource=https://statejar.com/mcp

Step one of connecting an app. The server checks the client and its redirect address first (a mismatch is answered 400 here, never by a redirect), then everything else: response_type=code, a PKCE code_challenge with S256 (the only method), the scopes (memory:read, memory:write, offline_access), and the resource (RFC 8707: it must be the MCP server's own URL; a client that sends none gets that one). A failed check sends the browser back to the app with error, state and iss. A passed one stores the request for ten minutes and sends the browser to the consent page (/oauth/authorize?req=…), which shows the app's name, where it sends you back, your account and the scopes, and posts your decision to /oauth/authorize/decide. Rate-limited per address.

Start Connect with OpenRouter

Begin OpenRouter's OAuth PKCE flow for the signed-in account and get the address of OpenRouter's authorization page.

POST /api/v1/keys/provider/openrouter/oauth/start (no body)

The first half of Connect with OpenRouter. The server makes the PKCE code_verifier and keeps it for ten minutes, encrypted with AES_KEY and bound to the signed-in account and to this browser. The verifier never leaves the server: the browser gets an HttpOnly cookie holding a random nonce (__Host-sj_openrouter; sj_openrouter over plain http on localhost) and this answer:

{"authorize_url": "https://openrouter.ai/auth?callback_url=…&code_challenge=…&code_challenge_method=S256&state=…&key_label=StateJar", "expires_in": 600}

  • code_challenge is the base64url SHA-256 of the verifier (S256).
  • state comes back on the callback, and is how the server finds this connection again.
  • callback_url is always this site's own page, OPENROUTER_OAUTH_CALLBACK_URL, and never anything the request said.
  • key_label prefills the new key's label on OpenRouter's page.

Errors:

  • 401: no console session. A developer key is refused too, as on every provider-key route.
  • 503: the callback setting is not an address OpenRouter can return to. Paste a key instead.
  • 429: more than 10 starts a minute or 30 an hour for the account.

Each start replaces nothing: two tabs can each have a connection under way. Every expired connection, from any account, is deleted when a new one starts.

Start two-factor setup

Creates a new authenticator secret and returns it once, with its otpauth link and a QR code.

POST /api/v1/auth/2fa/setup

Console session only. Returns the secret (base32), the otpauth:// link and a QR code (SVG) for your authenticator app. The secret is stored encrypted (AES-256-GCM) and never shown again; nothing changes at sign-in until you confirm it with /auth/2fa/enable. Calling it again before confirming replaces the secret. 409 if 2FA is already on.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

StateJar Demo Pay checkout page

The payment simulator's own checkout page, with Success, Failure and Pending buttons. Shown only to the owner and the demo account; no money ever moves.

GET /api/v1/billing/demopay/pay/{id}?t=… shows the page; POST the same address records the choice. Only the owner and the accounts in DEMO_PAY_EMAILS can create a Demo Pay order; everyone else sees the paid plans as "Join waitlist".

  • Success marks the simulated payment as paid, Failure as declined, Pending leaves it open.
  • After a choice, StateJar gets a signed notice and then asks Demo Pay for the order's status, the same two steps as a real gateway. The plan changes only when both agree on the order, the amount and the currency.
  • Every Demo Pay order is a test order: the receipt says so, and it is never counted as revenue. Demo Pay cannot be switched to live.

Submit your UTR

Submit your UTR.

POST /api/v1/billing/manual/orders/{order_id}/utr

Body {"utr", "amount"}. The UTR is 12 digits; the amount must equal the order's amount exactly; a UTR already used on any order is refused (409). The order then waits for an admin to check the bank statement. Submitting a false UTR is fraud.

Test mode only (2026-11-05): these methods are not offered on the production deployment and never activate a plan there. Nothing is ever credited on a UTR alone.

The providers listed in PAYMENTS_ENABLED_PROVIDERS are unaffected; these two methods are separate, test-only, and paid plans stay "coming soon".

Support tickets

POST /api/v1/tickets files a support ticket; GET /api/v1/tickets lists yours and GET /api/v1/tickets/{id} reads one with its thread.

POST /api/v1/tickets (multipart form) files a ticket: subject (3 to 120 characters), description (up to 5,000), category (account, memory, billing, api, bug, other), priority (low, normal, high, urgent), optionally a session_tag or handle that must be one of your own (anything else is 422), and up to 3 files (see attachments). Ten tickets an hour. Answers 201 with the ticket's id.

GET /api/v1/tickets lists your tickets, newest activity first. GET /api/v1/tickets/{id} returns one with its thread of messages (yours and the team's) and their attachments. Internal notes the team writes are never part of it. A ticket that is not yours answers 404, the same as one that does not exist.

A ticket moves open, in progress, resolved, closed. Your reply to a resolved ticket reopens it. See the My tickets page.

Test a provider connection

Check a saved key really works, before you need it live.

POST /api/v1/keys/provider/{provider}/test

Actually calls the provider and reports what happened, in plain language: a bad key, a rate limit, an unknown model, or an unreachable address. It never returns the provider's raw error body, those bodies have been observed to echo the request, including the key.

Worth pressing before a demo. A key that was pasted with a trailing space fails here in three seconds instead of on stage.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

The hosted MCP server

StateJar's memory as Model Context Protocol tools at https://statejar.com/mcp, with a developer key or an OAuth sign-in as the credential.

https://statejar.com/mcp, Streamable HTTP, stateless, JSON responses; the 2026-07-28 revision (one exchange per request, MCP-Protocol-Version header) and the earlier handshake revisions both answered.

Any MCP client (Claude Code, Claude Desktop and claude.ai connectors, ChatGPT connectors, Cursor, VS Code, Codex, Gemini CLI, OpenCode, Devin/Windsurf) can use it, with one of two credentials:

  • A developer key (sj_live_…, from the API keys page) as Authorization: Bearer <key>: the account's full memory credential.
  • An OAuth 2.1 sign-in: the client discovers StateJar's authorization server from the 401 and its two discovery documents (/.well-known/oauth-protected-resource/mcp, /.well-known/oauth-authorization-server), identifies itself (a published identity or a registration), opens the consent page in your browser, and exchanges the code for tokens (token endpoint). An access token lives an hour and carries the scopes you allowed: memory:read for the read tools, memory:write for the write tools. Disconnect an app on the API keys page.

A request without a valid credential gets 401 with WWW-Authenticate: Bearer resource_metadata="…", scope="memory:read memory:write", and no tool runs; a token without a tool's scope gets a 403 insufficient_scope tool error.

Tools (each takes an optional session, the scope inside your account; the default is mcp):

ToolScopeWhat it does
rememberwriteStores the facts in a message. Secrets are removed first; the reply lists what was stored, held or declined, with reasons. Optional facts: facts the client's own model read, each with its verbatim evidence; the server checks them against the text and its gates and reports the accepted and refused ones.
searchreadThe memory a question needs as memory_context, the handle it came from and the audit id of the disclosure.
getreadThe current state, one fact of it, or the state a handle names; the handle is re-derived first.
listreadEvery fact of the current state as path and value.
updatewriteA correction in the user's words, stored as a new version; facts as in remember.
deletewriteRemoves one fact from the current state as a new version; history keeps it.
historyreadThe versions, newest first, by handle.
auditreadWhat was disclosed, newest first.
verifyreadRe-derives a handle from the stored state.
exportreadEvery version with its handle and state.

Each tool calls the same routes as the REST API as your account, so the same limits, checks and audit rows apply. A fact a client proposed and the server accepted is stored with the source host, and the extraction audit row names the client as the model. Limits: a message of at most 20,000 characters, 32 proposed facts, a request of at most 256 KB, and MCP_RATE_PER_MIN requests per credential per minute. Nothing of a message is logged.

The manual payment methods on offer

The manual payment methods on offer.

GET /api/v1/billing/manual/methods

Signed in. Lists the enabled and fully set up methods with their limits, extra fee, bonus and the fraud warning. Empty on the production deployment.

Test mode only (2026-11-05): these methods are not offered on the production deployment and never activate a plan there. Nothing is ever credited on a UTR alone.

The providers listed in PAYMENTS_ENABLED_PROVIDERS are unaffected; these two methods are separate, test-only, and paid plans stay "coming soon".

The provider's return

Where Google or GitHub send the browser back; checks the flow, finds or creates the account, redirects to the console.

GET /api/v1/auth/oauth/{provider}/callback?code=…&state=…, registered with the provider as the redirect URI (PUBLIC_API_URL). Not called by hand.

The state must match the flow cookie's (an ?error= reply too), and a flow is answered once: the same cookie and state sent again are refused with state_mismatch, whatever the provider would say. The code is exchanged with the PKCE verifier; Google's ID token is validated (issuer, audience, nonce, signature against the JWKS). Nothing happens on an address the provider has not verified (Google email_verified, a GitHub verified address): no account is made, linked or signed into, and the answer is email_unverified whether or not an account holds the address. Otherwise the identity is matched by the provider's own account id: seen before, it signs its account in; new, it is linked to the account that holds the verified address, or creates one with it. Linking to an account whose address was never proven is that address's first proof: what the account's creator left is removed first (Confirm your e-mail address). Every outcome is a redirect to the console's /login/oauth: with the session cookie set (and ?account_reset=1 after such a reset), with #mfa_token=… when two-factor sign-in is on, or with ?error= one of provider_error, flow_expired, state_mismatch, no_email, email_unverified, disabled, cannot_sign_up.

Ticket attachments

GET /api/v1/tickets/{id}/attachments/{attachment_id} downloads an attachment of your own ticket; png, jpg, pdf and txt only, 5 MB each, 3 per message.

GET /api/v1/tickets/{id}/attachments/{attachment_id} sends the file as a download (Content-Disposition: attachment, X-Content-Type-Options: nosniff), to the ticket's owner only; anyone else gets 404.

What may be attached: png, jpg, pdf or plain text (UTF-8, no control characters), decided by the file's first bytes and not by its name, up to 5 MB each and 3 per message. Anything else is 422, an oversized file 413. Files are stored outside every served path under a random name, never opened or run by the server, and deleted with your account. See support tickets.

Tokens and dollars saved

What StateJar sent to a model against what sending the whole memory state would have cost, from your own audit rows and verified states, with StateJar's extraction cost beside it.

GET /api/v1/memory/bill

For the newest 200 audited turns of your account: the memory_tokens_sent (the memory context StateJar actually built, counted with tiktoken cl100k_base) against whole_state_tokens (the same builder given the whole active state), and the difference, tokens_saved_vs_whole_state. A turn whose state fails verification is counted in excluded_unverified and left out.

usd_saved_vs_whole_state prices that difference at the reply model's listed input price and is null when no turn used a model with a listed price. extraction shows how many extraction readings your account had, and what the cloud ones cost at the listed price, using the service's measured tokens per reading; it is null until usage has been measured. net_usd_saved_vs_whole_state subtracts it.

What is not here. full_history_tokens_saved is always null: the server never stores a transcript, so it cannot know what re-sending your chat would have cost. The Playground's Tokens tab counts that for the chat in front of you, from your browser's copy.

Turn off two-factor sign-in

Needs your password and a current code (or a recovery code); removes the secret and every recovery code.

POST /api/v1/auth/2fa/disable with {"password": "…", "code": "…"}

Console session only. Both the password and a code are required, so a session left open on a shared machine cannot switch it off. A wrong pair answers 403 and counts toward the lockout.

An account without a password (made by an e-mail code, Google or GitHub; see Confirm your e-mail address) sends a fresh code from the app alone, {"code": "123456"}, or, having lost the phone, a recovery code beside a code we e-mail it for 2fa_disable (POST /auth/account/code): {"code": "abcde-fghjk", "email_code": "12345678"}. An admin account that turns 2FA off loses access to the admin panel until it is turned on again.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Turn on two-factor sign-in

Confirms setup with a 6-digit code; returns ten one-time recovery codes, once.

POST /api/v1/auth/2fa/enable with {"code": "123456"}

Console session only. The code must come from the secret created by /auth/2fa/setup. On success, sign-in asks for a code from then on, and the response carries ten recovery codes, shown only here, each usable once if you lose the phone, plus a new session token marked as verified. A wrong code answers 400 and counts toward the sign-in lockout.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Two-factor status

Whether two-factor sign-in is on for your account, how many recovery codes are left, and whether this session passed a code.

GET /api/v1/auth/2fa

Console session only. Returns enabled, pending (setup started but not confirmed), recovery_codes_remaining and session_verified (this session was issued after a code, the admin panel needs that). See two-factor sign-in.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Unsubscribe from founder e-mails

The link at the end of a bulk e-mail from StateJar's founder. One click stops further e-mails of that kind to your address.

GET /api/v1/leads/unsubscribe?e=…&t=… is the link at the end of a bulk e-mail sent from StateJar's lead inbox (investor updates, the paid-plan waitlist). It records only a keyed hash of your address, and no further mail of that kind is sent to it. Account and sign-in e-mails are not affected.

Usage

Aggregate disclosure volume across your audited calls.

GET /api/v1/usage

Sums the retriever's work over every audited call: how much was disclosed, and an estimate of what was saved against the disclosable baseline.

An estimate, and labelled as one, it is derived from stored field keys and sizes, not from a provider's billing.

Integrity. The request and state counts are as stored; the tokens-saved estimate uses verified states only and is null (not 0) when none verify.

Plan. plan holds the account's plan and this month's use against it: adds (adds_used of adds_limit: messages a model read, with the month's top-ups) and recalls (recalls_used of recalls_limit: query, chat, restore), developer keys, retention, seats, and cloud_extractions_used of cloud_extractions_limit, the messages the server's own cloud providers read for the account this month against the plan's budget (null = no budget). Past the budget the account's messages are read without a model, their weak facts held, until your own provider key reads them or the month turns; nothing is refused. A message read with your own saved provider key does not count.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Use a sign-in link

The page's POST that turns a link's token into a session. Never on the mail scanner's GET.

POST /api/v1/auth/link/consume with {"token": "…"}, sent by the link page when you press Sign in, not when the page opens, so a mail scanner that prefetches the link cannot spend it.

Needs the __Host-sj_link cookie of the browser that asked for the link; without it or with another browser's, 403 and the link stays usable where it was requested. A token that is unknown, expired or already used answers 401. Otherwise the answer is a session, as a verified code gives: access_token, the __Host-sj_session cookie, or mfa_required for an account with two-factor sign-in on.

The link proves the address. On an account whose address was never proven (made by a sign-up with a password), that first proof resets what the account's creator left before the session opens, and the answer carries "account_reset": true: see Confirm your e-mail address.

Verify a sign-in code

Exchanges the e-mailed code for a session; creates the account on its first verified code.

POST /api/v1/auth/code/verify with {"email": "you@example.com", "code": "12345678"} (spaces in the code are ignored).

A right code within its ten minutes signs the account in: the answer carries access_token (the JSON token every client uses) and sets the session cookie __Host-sj_session (see Sessions). An address with no account gets one here, this is the moment the address is proved, so such an account has no password; it signs in by code, link, Google or GitHub. An account with two-factor sign-in on gets mfa_required and a five-minute mfa_token instead, for POST /auth/login/2fa, exactly as a password does. A wrong, expired or used code answers 401 wrong or expired code; the sixth guess at one code finds it dead. A suspended account answers 403 after the code check, and nothing else changes.

The first proof of an address. An account made by a sign-up with a password has never proven its address. The code is that proof, so before the session opens, whatever the account's creator left is removed, its password, every other session and token, developer keys, connected apps, two-factor sign-in, linked Google or GitHub sign-in and saved provider keys, and the answer carries "account_reset": true; the memory stays. The code's mail says so first. An owner who confirms the address from inside the account never sees this (Confirm your e-mail address). Only sign-in codes sign in: a code from POST /auth/account/code answers 401 here.

What the account's forms need

GET /api/v1/auth/account says whether the account has a password it set, whether its address is confirmed, whether 2FA is on and whether codes can be e-mailed.

GET /api/v1/auth/account (a console session; a developer key gets 401)

{"email": "you@example.com", "password_set": false, "email_verified": true,
 "email_verified_at": "2026-10-27T09:00:00+00:00", "two_factor": false, "email_codes": true}
  • password_set, whether you set this account's password yourself. An account made by an e-mail code, Google or GitHub has none, and neither has one whose address was first proven by a sign-in (see Confirm your e-mail address). Without one, deleting the account, downloading your data and the two-factor changes ask for a code we e-mail you where they would ask for the password; with two-factor sign-in on, a code from your app works too.
  • email_verified / email_verified_at, whether and when the address was first proven: by a sign-in code or link e-mailed to it, a Google or GitHub account that has verified it, or your own confirmation. A sign-up with a password proves nothing.
  • two_factor, whether two-factor sign-in is on.
  • email_codes, whether this server e-mails codes now (EMAIL_SIGN_IN, AUTH_LINK_SECRET and a mail server that sends).

Which account is this key? (whoami)

`statejar whoami`, the account, label and expiry behind the presented key, never the key.

GET /api/v1/device/whoami with X-API-Key

Answers the account's e-mail and the key's last four characters, label and expiry. The key itself is never returned. A console JWT also answers (with no key details), so the page and the terminal agree on who is signed in.

Which sign-in methods are on

Public: the methods the admin has enabled, for the login page.

GET /api/v1/auth/methods, no sign-in needed.

methods lists what is on, in order, from password, code, link, google, github; the login page shows only those. email_configured says whether the server can send codes and links: the EMAIL_SIGN_IN switch, AUTH_LINK_SECRET and a mail server that sends. A method that is off answers 404 on every one of its own routes, so a page that shows a stale button cannot sign anyone in with it. Changed from Login methods in the admin panel.

Your connected apps

The apps this account connected through OAuth, with their scopes and last use; disconnecting one revokes every token it holds for you.

GET /api/v1/oauth/clients and DELETE /api/v1/oauth/clients/{id}, console session only.

The list behind Connected apps on the API Keys page: one row per app that ever held a token for this account, client_name, client_id, kind, the scopes of its newest grant, connected_at, last_used_at and whether it is active (holds a live token). DELETE revokes every token the app holds for this account at once ({"revoked": n}): its next MCP call gets 401 and its refresh fails; it may ask to connect again, and you decide again. 404 for an app this account never connected. Both write nothing but the revocation's event row.

Your sessions

Lists the account's live sessions, masked, and which one is this browser's.

GET /api/v1/auth/sessions, signed in.

Every live session of the account: when it was opened, last used and expires, the browser's user-agent string (shortened), whether it was opened with a two-factor code, and current: true on the one this request came with. No token and no address are in the answer: the server holds only their hashes. current_is_cookie is false for a caller that signed in with a JSON token only, which has no session row of its own. Shown in Profile under Sessions.

Your StateJar API keys

List the keys you have generated, and revoke one.

GET /api/v1/apikeys · DELETE /api/v1/apikeys/{key_id}

The listing never contains a usable key, only an id, a label, a prefix and timestamps. There is no endpoint that returns a full key after creation, because a store that can show you a key can show it to whoever reads the database.

Deleting is immediate. A revoked key fails on its next request.

Request and response shapes, with copyable samples in four languages, are in the API Docs, this entry explains the reasoning, not the schema.

Settings

AES_KEY

Environment variable AES_KEY. Default: change-me-32-bytes-key-required!

Environment variable: AES_KEY

Default: change-me-32-bytes-key-required!

The key that encrypts stored provider keys. Must be changed before deployment, and must be exactly 32 bytes.

Provider keys are encrypted with this before they touch the database and are never returned by the API, the listing shows only the last four characters.

Rotating it makes existing stored provider keys undecryptable. They cannot be recovered; users have to re-enter them. Plan for that before changing it.

ANTHROPIC_BASE_URL

Environment variable ANTHROPIC_BASE_URL. Default: https://api.anthropic.com/v1

Environment variable: ANTHROPIC_BASE_URL

Default: https://api.anthropic.com/v1

Where Anthropic requests are sent.

Change it to route through a gateway, a regional endpoint, or a proxy that speaks the Anthropic API. StateJar appends its own paths to this base, so give the API root and not a full endpoint URL.

See Anthropic.

APP_ENV (which deployment this is)

Which deployment the backend is, production or staging, as the deploy passes it; empty on a developer's machine. Payments' test mode is refused in production.

APP_ENV names the deployment: production or staging, passed by the deploy, and empty on a developer's machine.

Payments read it: in production, test mode is refused whatever else is set, so no order made against a stand-in can give an account a plan there, and a test order that reached a production database activates nothing.

BACKUP_GEMINI_MODEL

The Gemini model the extraction backup asks.

BACKUP_GEMINI_MODEL, default gemini-3.5-flash-lite, a model id of the Gemini API (generateContent).

Asked only by the extraction backup (BACKUP_PROVIDERS), with the same JSON schema, temperature 0 and output limit as the local model, and the key sent in a header, never in the URL. The audit row of a reading it answered names it as gemini/<model>.

BACKUP_OPENROUTER_MODEL

The OpenRouter model the extraction backup asks.

BACKUP_OPENROUTER_MODEL, default openai/gpt-4o-mini, an OpenRouter model id.

Asked only by the extraction backup (BACKUP_PROVIDERS), with the same JSON schema as the local model (response_format: json_schema, strict), temperature 0, and provider.data_collection: deny, so OpenRouter routes the call only to providers that do not store or train on inputs; a model no such provider serves answers 503 and the next key or provider is tried. The audit row of a reading it answered names it as openrouter/<model>.

BACKUP_PROVIDERS

The master switch of extraction's cloud path and the providers that start enabled; empty = no cloud provider is asked.

BACKUP_PROVIDERS, default gemini,openrouter, a comma-separated list of registry provider names; empty turns the cloud path off altogether.

The list is the master switch and the starting point: the providers it names are seeded enabled in the provider table when the table is first created (the two the owner authorised), every other provider of the registry starts off, and from then on the table decides which providers are asked and in which order (priority). A provider whose terms allow training on inputs (Gemini's free tier, Mistral's free mode, DeepSeek, Cohere) is never enabled by this list except Gemini, whose key the owner authorised knowingly.

Which side reads first is EXTRACTION_PRIMARY: with cloud (the default) the enabled providers read every message and no local model stands behind them (none runs on a deployed server); with local, in development and eval only, a hand-made local model reads first and a cloud key is asked only after EXTRACTOR_BACKUP_AFTER_S (EXTRACTOR_BACKUP_JOB_AFTER_S for a background reading). Either way the cloud model only proposes: the rules, the span proposer, the deterministic gates, the store policy, versioning and the audit trail apply to its answer exactly as to a local one, and the audit log's extraction row names the provider and model that read the message.

The keys are rows of the backup_keys table (encrypted with AES_KEY, masked after saving, managed by admins in backup keys), never settings. A key that is rate-limited or refused goes on cooldown and the next key, then the next provider, is tried; when none answers, the message is read without a model, its weak facts held, and queued to be read again when a provider is healthy. A provider row with no model reads BACKUP_GEMINI_MODEL or BACKUP_OPENROUTER_MODEL for those two providers, else the registry's default. Requests to OpenRouter are routed only to providers that do not store or train on inputs (provider.data_collection: deny). The privacy policy lists every provider with its data-use terms.

BILLING_PUBLIC_URL

The site the payment provider sends the buyer back to after its checkout (<url>/billing?order=<id>), and the links in receipts.

BILLING_PUBLIC_URL, default https://statejar.com.

After paying (or giving up) on the provider's own page, the buyer's browser is sent to <this>/billing?order=<id>, which shows the order as the server holds it and activates nothing (orders). Receipts link to <this>/billing and the refund policy. Live mode (PAYMENTS_MODE) needs it to be HTTPS; a local test run points it at the local frontend. Nothing takes payments until PAYMENTS_ENABLED_PROVIDERS names a provider.

BILLING_RECONCILE_ENABLED

Whether the API runs the payment reconcile pass by itself (true, the default), asking the provider about open orders and pending refunds.

BILLING_RECONCILE_ENABLED, default true.

While it is on and a payment provider is configured, the API asks the provider about every open order and pending refund every five minutes while one is open, and every hour otherwise, so a payment whose notice never arrived still starts its plan and an expired checkout is closed (the reconcile pass, which an admin can also run at once). It runs whether or not PAYMENTS_ENABLED_PROVIDERS still names the provider, so an order paid before payments were switched off is still honoured. The test suite turns it off.

BILLING_WEBHOOK_BASE

The public API base the payment provider's notices are sent to (<base>/billing/binance/webhook), given to the provider with each order and refund.

BILLING_WEBHOOK_BASE, default https://api.statejar.com/api/v1.

Each order and refund tells the provider where to send its notices: <this>/billing/binance/webhook (the webhook). It must be reachable from the provider: the production API in live mode (HTTPS is required there, PAYMENTS_MODE), the local API in a test run, where the stand-in posts its signed notices. Nothing takes payments until PAYMENTS_ENABLED_PROVIDERS names a provider.

BINANCE_PAY_API_KEY

The Binance Pay merchant API key; a server secret written by the deploy from a GitHub secret, never shown, logged or stored in the database.

BINANCE_PAY_API_KEY, default empty.

The API key of the owner's Binance Pay merchant account, sent with every request as BinancePay-Certificate-SN. It reaches the server only as a GitHub Actions secret that the deploy writes into the environment's env file (mode 600, docs/payments.md); it is never printed, logged, returned by the API or written to the database. Without it, and without BINANCE_PAY_SECRET_KEY, Binance Pay takes no payment whatever PAYMENTS_ENABLED_PROVIDERS says. In test mode the stand-in's own fixture key is used instead.

BINANCE_PAY_BASE_URL

Where the server calls Binance Pay, its documented host in live mode, a stand-in on the server's own machine in test mode.

BINANCE_PAY_BASE_URL, default https://bpay.binanceapi.com, the one host Binance Pay's merchant documentation names.

Every request to Binance Pay (create, query and close an order, refunds, its certificates) goes here, signed with the merchant's key. In live mode (PAYMENTS_MODE live) it must be exactly that host; in test mode it must be a loopback address, where the stand-in merchant server runs, and Binance's host is refused. A value that does not fit the mode turns Binance Pay off, whatever PAYMENTS_ENABLED_PROVIDERS says. So the default, with the default test mode, takes no payment.

BINANCE_PAY_FIAT_CURRENCY

Which of a plan's prices Binance Pay is asked to charge, INR (the default) or USD; Binance converts it into USDT or USDC at its own rate.

BINANCE_PAY_FIAT_CURRENCY, default INR.

A plan has a price in rupees and one in dollars (the plans table, which the admin panel edits). Binance Pay is asked to charge the plan's price in this currency, and it converts that into USDT or USDC at its own rate when the order is made; the order then records both, the price as listed and what was charged, and a payment must match what was charged to the last digit. USD asks for the dollar price instead. A plan without a price in that currency, or with a price of zero, is not sold. Nothing takes payments until PAYMENTS_ENABLED_PROVIDERS names a provider.

BINANCE_PAY_SECRET_KEY

The Binance Pay merchant secret that signs every request; a server secret written by the deploy from a GitHub secret, never shown, logged or stored.

BINANCE_PAY_SECRET_KEY, default empty.

Signs every request to Binance Pay: the upper-case hex HMAC-SHA512 of the timestamp, the nonce and the exact body, as Binance's API rules specify (app/billing/binance.py). Like the API key it reaches the server only as a GitHub Actions secret the deploy writes into the env file, and is never printed, logged, returned or stored. Notices from Binance are not checked with it: they carry Binance's own RSA signature, checked with its published certificate (webhook). Nothing takes payments until PAYMENTS_ENABLED_PROVIDERS names a provider.

BUILTIN_GATE_DIR

The directory the backend processes share to keep extraction off the development local model while a reply runs (none runs on statejar.com).

Development and eval only. StateJar runs no local model on staging or production (owner's decision, 2026-10-30); this setting matters only to a run that builds one by hand (BUILTIN_MODEL).

BUILTIN_GATE_DIR, default empty (the temp directory's statejar-builtin-gate).

The backend runs two processes in one container, and the development local model (BUILTIN_MODEL) serves replies and extraction v3. A reply marks this directory while it waits for and uses the model; an extraction call starts only while no process has such a mark, and one extraction call runs at a time across the processes. If the directory cannot be made, each process keeps only its own gate and the server logs it once.

BUILTIN_LLAMA_PROPOSER_SLOT

The llama-server slot extraction's proposals are pinned to, so replies and the verifier go first; -1 lets the server pick.

Development and eval only. StateJar runs no local model on staging or production (owner's decision, 2026-10-30); this setting matters only to a run that builds one by hand (BUILTIN_MODEL).

BUILTIN_LLAMA_PROPOSER_SLOT, default -1.

llama-server never puts calls with different adapter settings in one batch. Each step runs the calls whose setting matches the lowest-numbered busy slot. deploy.sh pins extraction's proposals, the only calls with the adapter on, to the server's last slot (NUM_PARALLEL − 1). Replies and the verifier, both the base model, then go before them, as the backend's priority gate already orders them, and the proposals' slot keeps its cached prompt. -1 lets the server pick.

BUILTIN_MAX_IN_FLIGHT

How many development-local-model replies (none runs on statejar.com) one backend process generates at once; the rest wait up to 20 s, then 503.

Development and eval only. StateJar runs no local model on staging or production (owner's decision, 2026-10-30); this setting matters only to a run that builds one by hand (BUILTIN_MODEL).

BUILTIN_MAX_IN_FLIGHT, default 5.

The bounded queue in front of the development local model (BUILTIN_MODEL). Ollama decodes a fixed number of requests at a time (NUM_PARALLEL in the model server's configuration, 5 on statejar.com); more would only pile up inside it. Each backend process therefore lets at most this many replies generate at once; a further request waits up to 20 seconds for a slot and then answers HTTP 503 "busy, try again in a moment, or add your own provider key". Set it to Ollama's NUM_PARALLEL (the backend runs two workers, so the server-wide bound is twice this).

BUILTIN_MODEL

Development and eval only, a local Ollama model extraction v3 can read with; empty (the value in every deployed environment) means StateJar runs no model of its own.

BUILTIN_MODEL, default empty, and empty on statejar.com and staging.

StateJar runs no model on its own servers (owner's decision, 2026-10-30). The free built-in model that answered Playground replies, and the local fallback behind the cloud providers for extraction, are gone: replies are answered by the cloud providers (StateJar's default model), extraction is read by the cloud providers (EXTRACTION_PRIMARY), and a message no provider can read is read by the rules and the span proposer alone, its weak facts held, and read again when a provider is healthy.

This setting survives for development and for an evaluation run that builds a local Ollama model by hand (BUILTIN_OLLAMA_URL, or llama-server at BUILTIN_LLAMA_URL: BUILTIN_SERVER). deploy.sh writes it empty on every deploy, and extraction v3 then builds its engine from the cloud pool alone. Extraction v3 asks the model named here unless EXTRACTOR_V3_MODEL names another; the old rule tiers never do.

CLOUD_DEFAULT_PROVIDER_CAP_USD

The monthly cost cap, in US dollars, of every cloud provider that has no limit of its own; at the cap the provider is skipped until the month turns.

CLOUD_DEFAULT_PROVIDER_CAP_USD, default 25 (US dollars a month); 0 stops every provider that has no limit of its own.

Extraction's cloud providers read first, so their spend needs a ceiling even when nobody set one. A provider whose row in the provider table has no cost_limit_usd_month is capped at this figure; an admin may set a different limit for a provider, higher or lower, and clearing it returns the provider to this default. The month's cost is estimated from the tokens the server's own keys used at the provider's paid-tier list price, even on a free key, so a free tier never hides the spend a paid key would have. At the cap the provider is skipped until the month turns, and the next provider reads the message; with no provider left the message is read without a model and queued to be read again. The cloud health check says cost_limit for a provider at its cap.

Each account also has a monthly budget of cloud-extracted messages from its plan (cloud_extractions_per_month in plans); past it that account's messages are read without a model (or by the account's own saved provider key).

CLOUD_REPLIES_PER_HOUR

How many replies per account per hour StateJar's default model answers; HTTP 429 after that.

CLOUD_REPLIES_PER_HOUR, default 30.

Replies from StateJar's default model are counted per account from the audit rows every reply writes (provider cloud, kind chat), over the last hour. Past the limit /chat answers HTTP 429 with a message that says so; replies through the user's own provider keys are not counted and not limited by this setting.

CLOUD_SPEND_CAP_USD

The default global monthly cap on cloud provider spend in US dollars (25) until the owner sets one in the admin console.

CLOUD_SPEND_CAP_USD, default 25.

The month's estimated spend over every cloud provider is held against this figure (the spend cap, which the owner can change in the admin console and which then wins). At it, cloud extraction is held: messages are read by the rules at once and queued for the model until the cap is raised or the month turns. The owners are mailed once a month at 50%, 80% and 100%.

CLOUD_WATCH_ENABLED

Whether each backend process watches extraction's cloud providers, so the cloud health check knows whether they can read.

CLOUD_WATCH_ENABLED, default true.

Every cloud call made with one of the server's own provider keys notes how its provider answered: a success, an HTTP status, a timeout, the monthly cost cap reached, no usable key. A call made with a user's own saved key says nothing about the server's keys and is not noted. With this setting on, each backend process also looks every quarter of CLOUD_WATCH_INTERVAL_S, and at least every 15 minutes, and sends one probe to a provider whose newest outcome is older than the interval: the Test key call, a 16-token answer to "ping" with the key the pool would pick next, counted on that key like any call. A server with traffic is never probed for a provider its calls answer for. One process probes at a time.

The cloud extraction health check answers from these notes. With the setting off the notes from real calls still arrive, and a provider nobody's message reached stays "unknown". Tests turn it off; there is no reason to turn it off on a server that has cloud providers.

CLOUD_WATCH_INTERVAL_S

How old a cloud provider's newest outcome may be before the watcher probes it; at most one probe per provider in this time.

CLOUD_WATCH_INTERVAL_S, default 3600 (one hour); values under 300 are read as 300.

The cloud watcher probes an enabled provider only when neither a real call nor an earlier probe has said how it answers for this long, so each provider gets at most one probe in this time and none while traffic answers for it. A probe is the cheapest call the provider takes (a 16-token answer), so even an idle server spends at most 24 such calls a day per provider at the default, well inside a free tier. A shorter interval notices a dead key sooner on a quiet server; the health check shows how old each provider's newest outcome is (age_s).

CONTEXT_BUDGET_TOKENS

Environment variable CONTEXT_BUDGET_TOKENS. Default: 200. The most a memory context sent to a model may cost, in tokens; 0 turns the cap off.

Environment variable: CONTEXT_BUDGET_TOKENS

Default: 200

The memory context StateJar puts in front of a model (the memory_context of POST /memory/query, and the system message of POST /chat) never costs more than this many tokens, counted with tiktoken cl100k_base.

  • The fields the message is about go in first: the ones it names, the topics it asks about, and the ones whose stored value it mentions. For a question on a small memory, the rest of the memory fills what is left.
  • A field that does not fit is left out whole, never cut in half, and is listed in the response's metadata.budget_dropped.
  • Nothing is sent at all when nothing stored is relevant: small talk ("hey, how are you?"), a task no stored fact bears on, or an account with no memory yet. The context is then an empty string.
  • The context starts with the same fixed line every time, so a provider's prompt cache can reuse it.

0 turns the cap off. A model's own tokenizer may count a few tokens more or fewer than cl100k_base.

CORS_ORIGINS

Environment variable CORS_ORIGINS. Default: localhost:5173, statejar.vercel.app, statejar.com

Environment variable: CORS_ORIGINS

Default: localhost:5173, statejar.vercel.app, statejar.com

Which browser origins may call the API.

A frontend served from an origin not in this list gets a CORS failure that looks, from the browser, like the API being down. When you deploy the frontend somewhere new, add its origin here.

This is deployment wiring rather than a product feature, see docs/deployment.md.

DB_URL

Environment variable DB_URL. Default: mysql+pymysql://root:@localhost:3306/statejar

Environment variable: DB_URL

Default: mysql+pymysql://root:@localhost:3306/statejar

The database StateJar stores state, audit rows and accounts in.

Any SQLAlchemy URL works. MySQL is the deployed default; SQLite is what the test suite uses, and it matters more than it sounds, the default points at MySQL, so a test run that forgets to override this pays a connection timeout per client and takes minutes instead of seconds:

DB_URL="sqlite:///:memory:" python -m pytest -q

Changing this on a running deployment points StateJar at different data. It does not migrate anything.

DEMO_PAY_EMAILS

The accounts, besides the owner, that may pay with StateJar Demo Pay, the payment simulator. Nobody else sees it.

DEMO_PAY_EMAILS=demo@example.com (default: empty, so only the owner can use Demo Pay). Sign the account up first, then list it: sign-up refuses an address that is already listed.

EMAIL_SIGN_IN

Whether this server e-mails codes at all, sign-in codes and links, and an account's own codes. Off on the servers until a PR turns it on.

EMAIL_SIGN_IN=true|false (default here: true; on the servers it comes from each environment's deploy configuration, and a missing file or line means false).

With it off, nobody is e-mailed a code: asking for a sign-in code or link and asking for an account code answer 503, and /auth/methods says e-mail sign-in is not configured, so the login page offers the password. A code already sent can still be used.

E-mail sign-in also needs AUTH_LINK_SECRET and a mail server that sends (MAIL_MODE=smtp). The switch exists so that neither of those reaching a server turns e-mail sign-in on by itself: it is turned on in a reviewed change once the mail server's DNS records resolve. Turn Google or GitHub sign-in on only where this is on, an account they make has no password, and confirms deletion and export with an e-mailed code (or a code from its authenticator app once two-factor sign-in is on).

EXTRACTION_PRIMARY

Which side reads a message first, the enabled cloud providers (the default) or, in development and eval only, a local model; no deployed environment runs a local model.

EXTRACTION_PRIMARY, cloud (the default) or local.

With cloud, a model call during extraction goes first to the cloud providers an admin has enabled, in their priority order, round-robin over each provider's healthy keys (backup providers, backup keys); the first side gets EXTRACTOR_BACKUP_AFTER_S of the call's budget (EXTRACTOR_BACKUP_JOB_AFTER_S for a background reading). There is no local model behind them (owner's decision, 2026-10-30; staging and production run none): when every enabled provider and key failed or answered nothing usable, the message is read without a model (the rules and the span proposer), a fact only one of them supports is held aside rather than stored, and the message is queued to be read again when a provider is healthy, so no fact is lost, only delayed.

With local, which only a development or eval run with a hand-made local model uses (BUILTIN_MODEL), the local model reads first and a cloud key is asked only after it was busy or silent for that long, errored, or is not loaded. Evaluation runs never reach a cloud provider in either setting.

Whichever side proposed a fact, the rules, the span proposer, the deterministic gates, the store policy, versioning and the audit trail apply alike, and the audit log's extraction row names the provider and model that read the message. The privacy policy lists the providers and each one's data-use terms.

EXTRACTOR_BACKUP_AFTER_S

How long a message a client waits for gives the first-tried side (the cloud providers by default) before the other side is asked (seconds).

EXTRACTOR_BACKUP_AFTER_S, default 20.

For a message read while the client waits (POST /memory/ingest without async), each model call gives the side EXTRACTION_PRIMARY names first this long. With cloud (the default) the enabled providers (BACKUP_PROVIDERS) get it to answer, keys and providers in turn, and the call times out at EXTRACTOR_V3_TIMEOUT_S; when none of them answered anything usable the message is read without a model and queued to be read again (no local model runs on a deployed server). With local the local model gets it, to get a turn at the shared model and to answer, and the cloud takes the rest if it has not answered or answered with an error; an answer the local model did give, even one cut short, is then kept. In both orders a chunk the local server refused as too long is read in halves as before, and without a usable cloud key the local model keeps the whole timeout.

EXTRACTOR_BACKUP_JOB_AFTER_S

How long a background reading gives the first-tried side before the other side is asked (seconds).

EXTRACTOR_BACKUP_JOB_AFTER_S, default 90.

The same rule as EXTRACTOR_BACKUP_AFTER_S for a message read in the background (async: true, a long message, or one sent while the session's earlier message is still being read): nobody waits for it, so the first-tried side gets longer before the call goes to the other, and the rest of EXTRACTOR_V3_JOB_TIMEOUT_S is the other side's.

EXTRACTOR_LLM_FALLBACK

Environment variable EXTRACTOR_LLM_FALLBACK. Default: False

Environment variable: EXTRACTOR_LLM_FALLBACK

Default: False

Whether tier 3, extraction via a language model, may run at all.

Off by default, and deliberately. Tier 3 spends your provider credit on every message that triggers it. Turning it on is a decision to pay for better extraction on messy input.

Turning this on is necessary but not sufficient: tier 3 also needs a provider key saved for the account, and it only fires when the cheap tiers came up short. See tier 3 for the exact trigger conditions.

EXTRACTOR_LLM_FALLBACK_MODELS

Environment variable EXTRACTOR_LLM_FALLBACK_MODELS. Default: openrouter/anthropic/claude-haiku-4.5,openrouter/google/gemini-2.5-flash

Environment variable: EXTRACTOR_LLM_FALLBACK_MODELS

Default: openrouter/anthropic/claude-haiku-4.5,openrouter/google/gemini-2.5-flash

Every default entry starts with openrouter/, so one saved OpenRouter key covers the whole chain. An entry without that prefix, for example openai/gpt-4o-mini, is routed to that vendor's own API and needs that vendor's key saved as well.

Comma-separated models to try, in order, when EXTRACTOR_LLM_MODEL fails.

A rate limit or an outage on one vendor should degrade extraction quality, not stop it. The chain is tried in order; if every model fails, the tier is marked unavailable and you see the amber chip, the deterministic tiers still ran, and the state is still valid, just thinner.

Only recoverable failures advance the chain. A bug in StateJar's own code is not retried against another vendor; it is raised.

EXTRACTOR_LLM_MODEL

Environment variable EXTRACTOR_LLM_MODEL. Default: openrouter/openai/gpt-4o-mini

Environment variable: EXTRACTOR_LLM_MODEL

Default: openrouter/openai/gpt-4o-mini

Which model tier 3 asks first.

It must be a model that reliably returns strict JSON, extraction is a structured task, and a model that editorialises around its JSON fails the parse and burns the call. gpt-4o-mini is the default because it is cheap and consistent at exactly this.

The id is namespaced with its provider, because a bare openai/gpt-4o-mini is ambiguous between an OpenRouter id and an OpenAI routing prefix.

EXTRACTOR_MODE

Environment variable EXTRACTOR_MODE. Default: auto

Environment variable: EXTRACTOR_MODE

Default: auto

Which extraction tiers may run.

ValueBehaviour
autorules, then GLiNER2 if its dependencies are installed
glinerforce the neural tier on; fail loudly if it cannot load
rulesdeterministic patterns only

rules is the fastest and the most predictable, and it is what the test suite runs with. It is also a reasonable production setting if your input is well-formed, the neural tier costs memory and start-up time.

This does not control tier 3; that is EXTRACTOR_LLM_FALLBACK.

EXTRACTOR_V3_BACKGROUND_CHARS

Messages longer than this are read in the background (202 and a job); default 3000.

EXTRACTOR_V3_BACKGROUND_CHARS, default 3000.

With extraction v3, a message longer than this many characters is answered with 202 and a job instead of being read while the request waits. Messages are limited to 50,000 characters.

EXTRACTOR_V3_CLOUD_VERIFIER_MODEL

A stronger cloud model for extraction's verify step only; off by default.

EXTRACTOR_V3_CLOUD_VERIFIER_MODEL, default empty (off), or <provider>/<model>, for example gemini/gemini-3.8-flash.

When set, extraction's verify step (the yes/no check on each proposed fact) asks that model of that provider first, with the same pool keys, cooldowns and cost limits as every other extraction call; the facts are still proposed by the usual model (BACKUP_GEMINI_MODEL). If the stronger model gives no usable answer, the usual client answers the check. Empty: the verifier asks the same model as the proposer, as before.

EXTRACTOR_V3_GLINER

A GLiNER checkpoint for extraction v3's span proposals; empty means none.

EXTRACTOR_V3_GLINER, default empty.

The name of a GLiNER checkpoint baked into the server image (the ML extras), for example urchade/gliner_multi-v2.1. Its spans (person, city, organisation …) are hints for the model and a second source for the store policy; a span never becomes a stored fact on its own. Empty runs v3 without it. Only read when EXTRACTOR_VERSION is v3.

EXTRACTOR_V3_HOLD_TURNS

How many further messages a held fact waits to be said again; default 5.

EXTRACTOR_V3_HOLD_TURNS, default 5.

A fact only one weak source supports is held outside the state. If the user says it again within this many messages in the same session it is stored; otherwise it is dropped. Held facts are never sent to a model and never part of a handle.

EXTRACTOR_V3_JOB_TIMEOUT_S

Seconds a background ingest waits for one model call; default 300.

EXTRACTOR_V3_JOB_TIMEOUT_S, default 300.

How long one model call of a background ingest may take: an ingest sent with "async": true, or a message long enough to be read in the background. Nobody waits for a background ingest, so its calls get the timeout extraction v3 was measured with on the blind set; the ingest a client waits for keeps EXTRACTOR_V3_TIMEOUT_S. A call that times out leaves its facts held rather than stored, and that message's reading is not cached.

EXTRACTOR_V3_MAX_IN_FLIGHT

Extraction calls one backend process may have on the development local model at once; default 2.

Development and eval only. StateJar runs no local model on staging or production (owner's decision, 2026-10-30); this setting matters only to a run that builds one by hand (BUILTIN_MODEL).

EXTRACTOR_V3_MAX_IN_FLIGHT, default 2.

The server's own model answers Playground replies and reads messages for extraction v3. Replies go first: an extraction call waits while a reply is waiting, never takes the model's last free slot, and each backend process has at most this many extraction calls in flight. Since 2026-10-13 an extraction call also waits while a reply runs or waits in either backend process, and one extraction call runs at a time across them (BUILTIN_GATE_DIR); under replies without a pause it waits up to its own timeout, then the message is read without the model.

EXTRACTOR_V3_MODEL

The model extraction v3 asks; empty means the built-in model.

Development and eval only. StateJar runs no local model on staging or production (owner's decision, 2026-10-30); this setting matters only to a run that builds one by hand (BUILTIN_MODEL).

EXTRACTOR_V3_MODEL, default empty, which means BUILTIN_MODEL.

The model extraction v3 asks to propose facts and then to verify them, on a development model server built by hand (BUILTIN_SERVER); on statejar.com there is none, and the cloud providers read every message (and, when that development model is busy, fails or is missing, the cloud takes the call BACKUP_PROVIDERS)). On llama-server with an adapter (BUILTIN_LLAMA_ADAPTER), the proposals are made with the adapter on and the verifier's calls with it off. Calls use structured output, temperature 0 and a fixed seed. Playground replies go before them on the shared model (EXTRACTOR_V3_MAX_IN_FLIGHT). Only read when EXTRACTOR_VERSION is v3.

EXTRACTOR_V3_TIMEOUT_S

Seconds extraction v3 waits for one model call while a client waits; default 60.

EXTRACTOR_V3_TIMEOUT_S, default 60.

How long one extraction call to the model may take while the client waits for the ingest (the verifier gets half, at least 10 s). A background ingest ("async": true, or a long message) uses EXTRACTOR_V3_JOB_TIMEOUT_S instead. A call that times out leaves its facts held rather than stored, and that message's reading is not cached, so the next ingest tries the model again.

EXTRACTOR_VERSION

Which extractor reads messages: rules (the rule tiers) or v3; default rules.

EXTRACTOR_VERSION, default rules.

rules reads messages with the rule tiers as before. v3 switches POST /memory/ingest to extraction v3: rules, GLiNER spans and the server's own model (EXTRACTOR_V3_MODEL) propose facts, and deterministic checks decide, the evidence must be in the message, the value must be in the evidence, and the fact must be about the speaker and asserted. A fact is stored when two sources agree or the verifier is confident; a fact only one weak source supports is held, never stored, never sent to a model, until it is said again within EXTRACTOR_V3_HOLD_TURNS messages; everything else is declined with its reason. The ingest response lists extraction.stored, pending, declined and removed. No confirmation is asked unless the request says review: true.

The same message (in its normal form) always gets the same reading: the result is cached under a SHA-256 of the message and the model, prompt, schema and configuration versions, which the audit row records. Production could switch to v3 only if the blind v3 measurement showed it more precise than the rules without more false acceptances. It did (2026-10-10, 500 messages in eight languages: precision 86.0% against 33.7%, false acceptance 3.5% against 7.7%, on the frozen version 3.2). Reading after the reply was built next (background ingests, and a model re-read when the model is busy), and statejar.com is now set to v3, in version 3.6: changed since on the development set only, and not scored on the blind set again. GET /api/v1/health names the extractor running (extractor).

GEMINI_BASE_URL

Environment variable GEMINI_BASE_URL. Default: https://generativelanguage.googleapis.com/v1beta

Environment variable: GEMINI_BASE_URL

Default: https://generativelanguage.googleapis.com/v1beta

Where Google Gemini requests are sent.

The default is the v1beta surface, which is where the current Gemini models live. Change it for Vertex AI or a proxy. Give the API root; StateJar appends its own paths.

See Google.

GEO_COUNTRY_HEADER

The request header in which the edge states a visitor's country. Empty (default) = a sign-in's country is recorded as unknown; no address is ever sent to a location service.

GEO_COUNTRY_HEADER=CF-IPCountry (default: empty, the country of every sign-in is recorded as unknown).

The admin Users page shows where a sign-in came from only as far as the edge in front of the API says so. StateJar's own Caddy edge sets no such header, and no address is ever sent to a geolocation service. If a CDN sits in front and sets a header with a two-letter country code, name it here and it is stored beside each sign-in (the sign-in history); the "new country" flag works only from then on. Set it only when the edge overwrites the header on every request, or a client could send any value. Anything that is not two letters is ignored.

GST_RATE_PERCENT

The total GST rate used for the tax lines once a GSTIN is set (default 18). Split into CGST and SGST halves within the supplier's state, IGST otherwise. The accountant confirms it.

GST_RATE_PERCENT. The total GST rate used for the tax lines once a GSTIN is set (default 18). Split into CGST and SGST halves within the supplier's state, IGST otherwise. The accountant confirms it.

It only changes how the receipt of an order is laid out; it does not change what a gateway is asked to charge. Prices are shown "+ GST as applicable".

GST_SAC_CODE

The SAC printed on the invoice. Empty (the default) prints "To be confirmed by the supplier's accountant"; the code is never guessed.

GST_SAC_CODE. The SAC printed on the invoice. Empty (the default) prints "To be confirmed by the supplier's accountant"; the code is never guessed.

It only changes how the receipt of an order is laid out; it does not change what a gateway is asked to charge. Prices are shown "+ GST as applicable".

GST_SUPPLIER_GSTIN

The supplier GSTIN. Empty (the default) means not registered: receipts say "GSTIN: not yet registered" and "GST not charged: supplier not registered", and no tax line is computed. Set only after GST registration; a value that is not a valid GSTIN shape is ignored.

GST_SUPPLIER_GSTIN. The supplier GSTIN. Empty (the default) means not registered: receipts say "GSTIN: not yet registered" and "GST not charged: supplier not registered", and no tax line is computed. Set only after GST registration; a value that is not a valid GSTIN shape is ignored.

It only changes how the receipt of an order is laid out; it does not change what a gateway is asked to charge. Prices are shown "+ GST as applicable".

GST_SUPPLIER_NAME

The seller name printed on a receipt and invoice. Default `StateJar`.

GST_SUPPLIER_NAME. The seller name printed on a receipt and invoice. Default StateJar.

It only changes how the receipt of an order is laid out; it does not change what a gateway is asked to charge. Prices are shown "+ GST as applicable".

INGEST_WAIT_S

Seconds a recall waits for the session's unfinished ingests; default 5.

INGEST_WAIT_S, default 5.

With memory updated right after the reply, a message's facts are written by a background ingest. The session's next query or chat waits for that ingest at most this long, so what was said in one turn is in memory for the next whenever the user takes longer than the extraction. If the wait runs out, the answer comes from the last finished version and says so: metadata.pending_ingest: true on a query, pending_ingest: true on a chat reply. Never silently.

JWT_SECRET

Environment variable JWT_SECRET. Default: change-me

Environment variable: JWT_SECRET

Default: change-me

The signing secret for session tokens. Must be changed before deployment.

If it is left at the default, anyone who knows the default can mint a valid token for any account. Use at least 32 bytes of random data; shorter keys produce a warning from the JWT library because HMAC-SHA256 wants 32.

Rotating it invalidates every issued token, logging everyone out. That is the correct response to a suspected leak.

MAIL_PER_ADDRESS_PER_HOUR

Messages to one address within an hour before the backend refuses to send another (429 mail_rate_limit).

MAIL_PER_ADDRESS_PER_HOUR, default 5.

Counted from the mail_log rows of the last hour for the address's hash (statuses sent and logged): at the limit the next request to mail that address is refused with HTTP 429 mail_rate_limit and a Retry-After, and a refused row is kept so the admin console shows it. Login codes and magic links are asked for a few times at most; the limit stops one address being flooded with codes, and keeps the server under the host's sending cap. The per-IP limit on the same routes is the API's usual rate limiter (MAIL_LIMIT, 5 a minute and 20 an hour per address of the caller), so neither one address nor one client can use the server as a cannon.

MAIL_SMTP_PORT

The port of the mail container the backend connects to, 25 inside the private network.

MAIL_SMTP_PORT, default 25.

The port on MAIL_SMTP_HOST. Postfix listens on 25 inside its container and that port is not published on the host, so 25 here is a private-network port, not an internet one. Used only when MAIL_MODE is smtp.

MAIL_SMTP_TIMEOUT_S

How many seconds one hand-over to the mail server may take before it counts as failed.

MAIL_SMTP_TIMEOUT_S, default 10.

The socket timeout for one SMTP hand-over (connect, greeting, the message, the server's 250). The mail server runs on the same host, so a slow answer means it is down or stuck, and the attempt is recorded as failed with the error's class rather than holding the user's request open. The user is told the message could not be sent; nothing is retried by the backend (the mail server's own queue retries what it has already accepted).

MCP_ALLOWED_HOSTS

The Host headers the MCP server answers; any other host gets 421 (DNS-rebinding protection).

MCP_ALLOWED_HOSTS, default the site and API host names of statejar.com and of the staging environment, localhost:* and 127.0.0.1:*.

The hosted MCP server answers only requests addressed to one of these hosts; any other gets 421 Misdirected Request before a tool runs. Browser origins are checked against CORS_ORIGINS. A JSON list, like CORS_ORIGINS.

MCP_ENABLED

Whether the API serves the hosted MCP server at /mcp (default true).

MCP_ENABLED, default true.

When true, the API serves the hosted MCP server at /mcp and its discovery document at /.well-known/oauth-protected-resource/mcp. When false, neither exists and every other route is unchanged.

MCP_ISSUER_URL

Where the MCP server's bearer tokens come from; they are developer keys made in the API keys page.

MCP_ISSUER_URL, default https://statejar.com.

The hosted MCP server takes a developer key (sj_live_…, made on the API keys page) as its bearer token; there is no OAuth flow. This URL is named as the authorization server in the server's discovery document so a client can tell where keys come from. deploy.sh writes it for each environment.

It is also this site's origin for Connect with OpenRouter: unless OPENROUTER_OAUTH_CALLBACK_URL is set, OpenRouter sends the browser back to <MCP_ISSUER_URL>/connect/openrouter.

MCP_PUBLIC_URL

The URL the MCP server names as its resource in its discovery document.

MCP_PUBLIC_URL, default https://statejar.com/mcp.

The address clients use for the hosted MCP server. It is the resource in the server's protected-resource metadata (/.well-known/oauth-protected-resource/mcp, RFC 9728). deploy.sh writes it for each environment: https://statejar.com/mcp on production, and the staging site's own /mcp on staging.

MCP_RATE_PER_MIN

MCP requests one developer key may make per minute in one API process (default 240); then 429.

MCP_RATE_PER_MIN, default 240.

Each request to the hosted MCP server counts against its developer key for the current minute; over the limit the server answers 429 with Retry-After, and no tool runs. The writes a tool makes (remember, update, delete) and every search also count against the plan's own operation and per-minute limits, as REST calls do.

ML_MODEL_DOWNLOADS

Environment variable ML_MODEL_DOWNLOADS. Default: False

Environment variable: ML_MODEL_DOWNLOADS

Default: False

Whether the optional ML tiers may download their model from huggingface.co.

It only matters when the extras in backend/requirements-ml.txt are installed. Two tiers use a model: GLiNER2 extraction (urchade/gliner_multi-v2.1, when EXTRACTOR_MODE is auto or gliner) and the semantic fallback of RETRIEVER_SEMANTIC.

ValueBehaviour
falseload from the local HuggingFace cache only. A model that is not cached is logged once and that tier is skipped, extraction continues on the rule tier, retrieval on the keyword map.
truea missing model is downloaded the first time it is needed, during that request.

Why the default is off. Before this setting existed, the first message after start-up downloaded the model at request time, from whatever network the server happened to be on, and the request waited for it. A server should not reach out to the internet because someone typed a message.

To use GLiNER2 without enabling downloads, fetch the model once into the cache (for example with hf download urchade/gliner_multi-v2.1) and leave this off.

Handles are unaffected by the setting itself, but a server that does run GLiNER2 can extract more than a rules-only one, so its handles can differ from a rules-only machine's, see EXTRACTOR_MODE.

MODEL_WATCH_ENABLED

Whether each backend process checks every minute that extraction's model is loaded, and loads it when not.

MODEL_WATCH_ENABLED, default true.

With extraction v3 on, each backend process asks the model server every minute whether the model is loaded. A model that is present but not loaded (after the model server restarted) is warmed by one process at a time; a model server that holds another model is left alone. What it saw is what the extraction health check answers from. Tests turn it off; there is no reason to turn it off on a server.

OAUTH_ACCESS_TOKEN_MINUTES

How long an OAuth access token for the MCP server lives (default 60 minutes).

OAUTH_ACCESS_TOKEN_MINUTES, default 60.

The lifetime of an access token the token endpoint issues for the MCP server. The MCP spec's security notes say authorization servers "SHOULD issue short-lived access tokens"; an hour means a stolen token is useful for at most an hour, and an app with a refresh token never notices. Lower it on a server where every MCP call is a sensitive one; there is no upper bound in the code, but a day defeats the point.

OAUTH_REFRESH_TOKEN_DAYS

How long an OAuth refresh token lives before the app must sign in again (default 30 days); each one works once.

OAUTH_REFRESH_TOKEN_DAYS, default 30.

The lifetime of a refresh token from the token endpoint. A refresh token is used once: each use hands the app a new one with the same lifetime, so an app in daily use stays connected until you disconnect it on the API Keys page, while one left unused for this many days signs in again. A refresh token presented twice revokes its whole family, whatever this setting says.

OLLAMA_BASE_URL

Environment variable OLLAMA_BASE_URL. Default: http://localhost:11434

Environment variable: OLLAMA_BASE_URL

Default: http://localhost:11434

The default address offered for a local Ollama daemon.

This is a default for the form, not a route the server uses: for a local daemon the browser talks to Ollama directly, because a hosted backend cannot reach an address on your machine. See Ollama local.

A base carrying a trailing /api or /v1 is normalised back to the host, so pasting a URL straight from Ollama's docs works.

OPENAI_BASE_URL

Environment variable OPENAI_BASE_URL. Default: https://api.openai.com/v1

Environment variable: OPENAI_BASE_URL

Default: https://api.openai.com/v1

Where OpenAI requests are sent.

Change it for Azure OpenAI, a self-hosted gateway, or any OpenAI-compatible endpoint. See OpenAI.

OPENROUTER_BASE_URL

Environment variable OPENROUTER_BASE_URL. Default: https://openrouter.ai/api/v1

Environment variable: OPENROUTER_BASE_URL

Default: https://openrouter.ai/api/v1

Where OpenRouter requests are sent.

Change it to route through a proxy or a compatible gateway. See OpenRouter.

OPENROUTER_OAUTH_CALLBACK_URL

The page OpenRouter sends the browser back to after Connect with OpenRouter. Default empty, meaning MCP_ISSUER_URL + /connect/openrouter.

Environment variable: OPENROUTER_OAUTH_CALLBACK_URL

Default: empty, meaning MCP_ISSUER_URL + /connect/openrouter. deploy.sh writes MCP_ISSUER_URL for each environment, so this is https://statejar.com/connect/openrouter in production and the staging site's /connect/openrouter on staging.

This is where Connect with OpenRouter asks OpenRouter to send the browser back. It is this site's own page, fixed by the setting and never taken from a request.

It must be an https address, or http on localhost or 127.0.0.1 for local development. OpenRouter accepts localhost callbacks on any port; with the Vite dev server that is http://localhost:5173/connect/openrouter. Any other value turns the button off: the start answers 503.

PAYMENTS_ENABLED_PROVIDERS

Which payment providers take payments, comma-separated; empty (the default, and every environment today) means none, so no plan can be bought.

PAYMENTS_ENABLED_PROVIDERS, default empty.

The switch for billing. Empty: no plan can be bought, the order endpoint answers 503 payments_disabled, and the pricing and Billing pages say billing is coming soon. binance turns Binance Pay on; a name the code does not integrate changes nothing.

A provider named here takes payments only when its configuration fits PAYMENTS_MODE: in test mode a stand-in on the server's own machine, in live mode Binance Pay's own host over HTTPS, and in both its key and secret set (BINANCE_PAY_API_KEY, BINANCE_PAY_SECRET_KEY). Otherwise it is left out and the server logs why. Real payments wait for the owner's explicit go-ahead (docs/payments.md).

PAYMENTS_MODE

test (the default) runs payments against a stand-in on the server's own machine, with no money moved; live talks to Binance Pay's own host and takes real payments.

PAYMENTS_MODE, default test.

Binance Pay documents no test environment, so test mode is a stand-in merchant server on the same machine as the API (tests/site/payments): the provider's base URL (BINANCE_PAY_BASE_URL) must then be a loopback address (127.0.0.1, localhost), Binance's own host is refused, every order and receipt is marked as a test, and no money moves. Live mode (live) talks to Binance Pay's documented host and to nothing else, needs HTTPS for the return page and the webhook (BILLING_PUBLIC_URL, BILLING_WEBHOOK_BASE), and takes real payments: it is set only on the owner's explicit go-ahead. Any other value turns every provider off.

The mode decides how a provider may be reached; whether it takes payments at all is PAYMENTS_ENABLED_PROVIDERS. Billing config reports the mode.

PUBLIC_API_URL

The public origin of this API, for the OAuth callback URL shown in the admin panel.

PUBLIC_API_URL=https://statejar.com (default: empty, the origin of each request is used, which is right behind Caddy and in local development).

The Google and GitHub sign-in flows return to <PUBLIC_API_URL>/api/v1/auth/oauth/<provider>/callback; the Login methods page shows that exact URL to register with the provider, and the server sends the same value as redirect_uri, which the providers compare character by character. Set it when the API is reached through a host name the request does not show, or to pin one of several.

PUBLIC_SITE_URL

The website a link in an e-mail opens (never the API host).

PUBLIC_SITE_URL=https://statejar.com (staging: the staging site's address). The deploy writes it from the environment's deploy configuration.

Every link in a mail StateJar sends (an invitation, a ticket reply, a receipt, the notice about a changed address) starts with this origin. It is the website, not the API: the request that causes a mail reaches the API host, and a link built from it would open the API instead of the page. Empty means the website that pairs with the API host (CORS_ORIGINS), so a local development server still links to its own console.

RATE_LIMIT_ENABLED

Environment variable RATE_LIMIT_ENABLED. Default: True

Environment variable: RATE_LIMIT_ENABLED

Default: True

Whether per-user request limits are enforced.

Leave it on. Turning it off removes the protection against a runaway client burning through your provider credit, and StateJar's chat endpoint spends real money per call.

Set it to false only for local load testing, and only locally. See rate limit errors.

RETENTION_JOB_ENABLED

Whether the API process runs the daily history-retention job (default true); off only in tests or when another process runs it.

RETENTION_JOB_ENABLED, default true.

When true, the API process starts app/retention.py's loop with the application: the first run one minute after start, then once every 24 hours. Each run deletes memory versions and audit rows older than each account's plan window, never a session's current head, and logs its counts (an admin can also run it at once from the admin console).

Set it to false to run the job elsewhere (for example from one process when several API processes serve the same database) or in a test environment. With it off nothing is deleted on a schedule; the admin console's run-now action still works.

RETRIEVER_FULL_STATE_TOKENS

Environment variable RETRIEVER_FULL_STATE_TOKENS. Default: 400. Up to this size a question is given your memory whole; a statement or a task only the fields it bears on.

Environment variable: RETRIEVER_FULL_STATE_TOKENS

Default: 400

What the reply model is given depends on the message:

  • A task ("book my delivery", "write 1,000 facts about space", "ek shayari sunao") is given only the fields it bears on, at any size, and nothing when no stored fact does. Those are the fields the intent map finds, the fields it names, the topics it asks about when it speaks of you, and the fields whose stored value it mentions.
  • A statement ("Truck count is 3", "Prefer WhatsApp for updates") is given the fields it names, the topics it speaks of and the fields whose value it mentions, at any size (since 3 Nov 2026).
  • A question is given the whole state while the state is at most this many tokens (about four characters a token), and only the fields it needs once the state is larger.
  • Small talk ("hey, how are you?") is given nothing.

Whatever is sent stays within CONTEXT_BUDGET_TOKENS (default 200 tokens).

Questions keep a small state whole because narrowing them by their words lost answers. A question without a pronoun ("budget kitna hai?") cannot be told from a general one by its words. Measured on memory-QA: five narrowing rules answered 192 to 237 of 300 questions, against 248 with the whole small state. Lowering this sends questions less and answers fewer of them. Raising it sends more per question. 0 turns the whole-state rule off.

RETRIEVER_SEMANTIC

Environment variable RETRIEVER_SEMANTIC. Default: False

Environment variable: RETRIEVER_SEMANTIC

Default: False

Whether retrieval may use a semantic fallback when keyword selection finds nothing.

Read this before enabling it. The guarantee it is written against is that handles are byte-identical with semantic retrieval on or off: retrieval is a read path, and the write path, extraction, canonicalization, hashing, is untouched by it. A test asserts exactly that.

So this can change what a model is told for a given question. It can never change what is stored or what a state hashes to.

SANDBOX_ACCOUNT_EMAIL

The address the sandbox's visitor accounts are named after (default sandbox@statejar.com); signup refuses it and every sandbox+ form of it.

SANDBOX_ACCOUNT_EMAIL, default sandbox@statejar.com.

Each sandbox session gets an account named sandbox+<hash of the session id>@statejar.com (the local part and domain come from this setting). Every such account is disabled, so it never signs in. Signup refuses this address and all its + forms, so nobody can take one first.

The daily wipe deletes the accounts by this name. Change it only when no sandbox account exists.

SANDBOX_DAILY_CAP

Sandbox actions a day for all visitors together (default 500): new sessions, messages and questions; one more answers 429 until 00:00 UTC.

SANDBOX_DAILY_CAP, default 500.

It is how many sandbox actions all visitors together may take in a UTC day: a new session, a message, or a question. The count is a row per day in sandbox_days, with no address and no text. When it is spent, every sandbox call answers 429 (sandbox_daily_cap) until 00:00 UTC.

It keeps the sandbox from taking the model server's time from accounts. Each visitor's account is also on the Free plan, whose per-minute and monthly limits apply as they do to any account.

SANDBOX_ENABLED

Whether the sandbox at /try is open (default true); false answers 404 to every sandbox call.

SANDBOX_ENABLED, default true.

When it is true, the sandbox is open: visitors without an account can start a session, send messages and ask questions. The API process also deletes every sandbox account, and everything in them, at each 00:00 UTC.

When it is false, every /api/v1/sandbox/… call answers 404, and nothing is deleted on a schedule.

SANDBOX_MAX_CHARS

The longest message the sandbox takes, in characters (default 500); a longer one answers 422.

SANDBOX_MAX_CHARS, default 500.

A sandbox message over this many characters is refused with 422 (message_too_long). Nothing of it is read. An account's own messages can be far longer: see ingest.

SANDBOX_PER_IP_PER_HOUR

Sandbox requests an hour per client address (default 30); one more answers 429.

SANDBOX_PER_IP_PER_HOUR, default 30.

It is the number of sandbox calls one client address may make in an hour: new sessions, messages and questions. Reading a message's job may be made twenty times as often. The client address is the one the edge proxy passes on. The count is kept in each API process's memory, as the other per-address limits are.

SHOW_OLLAMA

Environment variable SHOW_OLLAMA. Default: False

Environment variable: SHOW_OLLAMA

Default: False

Whether the Ollama provider cards appear in the console. They never appear on statejar.com.

Off by default on hosted deployments, where most users have no local daemon and two extra cards are noise. Turn it on for a local install, a workshop, or any deployment whose users run models themselves.

It controls visibility only. It does not disable the provider.

The demo

The 17-turn demo: the scenario

A Wayanad relief operation, built one fact at a time over seventeen turns.

A coordinator running a disaster-relief operation in Wayanad district feeds details in as they arrive: displaced families, truck capacity, convoy dates, a coordinator's contact, a sanctioned budget, kit specifications, supplies, staffing, freight costs.

Seventeen turns in, they ask a question that needs several of those facts at once:

How many family kits can we cover with the current budget?

The answer is computed from the retrieved state alone. The seventeen messages that built it are never replayed.

Why this scenario. It is a setting where a silently misremembered number does real harm, and where the facts are ordinary enough that no domain knowledge could have been built in. There is no relief-operation schema anywhere in StateJar.

A note on phrasing. Several turns are worded more like a form than a sentence. The extractor is frozen, so where a natural phrasing did not survive it the turn was rewritten rather than the pipeline. Those replacements have not yet been re-measured with tier 3 live.

Run it from the Playground: ▶ Run 17-turn demo. No provider key needed.

The 17-turn demo: what each marked turn shows

Three turns are marked because they demonstrate the behaviours people doubt.

Three of the seventeen turns carry a marker. They are the ones worth watching.

update, turn 8. "Update: truck count is now 5." The truck count was 3. It becomes 5, the 3 moves to history, and the handle changes because the state changed. Watch the Memory State panel: there is one truck count, not two. See supersession.

retracted, turn 11. "Actually cancel the purification tablets, state supply already covered it." The tablets field is not overwritten with zero and not left stale, it is removed. The active state no longer has it. See retraction.

corrected, turn 13. "Correction: budget is now ₹22 lakh." The budget was ₹18 lakh. This matters for the payoff: the final answer uses ₹22 lakh, and that is only right because the ₹18 lakh was superseded rather than kept alongside it.

The payoff, turn 17. The kit count is computed from the corrected budget and the kit price, two facts stated eleven turns apart, with the retracted item correctly absent.

The 17-turn demo: what to watch

Four things on the right-hand side, and what each proves.

While it runs, the right-hand panels are doing the actual argument.

  1. Memory State. Field count grows, and on turns 8, 11 and 13 it changes rather than grows. Nothing is ever duplicated.
  2. The handle. It changes on every turn that changed the state, and only then. A repeated fact does not move it, see reinforcement.
  3. The sparkline and the savings line. Tokens sent per turn against a full-transcript replay baseline. Early turns can show more than replay, and the demo says so on screen instead of hiding them, with three short messages there is nothing to save. The gap opens as the transcript grows. See token savings.
  4. The end card. The final handle. Copy it and restore it in a fresh session to prove the whole seventeen-turn memory travels as one string.

Controls. Play, Pause, Resume, Restart, Skip to end. Skip waits for the in-flight turn rather than abandoning it.

Troubleshooting

A value stored under an unexpected field

The value is there, but under a field name you did not expect.

Symptom. The value is there, but under a field name you did not expect.

Likely cause. Either it resolved through an alias to a canonical path, which is correct, and is what stops the same number being stored three times under three phrasings, or it fell through to a default destination.

A known case: a money value in a clause that names no recognised money concept currently defaults to constraints.budget.max. The vocabulary is literal, not stemmed, so cost is recognised and costs is not. See docs/known-issues.md.

Fix. If it is an alias resolution, nothing is wrong, check the value is right. If it is the money default, restate using a word the vocabulary knows (budget, cost, price, spend) or one that clearly names a different concept (invoice, rent, freight), which keeps it in its own field.

A value you stated was not stored

You said something with a number in it and the Memory State panel does not show it.

Symptom. You said something with a number in it and the Memory State panel does not show it.

Likely cause. Three possibilities, and the panel tells you which:

  1. It was declined. Look at the declined section, there will be a row with a reason.
  2. It was unresolved, you named a field without giving a value, or hedged. See not provided and user unsure.
  3. It was not an assertion, a question or a negation. See that reason.

If none of those appear, no tier recognised anything to extract.

Fix. Read the decline reason first; it names the fix. If nothing was extracted at all, restate it as a plain assertion with the value attached to its concept , family kit weight is 12 kg rather than the kits, they're about 12 kilos I think. If the concept is genuinely novel, it should land as a dynamic field; if it does not, that is worth reporting.

Amber 'unavailable' chip

A tier chip is amber and reads *unavailable*.

Symptom. A tier chip is amber and reads unavailable.

Likely cause. That tier was attempted and did not answer, which is deliberately distinguished from a tier that was skipped. Something went wrong reaching it: a missing or invalid provider key, a rate limit, a timeout (tier 3 gives up after 8 seconds), or every model in the fallback chain failing in turn.

Fix. Open the API Keys screen and press Test connection on the provider tier 3 is configured to use, that reports the real cause in plain language. If the key is fine, the likeliest remaining causes are a rate limit or a slow provider; the timeout is short on purpose so extraction degrades rather than stalling a turn.

Your state is still valid either way. The deterministic tiers ran, and everything they produced went through the same guards.

Chat history missing on another device

You sign in elsewhere and the memory is there but the conversation is gone.

Symptom. You sign in elsewhere and the memory is there but the conversation is gone.

Likely cause. Working as designed. StateJar never stores raw transcripts, the storage layer refuses them at write time, so the words live only in the browser that typed them. Only state travels.

Clearing site data, or logging out, also clears them. Logging out does so deliberately: a shared machine must not hand the next person the last person's conversation.

Fix. Nothing to fix. If you need the provenance of past turns, which handle was current, what was disclosed, that is available from the server: GET /sessions/{id}/turns. To move the memory itself, copy a handle and restore it. See the FAQ.

Handle restore returns different state

You restored a handle and the state is not what you expected.

Symptom. You restored a handle and the state is not what you expected.

Likely cause. Almost always one of three things:

  • The handle is from a different session or account. Handles are scoped to your account; one from elsewhere will not resolve.
  • You copied a truncated handle. The display shortens it in some places. Use the copy button, which copies the full value.
  • You are looking at the right state and misremembering the wrong one. The Handles tab lists every version, inspect a few and find the one you meant.

What it is not: silent corruption. Restore re-derives the handle from the stored state and refuses if they disagree, so a restore that succeeded returned exactly the bytes that hashed to that handle.

Fix. Copy the handle again with the copy button, and check the Handles tab for the version you actually want. See restore.

No LLM chip appears

You expected [tier 3](#tier.llm) to run and there is no `llm` chip, only `rules`.

Symptom. You expected tier 3 to run and there is no llm chip, only rules.

Likely cause. A skipped tier shows no chip at all. Tier 3 is gated behind several conditions and all of them must hold: EXTRACTOR_LLM_FALLBACK must be true, a provider key must be saved for the account making the call, and the message must actually look like it needs tier 3, at least 12 words with at most 2 fields found by the cheap tiers, or long and sparse, or over five words with nothing extracted at all.

The most common cause by far is the second one: a fresh account with no provider key. The second most common is the third: the message was short and clean, so the cheap tiers answered it completely and tier 3 was correctly not needed.

On statejar.com this tier is off (see Tier 3): messages are read by extraction v3 and the cloud providers, so there is no llm chip to expect. The rest of this entry is for a deployment or a development run that turns the tier on.

Fix. Check EXTRACTOR_LLM_FALLBACK=true on the deployment, save a provider key for the account you are testing with, and try a genuinely messy message, a rambling sentence with a value buried in a subordinate clause. If the tier runs and fails you will get an amber chip, which is a different and more informative outcome than no chip.

Ollama: blocked by origin policy

Ollama is running and `curl` works, but the browser cannot reach it.

Symptom. Ollama is running and curl works, but the browser cannot reach it.

Likely cause. Ollama refuses cross-origin browser requests unless it is started with OLLAMA_ORIGINS. curl is not a browser and is not subject to this, which is why the daemon looks healthy from the terminal and unreachable from the page.

The browser hides CORS detail from scripts for security, so the failure arrives as an opaque error rather than a useful one. StateJar distinguishes it from a dead daemon by checking whether anything is listening at all.

Fix. Restart Ollama with the origin you are loading StateJar from:

# macOS / Linux
OLLAMA_ORIGINS=https://statejar.com ollama serve

# Windows (cmd)
set OLLAMA_ORIGINS=https://statejar.com
ollama serve

# PowerShell
$env:OLLAMA_ORIGINS="https://statejar.com"; ollama serve

Use http://localhost:5173 instead when running the dev server. The card generates the exact command for your current origin with a copy button, use that rather than retyping. See Ollama local.

Ollama: connection refused

The local Ollama card reports the daemon is not reachable.

Symptom. The local Ollama card reports the daemon is not reachable.

Likely cause. Nothing is listening at the address. Usually Ollama is not running, or it is running on a different port than the one configured.

Fix. Start it:

ollama serve

Then confirm it answers:

curl http://localhost:11434/api/tags

If that returns a model list, the daemon is fine and the problem is the base URL in the card. If it returns nothing, Ollama is not running. If it returns models but the card still fails, you have the origin policy problem instead , see blocked by origin policy.

Rate limit errors

A request fails with a rate-limit error.

Symptom. A request fails with a rate-limit error.

Likely cause. Either StateJar's own per-user limit, your plan's monthly limit, or your model provider's.

The answer names which: plan_add_limit (this month's adds, the messages a model read, are used up; a message the rules handle alone is still free), plan_recall_limit (this month's recalls: query, chat, restore), fair_use_throttle (a very high rate of adds in an hour or a day; wait the seconds in Retry-After) or plan_rate_limit (this minute's requests on a key). A monthly limit resets on the first of the next month (UTC); a top-up of 1,000 adds raises the adds.

StateJar's limit exists because the chat endpoint spends real money per call and a runaway client would otherwise burn through your provider credit. It is controlled by RATE_LIMIT_ENABLED.

A provider's limit is theirs, and the message will name the provider.

Fix. Slow down and retry. For a provider limit, configure a fallback chain so extraction moves to another model instead of failing. Do not disable StateJar's limit on a deployment that faces the internet, the protection is the point.

Sign-in lockout

Ten wrong passwords or codes for one email address in 15 minutes lock sign-in for that address for 15 minutes.

The message. Too many attempts; try again in 15 minutes (HTTP 429).

Why. Ten failed passwords or two-factor codes for one email address within 15 minutes lock sign-in for that address for 15 minutes. It stops someone guessing a password or a 6-digit code. The answer is the same whether or not the address has an account, so the lockout cannot be used to find out who is registered.

Also per network. Sign-in is limited to 5 attempts a minute and 30 an hour from one IP address, and two-factor requests to 10 a minute and 60 an hour.

What to do. Wait 15 minutes, then sign in once, carefully. If you no longer have your authenticator app, use a recovery code. Failed attempts are kept only as a hash of the address and a count, and are deleted after the 15 minutes.

FAQ

Does StateJar save tokens?

Only on long chats. On a short chat the memory block costs more than the history it replaces; the measured totals are 17 turns -72.8% and 40 turns +3.7%.

Plainly: short chats cost more. StateJar's memory block is 110 to 290 tokens in the demos, so a conversation has to grow past that before it pays off. Measured on 2026-10-31 (backend/benchmarks/window_results.md; tiktoken, input tokens only, user turns only, last 1 turn plus StateJar against the full history, whole conversation):

  • 17-turn demo: 2,169 tokens of history against 3,749 with StateJar, -72.8% (a loss).
  • 40-turn demo: 9,874 against 9,506, +3.7% (a small gain). With the last 3 turns kept, the 40-turn demo is -5.3% in total and breaks even at turn 26.

The real benefit is not the token count: it is that the model is shown the current fact, not a stale one, with a handle and an audit trail for what it was shown. Count your own chat with the Save tokens guide. Full table and the three different baselines: Why is my token savings negative?, token savings and its baseline.

How do I get help from the team?

Search this page first; if the answer is not here, raise a ticket from My tickets. Replies arrive in the ticket and by e-mail.

  1. Search this page (press /, then type a word such as "declined" or "tokens"). The FAQ at the top covers the common questions.
  2. Raise a ticket. Sign in and open My tickets (sidebar), then New ticket: a subject, a category, a priority and a description. You can add a session tag or a handle so we look at the right memory, and up to 3 attachments (png, jpg, pdf or txt; 5 MB each). Never paste a password, API key or token into a ticket.
  3. Read the answer in the same thread, and by e-mail. Close the ticket when it is solved.

Not signed in or cannot sign in? Write to the address on the contact page. More: My tickets page, support tickets API.

How is this different from ChatGPT memory?

How is this different from built-in model memory?

Three differences that matter, and one honest point in their favour.

Portability. Built-in memory belongs to that vendor's product. StateJar state is addressed by a handle that resolves the same anywhere, a different session, a different model, a different vendor.

Inspectability. You can see every field StateJar holds, when it changed, what it changed from, and exactly what was sent to the model on any given turn.

Declining. StateJar tells you when it did not store something, and why. This is the part that has no equivalent: a memory feature that silently remembers a number wrongly is indistinguishable, to the user, from one that remembered it correctly.

In their favour: built-in memory requires no integration at all, and it captures nuance, tone, preferences implied rather than stated, that a type-checked field cannot represent. If you want a model that remembers you found something funny, that is not what StateJar is.

How is this different from RAG?

How is this different from RAG?

They solve different problems and compose fine.

RAG retrieves documents. You have a corpus, you embed it, and at query time you fetch passages that look similar to the question. It is excellent at "what does the manual say about X".

StateJar tracks state. There is no corpus. There are fields with current values, and the question is not "what is similar" but "what is true now".

The concrete difference is contradiction. If a document says the budget is 5000 and a later one says 8000, a similarity search may return both and the model picks. In StateJar the field holds 8000 and 5000 is in history, so both cannot be returned. See active state vs history.

The other difference is that similarity has no notion of type. This is the failure StateJar exists to prevent, see value type.

Use both when you have documents and facts that change.

Is my data used for training?

Not by StateJar. But your messages are sent to cloud model providers to be read, and each provider's own terms apply to what they receive.

Not by StateJar. We do not train any model on your state, your messages or your audit trail. StateJar runs no model of its own on its servers (since 2026-10-30), so there is nothing here that could learn from them.

Your messages do leave our server to be read. To extract facts, and to answer in the Playground with the default model, StateJar sends the message (and, for a reply, the memory it needs) to a cloud model provider we have enabled, with our own keys: paid Gemini first, then Mistral, then OpenRouter. No tier that trains on inputs (the free Gemini tier, Mistral's free Experiment plan) is used for your messages. What each of them may do with what it receives is decided by that provider's terms, which /privacy lists provider by provider, including whether it may train on paid-API traffic. The audit log names the provider and model that read each message.

A model you choose yourself is a separate question. If you reply with your own provider key, that provider's policy applies to the question plus the retrieved subset StateJar sends it; check the policy for your plan. The one setup where nothing reaches any vendor is a model on your own machine (Ollama local) used for replies; extraction by StateJar's server still goes to the providers above.

What do the plans cost, and can I pay yet?

Launch pricing: Free, Starter, Pro and Business; billing is coming soon, so every account is on Free today and the Free limits are enforced.

You cannot buy a plan yet. Billing is coming soon: every account is on the Free plan and no payment is taken today. The limits are real, though; the server enforces them now (why you may see a 429).

Launch pricing, per month, + GST as applicable:

PlanPrice (INR / USD a month)Adds a monthRecalls a monthHistory keptDeveloper keysRequests a minute per key
Free₹0 / $03,00020,00030 days230
Starter₹1,299 / $1525,000200,000180 days5120
Pro₹16,999 / $199300,0002,000,000 (fair use)1 year20600
Businesscustomcustomcustomcustomcustomcustom

An add is a message a model read to extract facts; a message the rules handle alone, with no model call, is free and unlimited. A recall is a query, a chat or a restore. A top-up is 1,000 extra adds for $1 / ₹85 (Starter and Pro). A year costs ten months' price. Business is for audit exports, an SLA and data in India. Each plan also caps what the server's own cloud models may spend reading one account's messages in a month (Free US$2, Starter $15, Pro $199, Business none; our estimate from the tokens counted, not a bill). Past it your new messages are still accepted and read at once by our rules; the model's reading waits, kept encrypted, until the next month or until the cap is raised, and nobody else's messages are affected. A message read with your own saved provider key never counts.

A plan pays for memory, retrieval and audit, never for model tokens: a model you chose runs on your own provider key. The live list is on /pricing (Pricing page); what each limit counts is in pricing and payments. Where your plan and orders show once billing opens: Billing page; refunds: refund policy.

What happens if StateJar is down?

What happens if StateJar is down?

Your model calls stop working if you use the proxy pattern, because the call goes through us. With the sidecar pattern your model call is your own, you lose memory retrieval, not the conversation.

Your state is not lost. It is rows in a database, and every version is addressable by its handle. Nothing about the format depends on us being reachable: a handle is a hash of content, so state exported today still verifies later.

This is a reason to prefer the sidecar for anything that must keep working.

If our model providers are down. Extraction and the Playground's default model use cloud providers. When none answers, a message is read by the rules alone, its weaker facts are held and read again once a provider is healthy, and a chat reply answers HTTP 503 "busy, retry in a moment". Nothing is lost or made up. A model you chose yourself, with your own key, is unaffected.

Where is my chat history stored?

Where is my chat history stored, and why not on the server?

In your browser, and only there.

StateJar does not store raw transcripts. This is not an oversight to be fixed with a messages table, it is the design the product is built on, and the storage layer refuses transcripts at write time.

So:

  • History is per browser. The same account on another device shows the same memory and an empty conversation.
  • Clearing site data clears it. Your state survives.
  • Logging out wipes it, deliberately, so a shared machine does not hand it to the next person.

The server keeps the other half of a session's history, provenance, not words. GET /sessions/{id}/turns returns the handle current on each turn, its parent, the state version, and what was disclosed from it. It says contains_message_text: false in its own response.

Your client holds the words. The server holds the provenance. Joining them is the caller's job, and that separation is the point.

Which model reads my messages?

StateJar runs no model of its own. Extraction and the default Playground reply go to cloud providers, paid Gemini first, then Mistral, then OpenRouter; your own key goes to your provider.

A cloud provider, never a model on our server. Since 2026-10-30 StateJar runs no local model on staging or production (no Ollama, no built-in model). Facts are read from your messages by extraction v3, which asks a cloud model to propose them; the rules, the span model (GLiNER2) and the deterministic gates then decide what is stored.

  • Order. Today: paid Gemini first, then Mistral, then OpenRouter, with StateJar's own keys. If one fails or is rate-limited, the next is tried. Which providers are on is an administrator's setting (BACKUP_PROVIDERS); /privacy lists each provider with its terms, and the audit log names the provider and model that read each message.
  • When every provider fails. The rules read the message alone, weaker facts are held and read again when a provider is healthy, and a chat reply answers 503 "busy, retry". Nothing is lost.
  • Replies. The Playground's default model is answered by the same providers. A model you pick with your own key goes to that provider instead, billed to you.
  • What is sent. The message, and for a reply the memory it needs. Never your whole memory, and no transcript is stored by StateJar (where chat history lives).

Training: see Is my data used for training?.

Why did it not remember something I said?

Why did it not remember something I said?

Because StateJar refuses to guess, and refusing sometimes means storing nothing.

Check the declined section first, most of the time there is a row there with a reason. Walk through a value you stated was not stored, which covers all three cases and what to do about each.

The trade being made is explicit: a system that stores everything it half- understood will remember more and be wrong more, and it will not tell you which. In a benefits application or a clinical intake that is the worse failure. If it is the wrong trade for what you are building, that is a fair reason not to use StateJar.

Why is my token savings negative?

On a short chat StateJar sends more tokens than the plain history; it only comes out ahead as a chat grows. The measured figures, with what was counted.

Short chats cost more with StateJar, not less. The memory block StateJar sends is 110 to 290 tokens in the demos, and a short history is cheaper than that. Memory pays off when a conversation is long enough that the history it replaces is bigger than the block.

Measured on 31 October 2026 (backend/benchmarks/window_demo.py, results in window_results.md; tiktoken cl100k_base, input tokens only, user turns only, no system prompt on either side; negative means StateJar sent more):

Whole conversation, total input tokensFull historyLast 1 + StateJarLast 3 + StateJarLast 5 + StateJar
17-turn demo2,1693,749 (-72.8%)4,165 (-92.0%)4,541 (-109.4%)
40-turn demo9,8749,506 (+3.7%)10,401 (-5.3%)11,247 (-13.9%)

So the 17-turn demo is a loss at every window, and the 40-turn demo is a small gain only when you send just the last message plus the memory. Per turn it crosses over late: with the last 3 turns, the 40-turn demo reaches break-even at turn 26.

Two other figures can look better and mean something else. The savings badge and the Dashboard card measure against the disclosable state and are clamped at zero; they say how selective the retrieval was, not whether you are ahead of a plain history. See token savings and its baseline, and the Save tokens guide to count your own chat with POST /memory/token-compare.

Reference

Changelog

User-visible changes, newest first, grouped by release.

Every user-visible change. Internal refactors are not listed.

2026-08-04, About and Help

Added

  • About page at /about, the problem, the three guarantees, the pipeline, why there is no domain code, and an explicit list of what does not work yet.
  • Help centre at /help, every concept, control, chip, provider, decline reason and value type, with in-page search and a stable anchor per entry.
  • Documentation coverage is now enforced by a test. A new endpoint, setting, provider, tier, decline reason or value type fails the build until it has a help entry.

2026-08-04, Chat history that survives a session switch

Added

  • Switching sessions and switching back now restores the transcript, the tier chips, the pipeline strip and the last retrieval, everything belonging to a turn swaps together.
  • A reload returns to the session you were in, not the first in the list.
  • GET /sessions/{id}/turns, per-turn provenance: handle, parent, state version, disclosures. Deliberately no message text.

Changed

  • Logging out now clears the chat transcripts stored in your browser. They are held client-side, and a shared machine must not hand them to the next person. See where chat history is stored.

Known issues

  • A money value in a clause that names no recognised money concept defaults to constraints.budget.max. The vocabulary is literal, so cost is recognised and costs is not. See the troubleshooting entry.

2026-08-03, Ollama split, and a longer demo

Added

  • Two Ollama cards instead of one: local, which is browser-direct and needs no key, and remote, which goes through our backend and requires one. Collapsing them into a single card had made the privacy difference invisible.
  • 17-turn relief demo with autoplay, a token sparkline, marked turns and an end card. See the demo scenario.

Fixed

  • Base URLs pasted from Ollama's own documentation (carrying a trailing /api or /v1) now normalise to the host instead of producing /api/api/tags.
  • A 200 response carrying {"error": "Unauthorized"} is treated as the failure it is rather than as success.

2026-08-02, Demo blockers

Fixed

  • The Memory State panel renders every namespace the API returns, so dynamic fields are visible instead of silently absent.
  • Provider errors no longer leak raw payloads; each failure gets a readable, key-free message.
  • An update now produces an acknowledgement that names what changed, rather than reporting a no-op, the model was only ever shown post-update state.
  • Restore a handle from the console.

2026-08-01, Open the extractor

Added

  • Dynamic fields: a concept no registry entry claims becomes a first-class field instead of being quarantined. A kiln schedule or a load limit is remembered without anyone having written a rule for it.
  • Retraction, taking a value back leaves the field genuinely empty.
  • Qualifier-aware updates, so "push the end date" finds the end date.

Changed

  • _unmapped now means "failed a guard", never "unfamiliar". See decline.

2026-07-31, Fail closed

Added

  • Type-compatibility guard: a value must pass a type check before it enters a field. 24 tonnes can no longer be stored as twenty-four rupees.
  • Grouped-number parsing (1,240, 1 240) and rejection of degenerate values.

Changed

  • Money now requires a positive signal, a symbol, a currency word, or a money noun. There is no default currency, because defaulting to one is how a bare number becomes a budget.

2026-07-30, Canonical schema registry

Added

  • One canonical path per concept, with aliases resolving onto it, enforced at the storage boundary. See canonical path.
  • Normalization to base units, so 1,240 kg and 1.24 tonnes produce the same bytes and therefore the same handle.

Conflict: user rejected X in favour of Y

You explicitly swapped one option for another, and both are recorded.

What it means. You did not merely change a value, you rejected one option and chose another. Call me instead of emailing.

Not a decline. The chosen value is stored. This is a conflict record explaining how the field got its value, written as user rejected email in favour of phone.

Why record it. An explicit rejection carries information a plain update does not: it says the other option was considered and refused, which is worth knowing before suggesting it again.

Where you see it. The conflicts list in Memory State.

Declined: funds on hand

You said how much money you have, not what it is for; StateJar asks whether it is your budget instead of assuming.

funds_on_hand, "i have 5000 rs", "mere paas 5000 rs hai", "i got 10000 rs for shopping".

An amount you have is probably the most you will spend, but a budget has to be said (see unclaimed money): StateJar never turns an amount into a limit on its own.

So instead of storing it or dropping it, the message's response carries a question, "Is ₹5,000 your budget?", under confirmations. Answer yes and it is written as constraints.budget.max in a new version; answer no and it is discarded. Until then it is listed under declined with this reason and is not part of your state or handle. See confirm.

Declined: low_confidence

The tier that proposed it was not sure enough for that field.

What it means. A field can require a minimum confidence. The tier that proposed this value scored below it, so it was declined rather than stored.

Why it happens. GLiNER2 carries 0.55 and tier 3 carries 0.5. A field that demands more than that will refuse both and accept only a rules match, which carries 1.0.

What to do. State the value more directly, in a form the deterministic tier can match. My budget is 5000 rupees rather than a value buried in a clause.

What it does not mean. That the value was wrong, only that the system's own estimate of its reliability did not clear the bar this field sets.

Declined: no currency signal

A number with a ceiling word but no currency ("under 50000") is asked about as a budget, never stored as one.

no_currency_signal, "i want a laptop under 50000", "max 800", "budget 20000 tak".

Money is the one value type StateJar refuses to infer: a number is stored as an amount only when the message carries a currency, ₹, Rs, INR, rupees (and the ways people actually spell it), lakh, crore, k, lpa. That rule is why "24 tonnes" never became a ₹24 budget.

A ceiling on a bare number is almost always a price, though, so rather than losing it the message's response asks, "Is ₹50,000 your budget?", under confirmations. Yes writes it as constraints.budget.max; no discards it. Until you answer it is not part of your state. See confirm.

Declined: not an assertion

The clause was a question, a negation or a retraction, not a claim.

What it means. Something that looked like a fact was not being asserted.

Why it happens. Is the budget still 5000? contains a value but claims nothing. The deadline is not March 3 denies one. A retraction withdraws one.

What to do. Nothing, usually, this is correct behaviour. Storing the number in a question is how memory systems end up confidently wrong about things the user was only asking about.

If you meant to assert it, say it as a statement.

Declined: rejected_value

The field exists, but the value is the wrong kind of thing.

What it means. The destination resolved fine and the value failed its normalizer, the field would not accept that kind of value.

This is the guard the whole product is built around. A shipping assistant told max load per container is 24 tonnes must not store a budget of twenty-four rupees. It gets this reason instead.

Why it happens. A value type mismatch: a quantity aimed at a money field, a duration aimed at a date, a value the normalizer could not parse at all.

What to do. Check whether the value belongs in that field. If it does, state it in the field's expected form (a date as a date, money with a currency). If it does not, this decline just saved you a wrong memory.

What it does not mean. That StateJar failed. Silently coercing it would have been the failure.

Declined: typo corrected

A money concept that only exists because a misspelling was corrected ("slary") is confirmed with you, not stored.

typo_corrected, "my slary was 80000rs", "my buget is 20000 rs".

StateJar corrects a short list of unambiguous misspellings before reading a message (salary, budget, rupees, name, address, email, mobile, tomorrow …). The correction never touches a value, the digits and your name stay exactly as typed, but a concept that exists only because of it is thin evidence.

So the amount is not stored. The message's response carries a question, "Save salary ₹80,000?", under confirmations. Yes writes it as that field in a new version; no discards it. See confirm.

Declined: unclaimed_money

An amount of money was stated without saying what it is, so it was not stored.

What it means. The message contained an amount of money, but nothing in it said what the amount is, so StateJar did not store it.

Why it happens. An amount becomes your budget only when you say it is one, "my budget is 50000", "I can afford 30000", "I can spend up to 5000", or when you put a ceiling on it: "under ₹2000", "nothing over ₹4999", "keep it under 600 rupees". A named kind of money keeps its own field ("my salary is 80000", "insured for $8,500"). Anything else is declined with this reason, for example:

  • "the phone costs 20000 rupees", a price, not what you will spend;
  • "I paid 3000 rupees for delivery", "I spent 5000 on shoes", payments already made;
  • "I have 3000 rupees", money on hand, not a stated limit.

Why it is not a guess. Earlier versions stored any such amount as the budget, so a phone's price or a delivery charge became "the most you will spend" and was sent to the model on every later turn. A missing budget can be stated in one message; a wrong one quietly shapes every answer after it.

What to do. If the amount was your budget, say so: "my budget is 20000".

Declined: unknown_key

Nothing in the registry claims that field name.

What it means. A value arrived addressed to a field name that no registry entry and no alias resolves to.

Why it happens. Usually an extractor produced a key from your phrasing that does not map onto a known concept.

What to do. Often nothing, if the concept is genuinely new it should have become a dynamic field instead, and seeing this reason for something that clearly is a concept is worth reporting. Otherwise, rephrase using a more common word for the same thing.

What it does not mean. That the value was wrong. The value was never examined; only its destination failed.

Glossary

Every term used anywhere in the product, alphabetical, each linking to its full entry.

One line each. Follow the link for the full entry.

TermOne line
Active stateWhat a field holds now, as opposed to what it used to hold.
AliasAn alternative name that resolves onto a canonical path.
Audit entryOne row recording a disclosure: handle, field keys, provider, model.
Base unitThe single unit a measure is converted to before storage.
Canonical pathThe one dotted address a concept resolves to.
ConflictA record that two values were asserted for one field.
CountA plain number of countable things, with no unit of measure.
DateA point in time.
DeclineA value StateJar refused to store, kept with its reason.
DisclosureState leaving StateJar for a model.
DurationA length of time, as opposed to a point in time.
Dynamic fieldA field for a concept the registry has no entry for.
Extraction tierOne of the three extractors: rules, GLiNER2, LLM.
FieldOne named slot holding one value.
GLiNER2Tier 2: schema-guided neural extraction.
HandleA content address for one exact state, written shm_….
HistoryEvery value a field previously held.
IdentifierA code that names something rather than measuring it.
IngestThe write path: extract, canonicalize, hash, store.
low_confidenceDeclined: the proposing tier was not sure enough.
Minimal disclosureSending only the fields a question needs.
MoneyAn amount with a currency. Requires a positive currency signal.
NamespaceYour account. Resolved from your credential, never passed in.
NormalizationConverting a value to one canonical form before storing it.
not an assertionDeclined: the clause was a question or a negation.
not providedUnresolved: a field was named with no value given.
OllamaLocally-run models on your own machine, for installs that offer it (not statejar.com). Reply prompts stay on your computer; StateJar runs no model of its own.
pendingUnresolved: a decision is explicitly outstanding.
PercentA proportion out of a hundred.
PipelineThe seven stages a message passes through.
ProviderThe vendor whose model answers: OpenRouter, OpenAI, Anthropic, Google (Ollama on installs that offer it); with no key, StateJar default model.
Proxy patternStateJar makes the model call for you.
QuantityA number with a unit of measure.
QualifierThe part of a concept saying which end, side or bound.
RatioA relationship between two numbers.
rejected_valueDeclined: the field exists, the value is the wrong kind.
ReinforcementRestating a stored value. Nothing changes, including the handle.
ReplayReconstructing exactly what one audited call disclosed.
RestoreAdopting a sealed state into a session, verifying it on the way in.
RetractionTaking a value back, leaving the field genuinely empty.
RetrievalSelecting the minimal subset relevant to a question.
RulesTier 1: deterministic patterns. Free, exact, reproducible.
ScopeWhich slice of memory an operation addresses.
SessionA named conversation thread.
Sidecar patternYou keep your model call; StateJar supplies the subset.
StateThe structured memory for a session at one point in time.
State versionOne link in a session's append-only chain of states.
SupersessionA new value replacing an old one, which moves to history.
TextA value that is words rather than a measurement.
Tier chipThe badge showing which extractor produced how many fields.
Token savingsHow much smaller a disclosure was, against a stated baseline.
_unmappedWhere declined values are parked, each with a reason.
unknown_keyDeclined: nothing in the registry claims that name.
Unknown typeThe classifier could not tell. Fails closed.
UnresolvedA field named without a value. Nothing was rejected.
user rejectedA conflict record: one option refused in favour of another.
user unsureUnresolved: uncertainty was signalled, so nothing was settled.
Value typeWhat kind of thing a value is. The core of the type guard.

Tier 1: rules

Deterministic patterns. Free, exact, narrow, identical on every machine.

What it is. Hand-written patterns and a canonical alias registry. No model, no network, no randomness.

What it catches. Direct statements, my budget is 5000, the deadline is March 3, each firing takes 14 hours, plus generic assignments (bedrooms: 3) and open concepts nobody wrote a rule for.

Why it runs first. It costs nothing and it is reproducible. The same message on two machines produces the same fields, which is what lets a handle mean anything.

Confidence. 1.0. A rule match is treated as certain, because it matched a literal pattern rather than judging.

When you see it alone. Most of the time, on well-formed input. A single rules chip is the normal, cheap, good case, not a sign that something was skipped.

Tier 2: GLiNER2

Schema-guided neural extraction for what the patterns missed.

Status. Off on statejar.com as a tier of the rule pipeline (EXTRACTOR_MODE=rules): production reads messages with extraction v3, which asks a GLiNER2 checkpoint of its own (fastino/gliner2-multi-v1, EXTRACTOR_V3_GLINER) for span proposals that deterministic checks then accept or decline. As this tier, on the held-out test split (204 sentences, scored once on 2026-10-08) adding it lowered precision from 86.4% to 67.4% and raised false acceptance from 7.4% to 22.1%, for 5 points more recall; on the blind set (189 sentences) precision fell from 75.7% to 53.3%. On the test run it took a median 0.53 s per message (rules: under 1 ms) and about 3 GB of memory, so it stays off.

What it is. A small neural span-labelling model (urchade/gliner_multi-v2.1) given a schema of labels to look for.

What it catches. Values stated in a shape the patterns do not cover, a name in an unusual position, a city inside a longer phrase.

Confidence. 0.55, with a floor of 0.5 on the model's own score. Lower than rules, deliberately: a span guess is not a pattern match, and fields that demand more confidence will decline it with low_confidence.

Dependencies. gliner and torch, which live in requirements-ml.txt rather than requirements.txt, production installs stay small and do not pull a CPU torch wheel. Where those are absent this tier simply does not run.

Controlled by EXTRACTOR_MODE.

Whatever it proposes goes through the same type guard and the same registry as everything else. A neural span is not more entitled to a field than a regex was.

Tier 3: LLM extraction

The rule pipeline's old third tier, off on statejar.com; extraction v3 reads with the cloud providers instead. StateJar runs no model of its own.

Status. Off on statejar.com as a tier of the rule pipeline (EXTRACTOR_LLM_FALLBACK=false, from the production deploy configuration). Production reads messages with extraction v3 instead, which asks a cloud model to propose facts and then to verify them, with deterministic checks deciding. The owner's rule (2026-10-09) was to turn it on only if blind set v2 showed the local tier raising precision without raising false acceptance. It did not. On the 373 messages of blind set v2, measured once on 2026-10-09, rules alone scored precision 67.9% and false acceptance 15.9%; rules plus Qwen3-4B-Instruct-2507 scored 66.5% and 19.8%, with recall up from 49.3% to 61.6%. The model's new mistakes were facts about someone else taken as yours, passing mentions taken as facts, and side details stored as plans.

Where it runs. As a tier of the rule pipeline it is off: it would only ever ask an Ollama model (ollama/…), and StateJar runs no model of its own on staging or production (owner's decision, 2026-10-30). Extraction v3 asks the cloud providers an administrator enabled (BACKUP_PROVIDERS, provider table): the message goes to paid Gemini first, then Mistral, then OpenRouter, with StateJar's own keys, never yours, each provider's terms are listed on /privacy, and the audit log names the provider and model that read it. When every provider fails the message is read by the rules and the span proposer alone, its weak facts held, and read again when a provider is healthy. Whether this tier is on at all on statejar.com is decided by a blind measurement (see about); when it is off, the rule pipeline reads with its rules alone.

What it is. A language model asked to return strict JSON, under a fixed schema, for a message the rules found hard: Hindi script, Hinglish function words, several people in one message ("my brother lives in Delhi"), a condition or a plan ("if I move to Mumbai"), a run-on first-person message. The model only proposes a value with the words it rests on; a proposal about someone other than the user, about a hypothetical, or whose value or evidence is not in the message is dropped before the guards even see it. It answers within 8 seconds or the message falls back to the rules alone (and, for an unsure amount, confirm before store).

Why it is gated. A model call costs seconds, so it fires only when the message looks like it needs it. The trigger conditions are:

  • EXTRACTOR_LLM_FALLBACK is on and a model is configured (none is, on statejar.com)
  • the message is one the rules find hard (Devanagari, two or more Hinglish function words, another person, a condition or a plan, a run-on first-person message), or the older size test: at least 12 words with at most 2 fields found, long and sparse, or over five words with nothing found at all

Confidence. 0.5, the lowest of the three.

When it fails. The fallback chain is tried first. If everything fails, the tier is marked unavailable and shows an amber chip, attempted and did not answer, which is not the same as skipped.

What it cannot do. It cannot bypass a guard. Its output is type-checked and routed exactly like a rule's. This is why probabilistic extraction is safe here at all: extraction runs before canonicalization, so the model proposes and the deterministic layer disposes.

Not seeing the chip when you expected it? See no LLM chip appearing.

Unresolved: not provided

A field was named but no value was given.

What it means. You referred to something without saying what it is, I still need to work out the venue, budget TBD.

Not a decline. Nothing was rejected. This is StateJar noting an open field: a thing you have signalled matters, with no value yet.

Where you see it. The unresolved list, not _unmapped.

Why it is useful. A model can be told what is still outstanding without being told a made-up value for it.

Unresolved: pending

A decision is explicitly outstanding.

What it means. The clause said a value is coming but not yet settled, final hai nahi, still pending, waiting on confirmation.

Why it is tracked. An assistant that knows which fields are pending can ask about the right ones. An assistant that stored a guess cannot.

What to do. State the value when it is decided.

Unresolved: user unsure

You said you were not certain, so nothing was stored as settled.

What it means. The phrasing signalled uncertainty, maybe around 5000, I am not sure yet.

Why it exists separately from not provided. They call for different responses. A field with no value needs one. A field the user is unsure about has a candidate and needs confirmation.

What to do. Confirm it plainly when you have decided, and it stores normally.

Value type: Count

A plain number of countable things.

What it is. A plain number of countable things.

Examples. 40 volunteers, 3 bedrooms, 12 crates

How StateJar treats it. A count has a countable noun but no unit of measure, that is what separates it from a quantity.

A field declares which value types it accepts. A value of the wrong type is declined rather than coerced. See value type.

Value type: Date

A point in time.

What it is. A point in time.

Examples. March 3, 2026-03-03, next Friday, 3rd March

How StateJar treats it. Stored as ISO alongside the raw text you used, so display keeps your phrasing while comparison uses the normalized form. A date will not be accepted by a money or quantity field.

A field declares which value types it accepts. A value of the wrong type is declined rather than coerced. See value type.

Value type: Duration

A length of time.

What it is. A length of time.

Examples. 14 hours, two weeks, 45 minutes, 6 months

How StateJar treats it. Distinct from a date: a duration is how long, a date is when. Mixing them is a common failure, push it by two weeks is a duration operating on a date.

A field declares which value types it accepts. A value of the wrong type is declined rather than coerced. See value type.

Value type: Identifier

A code that names something rather than measuring it.

What it is. A code that names something rather than measuring it.

Examples. ORD-4471, PNR 8823, GSTIN 27AAAPA…

How StateJar treats it. Identifiers look like numbers and are not. 4471 in order 4471 must never be arithmetic, a budget, or a count, this type is what prevents that.

A field declares which value types it accepts. A value of the wrong type is declined rather than coerced. See value type.

Value type: Money

An amount with a currency.

What it is. An amount with a currency.

Examples. 5000 rupees, INR 5,000, Rs 5000, $120

How StateJar treats it. Requires a positive signal, a symbol, a currency word, or a money noun in the clause. There is no default currency. A bare number is not money, which is precisely what stops 24 in a load-limit clause becoming twenty-four rupees.

A field declares which value types it accepts. A value of the wrong type is declined rather than coerced. See value type.

Value type: Percent

A proportion out of a hundred.

What it is. A proportion out of a hundred.

Examples. 20%, 20 percent

How StateJar treats it. Kept distinct from a plain count and from a ratio. 20% and 20 are not the same value and are not interchangeable in a field.

A field declares which value types it accepts. A value of the wrong type is declined rather than coerced. See value type.

Value type: Quantity

A number with a unit of measure.

What it is. A number with a unit of measure.

Examples. 24 tonnes, 1,240 kg, 500 ml, 3.2 km

How StateJar treats it. Normalized to a base unit, so 1,240 kg and 1.24 tonnes are the same stored value and produce the same handle. Weight, length, volume and data units are recognised.

A field declares which value types it accepts. A value of the wrong type is declined rather than coerced. See value type.

Value type: Ratio

A relationship between two numbers.

What it is. A relationship between two numbers.

Examples. 3:1, 2 to 1, one in four

How StateJar treats it. A ratio is a pair, not a number. Storing 3:1 as 3 would lose exactly the part that carries the meaning.

A field declares which value types it accepts. A value of the wrong type is declined rather than coerced. See value type.

Value type: Text

A value that is words rather than a measurement.

What it is. A value that is words rather than a measurement.

Examples. a name, a city, a colour, a chosen option

How StateJar treats it. Text fields carry their own guards, a blocklist stops filler words being stored as someone's name, and degenerate values are rejected rather than kept.

A field declares which value types it accepts. A value of the wrong type is declined rather than coerced. See value type.

Value type: Unknown

The classifier could not tell what kind of value this is.

What it is. The classifier could not tell what kind of value this is.

Examples. an ambiguous fragment with no clarifying context

How StateJar treats it. Fail-closed. An unknown type is not assumed compatible with anything; it is refused by any field that demands a specific type, and the refusal shows as rejected_value. Guessing here is exactly the failure mode StateJar exists to avoid.

A field declares which value types it accepts. A value of the wrong type is declined rather than coerced. See value type.