Skip to content

Quickstart — an agent on Limma in under ten minutes

You will start the API, attach an agent over MCP, ask limma_facts for everything at an address, read the count and one receipt, and re-read the receipt's bytes with limma_region. Every command below was run as written; the Python examples are executed by the test suite against the app and print exactly what is shown.

1. Start the API

cd "<repo>"
.venv/bin/uvicorn epistem.api.app:create_app --factory --host 127.0.0.1 --port 8000
curl -s http://127.0.0.1:8000/health

With no EPISTEM_API_KEYS set the API runs in dev mode: no key is required and no organisation check runs — in development only (LIMMA_ENV unset or dev; any other environment refuses to start without a usable key). To require keys, set EPISTEM_API_KEYS="<key>:<org>:<role>" (role admin, analyst, service or viewer; what each may do is in Keys and roles) before starting and send the key — the first field — as X-API-Key on every call. The organisation, role and scope come from the configured entry; sending the whole entry also works (it contains the key and grants nothing more), a key with a different org or role appended is refused, and a key in the query string (?api_key=) is refused 401 (step 4 shows each answer from the app). WHOSE a workspace is depends on where it was created. In every environment but development (on-prem, staging, production) POST /api/workspaces asks who is calling — a key; on a deployment that has accounts, a signed-in session too — the workspace belongs to the caller's organisation, and every unscoped key of that organisation reaches it. In development — this page — a workspace created with POST /api/workspaces belongs to no organisation, with a key or without one: once keys are required, only a key scoped to it reaches it (EPISTEM_API_KEYS="<key>:<org>:analyst:<workspace_id>") — an unscoped key is answered Workspace has no organization; a workspace-scoped API key is required to access it. The default workspace of step 2 is not created that way: it belongs to the calling key's organisation in every environment, development included (with no key at all, to none).

Ingesting documents runs a model — Limma's own reader, which reads with open weights: point it at an OpenAI-compatible server before starting (LIMMA_PROVIDER=openai_compat, LIMMA_MODEL_BASE_URL=http://<host>:8000/v1, LIMMA_MODEL=<the name it serves>, and LIMMA_MODEL_API_KEY when it needs one; install, section 2 serves one on a GPU). Your agent's own model is yours to choose. With EPISTEM_DISABLE_LLM=1 an ingest completes with zero facts. Costs and times are measured in troubleshooting.

2. A document — no workspace to create first

curl -s -X POST http://127.0.0.1:8000/v1/public/ingest \
     -H 'Content-Type: application/json' \
     -d '{"filename": "acme_q3_2024.txt",
          "content": "Acme Corp Q3 2024 update. Revenue 400. Net income 61."}'

The call names no workspace, so it is served from your key's default workspace, created by this first call (with keys required, by a key that may create workspaces — an admin or analyst key; in development with no key, one default belonging to no organisation). Every later call that names none lands in the same one, and every reply says which: the reply here is {"workspace_id": "...", "source_id": "...", "status": "queued", ...}. Extraction runs in the background and the document's facts appear when it completes. To keep documents apart, create more workspaces (POST /api/workspaces, step 1) and pass workspace_id.

limma_ingest over MCP is the same call and carries text — a PDF goes through the document upload route (POST /v1/workspaces/{workspace_id}/documents, multipart files=@<pdf>, with the workspace_id any reply named), or, from an agent that holds no key, through a one-time upload link limma_ingest mints (upload_link=True: files from an agent). A 150-page filing takes tens of minutes.

A whole folder — or an S3, Azure Blob or Google Cloud Storage bucket — is one command, which keeps the workspace current each time it runs again:

pip install limma-ai
export LIMMA_API_KEY=<your key>
limma sync ./my-documents --url http://127.0.0.1:8000

It syncs into the key's default workspace (--workspace <id> names another) and sends files through that same upload route, so it needs a key that may load documents: Connectors.

The examples below run against a workspace the test suite seeds directly with two small documents about "Acme Corp" (an annual report with six facts on two pages, and a quarterly update in two generations), so they cost nothing and print the same thing every time. Your own workspace will hold your documents' addresses instead.

3. Attach an agent over MCP

The API serves its MCP tools itself, at /mcp on its own port. One line attaches an agent, with the key and no workspace — Claude Code:

claude mcp add --transport http limma http://127.0.0.1:8000/mcp --header 'X-API-Key: ${LIMMA_API_KEY}'

Claude Desktop, Cursor and VS Code, and the hosted URL: MCP over HTTP.

An MCP client can instead launch the server as a local process over stdio (epistem/mcp/server.py). This is that client configuration — paste it into Claude Desktop's claude_desktop_config.json or any MCP client's server list, with the absolute path to this checkout's interpreter:

{
  "mcpServers": {
    "limma": {
      "command": "<repo>/.venv/bin/python",
      "args": ["-m", "epistem.mcp.server"],
      "env": {
        "LIMMA_API_BASE_URL": "http://127.0.0.1:8000",
        "LIMMA_API_KEY": ""
      }
    }
  }
}

LIMMA_API_KEY is sent as X-API-Key when set and omitted when empty (dev mode). The MCP server dials LIMMA_API_BASE_URL DIRECTLY: it takes no proxy from its environment (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY are ignored, so a key and a document are never handed to one a container runtime injected), and it has no setting to route through one — run it where it can reach the API. A private CA is honoured (SSL_CERT_FILE). From Python, the same attachment with the mcp client library lists the tools the agent will see:

import asyncio, os
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    params = StdioServerParameters(
        command="<repo>/.venv/bin/python", args=["-m", "epistem.mcp.server"],
        env={**os.environ, "LIMMA_API_BASE_URL": "http://127.0.0.1:8000"})
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            print([tool.name for tool in (await session.list_tools()).tools])

asyncio.run(main())

This prints the tools the server offers. By default that is the core six — find something, address it, read it back, put things in, and list what is in:

from epistem.mcp.server import TOOL_NAMES
print(len(TOOL_NAMES), "tools:", ", ".join(sorted(TOOL_NAMES)))
6 tools: limma_connect, limma_documents, limma_facts, limma_ingest, limma_region, limma_search

An MCP client sends every tool's schema on every request, before your question and before a single fact. The whole menu was measured at roughly 17,300 tokens per call when it held seventeen tools (it holds four more now) — on a 32K-context model, more than half the window spent before any work begins. The default is six for that reason, not because the rest went away.

Set LIMMA_MCP_TOOLS to widen it: core (default), advanced (the answer machine — ask, plan, precheck, derive, verify, attest), admin (workspace and audit — state, generations, diff, correct, corrections, resolve, apply_pack, and a connector's delete_document and reconcile — sync and access), all, or individual names. LIMMA_MCP_TOOLS=core,limma_ask is valid. An unknown tier or tool name is refused at start-up rather than quietly serving a shorter menu.

from epistem.mcp.server import ALL_TOOL_NAMES, TOOL_TIERS
print(len(ALL_TOOL_NAMES), "mapped;", ", ".join(
    f"{tier}={sum(1 for t in TOOL_TIERS.values() if t == tier)}"
    for tier in ("core", "advanced", "admin")))
21 mapped; core=6, advanced=6, admin=9

Every one of the twenty maps 1:1 to a /v1/public/* route whatever the tier, so a verb is always reachable over REST; the verb reference documents all of them and is generated from the code.

4. Ask for everything at an address

The examples use client, an HTTP client for the API — in your code:

import httpx
client = httpx.Client(base_url="http://127.0.0.1:8000",
                      headers={"X-API-Key": "<key>"})

The seeded app is configured with the entry sk_docs:org_docs:analyst, bound here as API_KEY; its key is sk_docs. What the public verbs answer for each thing a client might send:

key = API_KEY.split(":")[0]
for label, value in (("the key", key), ("the whole entry", API_KEY),
                     ("the key with another role", key + ":org_docs:admin"),
                     ("no key", "")):
    r = client.post("/v1/public/facts", headers={"X-API-Key": value},
                    json={"workspace_id": WS, "question": "revenue", "limit": 1})
    print(label, "->", r.status_code, r.json().get("detail", "ok"))
r = client.post(f"/v1/public/facts?api_key={key}",
                json={"workspace_id": WS, "question": "revenue", "limit": 1})
print("the key in the query string ->", r.status_code)
the key -> 200 ok
the whole entry -> 200 ok
the key with another role -> 403 Invalid API key.
no key -> 401 API key required. Provide via X-API-Key header.
the key in the query string -> 401

First discover which addresses exist. mode="explore" is relevance-ranked and cannot count; it is for finding the names the store uses:

r = client.post("/v1/public/facts", json={
    "workspace_id": WS, "question": "revenue 2023", "mode": "explore",
    "output_mode": "compact", "limit": 5})
addresses = {(f["attribute"], f["period"]) for f in r.json()["facts"] if f["attribute"]}
for attribute, period in sorted(addresses):
    print(attribute, "|", period)
headcount | FY2023
net_income | FY2023
revenue | FY2023

Then enumerate at the address. This is the coordinate question — over MCP, limma_facts(question, mode="enumerate", coordinate={...}) (and workspace_id=... for a workspace other than the key's default):

r = client.post("/v1/public/facts", json={
    "workspace_id": WS, "question": "revenue", "mode": "enumerate",
    "coordinate": {"attribute": "revenue", "period": "FY2023"}, "output_mode": "compact"})
body = r.json()
e = body["enumeration"]
print("exist:", e["exist"], "| delivered:", e["delivered"], "| bound:", e["bound"],
      "| complete:", body["coverage"]["completeness_licensed"])
fact = body["facts"][0]
print("value:", fact["value"], "| printed:", fact["value_token"])
print("receipt: page", fact["page"], "chars", fact["char_start"], "-", fact["char_end"],
      "| byte_verified:", fact["byte_verified"])
exist: 1 | delivered: 1 | bound: True | complete: True
value: 1250000000.0 | printed: 1,250
receipt: page 0 chars 126 - 131 | byte_verified: True

exist == delivered on a bound address licenses these are all of them; exist == 0 on a bound address licenses there are none. Read a count for what that does and does not cover.

5. Re-read the receipt

limma_region(source_id, start, end, page=...) returns the bytes the receipt points at, verbatim:

r = client.post("/v1/public/facts", json={
    "workspace_id": WS, "question": "revenue", "mode": "enumerate",
    "coordinate": {"attribute": "revenue", "period": "FY2023"}, "output_mode": "compact"})
fact = r.json()["facts"][0]
region = client.get("/v1/public/region", params={
    "workspace_id": WS, "source_id": fact["source_id"], "page": fact["page"],
    "start": fact["char_start"], "end": fact["char_end"]}).json()
print(repr(region["text"]), "== printed token:", region["text"] == fact["value_token"])
'1,250' == printed token: True

That is the whole loop: an address, a count, a receipt, and the bytes behind it.

6. Search, when the workspace has relations

A workspace whose pack declares relations (roles, screenings — see relations) answers a request over several of them at once with limma_search. Over MCP, limma_search(brief="...") compiles the request with one model call; with no model configured it answers 503 and names the way in — constraints, the compiled form, which runs no model. This runs against the documentation's roster workspace (docs/examples/roster.py):

from docs.examples.roster import roster
ws = roster(client).workspace_id
r = client.post("/v1/public/search", json={"workspace_id": ws, "constraints": [
    {"relation": "held_role", "slot": "seniority", "op": "gte", "value": "VP"},
    {"relation": "held_role", "slot": "geography", "op": "eq", "value": "DACH"},
    {"relation": "screening", "slot": "outcome", "op": "eq", "value": "passed"}]})
body = r.json()
print("matched:", body["entities"]["matched"], "| none:", body["counts"]["none"],
      "| not recorded:", body["entities"]["not_recorded"])
print("licensed:", body["coverage"]["completeness_licensed"], body["coverage"].get("narrowed_by"))
matched: ['halvard_quist'] | none: 3 | not recorded: ['yusuf_alderane']
licensed: False ['entities_not_recorded']

Search reads that warrant in full. Next: an address · a receipt · a count · None versus Not recorded · contested · provenance · supersession · relations · vocabulary · search · corrections · limits · troubleshooting · the verb reference.