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.
- Connect with OpenRouter takes you to openrouter.ai. Sign in there and approve the key; its label is
prefilled as StateJar.
- 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:
| Scope | Lets the app |
|---|
memory:read | search, get, list, history, audit, verify, export |
memory:write | remember, update, delete |
offline_access | stay 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.
| Stage | Sublabel | Means |
|---|
| Ingest | sent | the message reached the API |
| Extract | N fields | how many fields the tiers produced |
| Canonicalize | sorted | keys sorted at every level, the exact bytes the handle is computed from |
| Handle | first 8 hex characters | the content address that was derived |
| Store | v1 or v+1 | a first state, or a new version of an existing chain |
| Retrieve | N of M fields | how many of the eligible fields were disclosed |
| Audit | logged | a 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 sign-in link page
/login/link: where a sign-in link lands. Press Sign in; nothing happens until you do.
A sign-in link from your mail opens /login/link. The page shows one button, Sign in, and consumes the link
only when you press it (POST /auth/link/consume), so a mail scanner that opens links
ahead of you cannot spend yours.
It works in the browser you asked from, within ten minutes, once. In another browser the page says so and offers
a code instead (ways to sign in). A used or expired link says to ask for a new one.
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.
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.
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.
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:
| Answer | When |
|---|
503 payments_disabled | no provider takes payments (billing config) |
404 plan_not_found | no active plan has that id |
409 plan_not_for_sale | a free or contact-only plan |
409 plan_managed | an admin set this account's plan |
409 plan_in_force | another plan is paid for until until; change plans when it ends |
409 order_open | an unpaid order for another plan is open: pay or cancel it |
502 provider_error | the 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 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.
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.
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.
- 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.
- The connection is then used up, whatever happens next, so a state and its verifier work once.
- 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.
- 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.
| Status | When |
|---|
201 | The key is saved. |
400 | Start 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. |
401 | No console session. A developer key is refused too. |
422 | The code is empty, over 512 characters or not printable ASCII, or the state is not 43 base64url characters. Nothing is sent to OpenRouter. |
429 | More than 10 a minute or 30 an hour for the account. |
502 | OpenRouter 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:
- 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.
- 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.
- 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": "…"}.
- 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.
- 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):
| Tool | Scope | What it does |
|---|
remember | write | Stores 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. |
search | read | The memory a question needs as memory_context, the handle it came from and the audit id of the disclosure. |
get | read | The current state, one fact of it, or the state a handle names; the handle is re-derived first. |
list | read | Every fact of the current state as path and value. |
update | write | A correction in the user's words, stored as a new version; facts as in remember. |
delete | write | Removes one fact from the current state as a new version; history keeps it. |
history | read | The versions, newest first, by handle. |
audit | read | What was disclosed, newest first. |
verify | read | Re-derives a handle from the stored state. |
export | read | Every 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).
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.
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.
| Value | Behaviour |
|---|
false | load 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. |
true | a 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.
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.
| Term | One line |
|---|
| Active state | What a field holds now, as opposed to what it used to hold. |
| Alias | An alternative name that resolves onto a canonical path. |
| Audit entry | One row recording a disclosure: handle, field keys, provider, model. |
| Base unit | The single unit a measure is converted to before storage. |
| Canonical path | The one dotted address a concept resolves to. |
| Conflict | A record that two values were asserted for one field. |
| Count | A plain number of countable things, with no unit of measure. |
| Date | A point in time. |
| Decline | A value StateJar refused to store, kept with its reason. |
| Disclosure | State leaving StateJar for a model. |
| Duration | A length of time, as opposed to a point in time. |
| Dynamic field | A field for a concept the registry has no entry for. |
| Extraction tier | One of the three extractors: rules, GLiNER2, LLM. |
| Field | One named slot holding one value. |
| GLiNER2 | Tier 2: schema-guided neural extraction. |
| Handle | A content address for one exact state, written shm_…. |
| History | Every value a field previously held. |
| Identifier | A code that names something rather than measuring it. |
| Ingest | The write path: extract, canonicalize, hash, store. |
low_confidence | Declined: the proposing tier was not sure enough. |
| Minimal disclosure | Sending only the fields a question needs. |
| Money | An amount with a currency. Requires a positive currency signal. |
| Namespace | Your account. Resolved from your credential, never passed in. |
| Normalization | Converting a value to one canonical form before storing it. |
not an assertion | Declined: the clause was a question or a negation. |
not provided | Unresolved: a field was named with no value given. |
| Ollama | Locally-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. |
pending | Unresolved: a decision is explicitly outstanding. |
| Percent | A proportion out of a hundred. |
| Pipeline | The seven stages a message passes through. |
| Provider | The vendor whose model answers: OpenRouter, OpenAI, Anthropic, Google (Ollama on installs that offer it); with no key, StateJar default model. |
| Proxy pattern | StateJar makes the model call for you. |
| Quantity | A number with a unit of measure. |
| Qualifier | The part of a concept saying which end, side or bound. |
| Ratio | A relationship between two numbers. |
rejected_value | Declined: the field exists, the value is the wrong kind. |
| Reinforcement | Restating a stored value. Nothing changes, including the handle. |
| Replay | Reconstructing exactly what one audited call disclosed. |
| Restore | Adopting a sealed state into a session, verifying it on the way in. |
| Retraction | Taking a value back, leaving the field genuinely empty. |
| Retrieval | Selecting the minimal subset relevant to a question. |
| Rules | Tier 1: deterministic patterns. Free, exact, reproducible. |
| Scope | Which slice of memory an operation addresses. |
| Session | A named conversation thread. |
| Sidecar pattern | You keep your model call; StateJar supplies the subset. |
| State | The structured memory for a session at one point in time. |
| State version | One link in a session's append-only chain of states. |
| Supersession | A new value replacing an old one, which moves to history. |
| Text | A value that is words rather than a measurement. |
| Tier chip | The badge showing which extractor produced how many fields. |
| Token savings | How much smaller a disclosure was, against a stated baseline. |
_unmapped | Where declined values are parked, each with a reason. |
unknown_key | Declined: nothing in the registry claims that name. |
| Unknown type | The classifier could not tell. Fails closed. |
| Unresolved | A field named without a value. Nothing was rejected. |
user rejected | A conflict record: one option refused in favour of another. |
user unsure | Unresolved: uncertainty was signalled, so nothing was settled. |
| Value type | What 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.