Corpus guide

Corpus: from a key to a first query

This guide takes you from an empty workspace to a resolved decision, with no help beyond it. It walks one running example throughout: an expert named publication-review that decides how many reviewers a draft needs before it can be published.

You need a Corpus organization with at least one published expert and one published document version. If you're setting those up yourself, do that in the workspace first; this guide starts from a key.


1. Mint a key

Everything below is done in the workspace, at corpus.trysenda.xyz, signed in as an admin or the owner.

Mint a key

Open Keys in your organization's sidebar and mint one. Name it after who is calling, not what it calls: "review-bot", not "query-expert". The decision log shows that name beside every call the key makes. Scope it to the publication-review expert and to one tag, so the key reaches only what those two intersect.

The key is shown once, in full, right after you mint it. Copy it before you leave the screen: after that, the workspace only ever shows its prefix (sk_corpus_...). Store it as CORPUS_KEY in your shell or your secrets manager, whichever this environment already uses.


2. A first query, over curl

Every REST call carries Authorization: Bearer $CORPUS_KEY. The base path is https://corpus.trysenda.xyz/v1.

List the experts your key can reach

curl "https://corpus.trysenda.xyz/v1/experts" \
  -H "Authorization: Bearer $CORPUS_KEY"

This also confirms the key works and returns the expert's current published version and its graphHash, the two numbers a trace pins its conclusion to.

{
  "experts": [
    {
      "id": "5d9e2c1a-6f3b-4a7d-9e1c-2b3a4c5d6e7f",
      "name": "publication-review",
      "description": "How many reviewers a draft needs before it can be published.",
      "version": 4,
      "graphHash": "sha256:2f1a9c...",
      "tags": ["editorial"]
    }
  ]
}

Ask for reviewer_count without every fact

curl -X POST "https://corpus.trysenda.xyz/v1/experts/publication-review/query" \
  -H "Authorization: Bearer $CORPUS_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "goal": "reviewer_count",
    "facts": { "audience": "external" }
  }'

The expert needs to know whether the draft contains customer data before it can conclude. You've only supplied audience, so the outcome is needs_facts, and needs names exactly what's missing, typed: a boolean called contains_customer_data.

There's no session to resume. You don't retry the same call; you call again with the fact added.

{
  "decisionId": "3c6b1e2d-4f5a-6b7c-8d9e-0f1a2b3c4d5e",
  "outcome": "needs_facts",
  "conclusion": null,
  "needs": [
    {
      "concept": "contains_customer_data",
      "type": { "kind": "boolean" },
      "description": "Whether the draft names or quotes a customer."
    }
  ],
  "reason": null,
  "certainty": null,
  "expert": {
    "id": "5d9e2c1a-6f3b-4a7d-9e1c-2b3a4c5d6e7f",
    "name": "publication-review",
    "version": 4,
    "graphHash": "sha256:2f1a9c..."
  },
  "trace": [
    {
      "id": "s1",
      "parentId": null,
      "kind": "goal",
      "concept": "reviewer_count",
      "provenance": { "kind": "caller", "field": "goal" },
      "expert": "publication-review",
      "expertVersion": 4
    },
    {
      "id": "s2",
      "parentId": "s1",
      "kind": "fact",
      "concept": "audience",
      "value": "external",
      "provenance": { "kind": "caller", "field": "audience" },
      "expert": "publication-review",
      "expertVersion": 4
    },
    {
      "id": "s3",
      "parentId": "s1",
      "kind": "unresolved",
      "concept": "contains_customer_data",
      "provenance": { "kind": "none" },
      "expert": "publication-review",
      "expertVersion": 4
    }
  ],
  "traceHash": "sha256:9b7e21...",
  "inputHash": "sha256:44ad10...",
  "cached": false
}

Ask again, with the fact supplied

curl -X POST "https://corpus.trysenda.xyz/v1/experts/publication-review/query" \
  -H "Authorization: Bearer $CORPUS_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "goal": "reviewer_count",
    "facts": {
      "audience": "external",
      "contains_customer_data": "true"
    }
  }'

This time the outcome is resolved, conclusion is "3" (every value on the wire is a string; the concept's type says how to read it), and trace is the full derivation: the goal, each fact, the rule that fired, and its last step, a document step citing publication-policy.md at version 2. That's the citation the spec calls for: the trace reaches the paragraph of the policy the rule leaned on.

{
  "decisionId": "8f0f3b7a-9c2e-4d61-8a3f-1a2b3c4d5e6f",
  "outcome": "resolved",
  "conclusion": "3",
  "needs": [],
  "reason": null,
  "certainty": 1,
  "expert": {
    "id": "5d9e2c1a-6f3b-4a7d-9e1c-2b3a4c5d6e7f",
    "name": "publication-review",
    "version": 4,
    "graphHash": "sha256:2f1a9c..."
  },
  "trace": [
    {
      "id": "s1",
      "parentId": null,
      "kind": "goal",
      "concept": "reviewer_count",
      "provenance": { "kind": "caller", "field": "goal" },
      "expert": "publication-review",
      "expertVersion": 4
    },
    {
      "id": "s2",
      "parentId": "s1",
      "kind": "fact",
      "concept": "audience",
      "value": "external",
      "provenance": { "kind": "caller", "field": "audience" },
      "expert": "publication-review",
      "expertVersion": 4
    },
    {
      "id": "s3",
      "parentId": "s1",
      "kind": "fact",
      "concept": "contains_customer_data",
      "value": "true",
      "provenance": { "kind": "caller", "field": "contains_customer_data" },
      "expert": "publication-review",
      "expertVersion": 4
    },
    {
      "id": "s4",
      "parentId": "s1",
      "kind": "rule",
      "concept": "reviewer_count",
      "value": "3",
      "detail": "audience = external and contains_customer_data",
      "provenance": {
        "kind": "rule",
        "ruleId": "external-customer-data",
        "expert": "publication-review",
        "version": 4
      },
      "expert": "publication-review",
      "expertVersion": 4
    },
    {
      "id": "s5",
      "parentId": "s4",
      "kind": "document",
      "concept": "reviewer_count",
      "detail": "Section 3: drafts naming or quoting a customer need three reviewers.",
      "provenance": {
        "kind": "document",
        "documentId": "7a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
        "version": 2,
        "title": "publication-policy.md"
      },
      "expert": "publication-review",
      "expertVersion": 4
    }
  ],
  "traceHash": "sha256:6e12ab...",
  "inputHash": "sha256:c02fd7...",
  "cached": false
}

Ask the exact same question again

Same expert version, same facts: cached is now true, and traceHash is unchanged. The engine didn't re-evaluate; it returned the logged decision. traceHash is how you'd notice if it had: a changed policy or a changed rule changes the hash even when the conclusion doesn't.

{
  "decisionId": "8f0f3b7a-9c2e-4d61-8a3f-1a2b3c4d5e6f",
  "outcome": "resolved",
  "conclusion": "3",
  "needs": [],
  "reason": null,
  "certainty": 1,
  "expert": {
    "id": "5d9e2c1a-6f3b-4a7d-9e1c-2b3a4c5d6e7f",
    "name": "publication-review",
    "version": 4,
    "graphHash": "sha256:2f1a9c..."
  },
  "trace": [ /* identical to the call above */ ],
  "traceHash": "sha256:6e12ab...",
  "inputHash": "sha256:c02fd7...",
  "cached": true
}

Replay the decision

curl -X POST "https://corpus.trysenda.xyz/v1/decisions/8f0f3b7a-9c2e-4d61-8a3f-1a2b3c4d5e6f/replay" \
  -H "Authorization: Bearer $CORPUS_KEY"

Replay re-runs decisionId against the same expert version and the same inputs and reports whether the trace still matches. matches: true here means nothing moved under this decision since it was logged. A false match would name the first step that diverged, and what it expected against what it found.

{
  "matches": true,
  "divergedAt": null,
  "expected": null,
  "actual": null,
  "trace": [ /* identical to the resolved call above */ ]
}

List the documents, and find an id

curl "https://corpus.trysenda.xyz/v1/documents?title=policy&limit=2" \
  -H "Authorization: Bearer $CORPUS_KEY"

Every document has an id, and get_document, GET /v1/documents/{id}, its versions and publishing all take it. List the published documents in the key's scope with GET /v1/documents, ordered by title. title keeps the ones whose current title contains that text, ignoring case, and limit sets the page size: 50 by default, 200 at most.

next is the cursor of the following page. Pass it back as cursor and repeat until next is null; the cursor is opaque, so don't build one. Pass q instead of paging to search the bodies: a search is ranked, capped at limit (20 by default), never paged, and each hit carries a snippet of the body around the match.

{
  "documents": [
    {
      "id": "7a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
      "type": "policy",
      "language": "en",
      "tags": ["editorial"],
      "currentVersion": {
        "version": 2,
        "title": "publication-policy.md",
        "publishedAt": "2026-09-14T10:00:00.000Z"
      }
    },
    {
      "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "type": "policy",
      "language": "en",
      "tags": ["editorial", "legal"],
      "currentVersion": {
        "version": 1,
        "title": "Retention policy",
        "publishedAt": "2026-09-02T08:30:00.000Z"
      }
    }
  ],
  "next": "WyJSZXRlbnRpb24gcG9saWN5IiwiOWM4YjdhNmQtNWU0Zi00YTNiLThjMmQtMWUwZjlhOGI3YzZkIl0"
}

create_document and ask_corpus take tag names. GET /v1/tags lists the ones in the key's scope, so you don't have to guess them.

curl "https://corpus.trysenda.xyz/v1/tags" \
  -H "Authorization: Bearer $CORPUS_KEY"
{ "tags": [{ "name": "editorial" }, { "name": "legal" }] }

3. The same query, from Claude

The Corpus exposes one MCP endpoint, https://corpus.trysenda.xyz/mcp, over Streamable HTTP, stateless (no sessions). Every call carries the key as a bearer header, the same key you minted above.

Claude Code

Add the server once, from a shell where CORPUS_KEY is set:

claude mcp add --transport http corpus https://corpus.trysenda.xyz/mcp \
  --header "Authorization: Bearer $CORPUS_KEY"

The Claude API's MCP connector

Pass the server as an mcp_servers entry on the request, with the key as its authorization token. Check the current API reference for the exact field names before you ship this; the connector is still evolving.

{
  "mcp_servers": [
    {
      "type": "url",
      "url": "https://corpus.trysenda.xyz/mcp",
      "name": "corpus",
      "authorization_token": "$CORPUS_KEY"
    }
  ]
}

Claude Desktop

Desktop's remote connector setup (Settings, Connectors, add a custom connector) expects most servers to hand out access through OAuth. The Corpus authenticates by API key alone in this milestone; the OAuth chain is planned for a later one. If Desktop's connector flow doesn't accept a bearer key directly in your version, use Claude Code or the API connector above instead, both of which do.

The tools

Tool names and their input schemas are generated from the same OpenAPI document as the REST API, so they never drift from it.

tool
list_expertsThe experts the key can reach, with their published version and graphHash.
query_expertThe one you'll use most: expert, goal, and optionally facts and question. Same envelope as POST /v1/experts/{name}/query.
replay_decisionRe-run a logged decision by decision_id and report whether its trace still matches.
search_documentsSearch the documents in the key's scope, or list them. With query, full-text search over current versions, filtered by type or tag. Without it, the published documents ordered by title: title narrows by a fragment of the title, limit sets the page size (50 by default, 200 at most), and next in the result is the cursor to pass as cursor for the following page, null on the last. Each result carries the id get_document takes.
list_tagsThe tags in the key's scope: the names create_document and ask_corpus take as tags.
get_documentOne document, at its current version or a version you name.
create_documentWrite a new document. Every tag it carries must be inside the key's scope.
{
  "name": "query_expert",
  "arguments": {
    "expert": "publication-review",
    "goal": "reviewer_count",
    "facts": { "audience": "external", "contains_customer_data": "true" }
  }
}

What needs_facts means for the model

When query_expert comes back needs_facts, the model has one correct move: read needs, ask the person for exactly those concepts (nothing more, nothing guessed), and call query_expert again with them added. There is no partial answer to give in the meantime and no conclusion to infer from the facts already known; that's the whole point of a deterministic engine sitting in front of the model. A model that fills in a missing fact itself, or reports a conclusion the engine never reached, has broken the one guarantee the Corpus exists to give.


4. The refusal contract

Every query_expert call, over REST or MCP, returns one of four outcomes. Branch on outcome, never on the prose in reason or detail.

outcomemeansdo this
resolvedThe engine reached a conclusion. conclusion holds it; trace is the full derivation.Use conclusion. Read trace for the evidence; don't ask again.
needs_factsA rule needs a fact the engine can't derive and wasn't supplied. needs lists each one, typed.Get the missing facts and call again with them added.
out_of_scopeThe goal isn't a concept of this expert, or the key isn't scoped to reach it.Check the goal and the key's scope. This is not a retry case.
errorThe call itself was rejected before evaluation started: bad auth, a malformed body, a rate limit.Read error.code and error.hint in the response body (section 5), not this envelope.

5. Errors

An error response carries no envelope from section 4; it's a flat body with one error object: code, message, hint, and traceId, the id to quote if you ask for help.

{
  "error": {
    "code": "rate_limited",
    "message": "This key has exceeded its rate limit.",
    "hint": "Wait for the window in Retry-After, then retry the same request.",
    "traceId": "b3c1e9f2-1a2b-4c3d-8e9f-0a1b2c3d4e5f"
  }
}

Idempotency-Key

Every write (creating a document, a new version, a query, a replay) takes an Idempotency-Key header. Send the same key with the same body and you get back the original result, not a second write. Send the same key with a different body and the call fails with idempotency_key_reused: pick a new key per distinct request, a fresh one per retry only when the retry is really identical.

codeHTTP statusmeaning
unauthenticated401No key, or a key that doesn't parse. Check the Authorization header.
revoked_key401The key existed but has been revoked. Mint a new one.
rate_limited429The key's rate limit is exceeded. Retry-After names the wait.
not_found404The resource doesn't exist, or the key's scope doesn't reach it. The two look identical on purpose.
validation_failed422The request body doesn't match the schema. hint says which field.
idempotency_key_reused422The Idempotency-Key was sent before with a different body.
conflict409The write collided with the current state, for example publishing over a version that moved.
quota_exceeded429The organization used its plan's decisions, answers or indexed pages for the period (section 6). Retry-After names when the count resets.
internal500Something failed on the Corpus's side. Quote traceId if you report it.

6. Plans and limits

Every plan has every feature; Basic, Pro and Enterprise differ in how much of three things they include each month. There is no free plan. The month runs from the start of your billing period.

Decisions
One query_expert call, over REST or MCP, whatever its outcome. Reads, search and replay don't count.
Answers
One ask_corpus call (or POST /v1/answers) for every two parts of its question: a question the Corpus splits into three sub-questions counts two. It is counted once the question is understood and before any retrieval. A call that ends in needs_facts counts one decision instead.
Indexed pages
3,000 characters of a published version's text, attached files included, rounded up. Every version you publish is indexed again, so it counts again. A long document costs the same per page as a short one.
At the limit
Past decisions or answers, the call returns quota_exceeded (429) with Retry-After, and nothing is logged. With no pages left, publishing returns quota_exceeded and the version stays a draft; a version too long for the pages left stays unindexed until you publish it again. The workspace keeps working, and warns at 80% and at 100%.
Enterprise
Quantities and price agreed per customer, on a yearly contract. Write to us from the pricing page.

The quantities and prices of each plan