---
title: REST API
description: Felspar's REST routes - authentication, the agent routes, authoring and review routes, the decision result, and request formats.
sidebar:
  order: 2
---

Base URL: `https://api.felspar.ai`. All routes are versioned under `/v0`.

> This page is written by hand from the service's routes and contracts. A generated reference from a reviewed OpenAPI contract is coming and will replace it.

## Authentication

| Caller | Credential |
| --- | --- |
| Registered agent | `Authorization: Bearer <agent key>` |
| Person, in the web app | The web app's session cookie. Writes from a browser must come from the app's own origin. |

Every request checks the credential, the caller's current membership and grants. There is no cross-origin browser access. Responses are never cached.

## Request bodies

- JSON bodies up to 2 MiB.
- `application/x.felspar-content-parts` bodies up to 4 MiB. This compact format, used by the SDK and the web app, sends repeated text once; Felspar verifies every part against its fingerprint before use.

## Agent routes

These are the routes a registered agent uses. An agent can call them only for jobs it is registered for.

| Method and path | Does |
| --- | --- |
| `GET /v0/jobs` | The jobs this agent key may run, as `{ "jobs": [{ "id" }] }`: those it is registered for, followed or pinned, with a live release. Agent keys only; a revoked key gets 403. |
| `GET /v0/jobs/{job}/brief` | Everything the job needs: purpose, use-when, not-for, owner, subject, identity, questions, quoted meanings, answers, where to look, outcomes, next steps, escalations, attempt limit, request format |
| `POST /v0/jobs/{job}/decisions` | Decide one case from facts. Felspar picks the release (Latest or the agent's pin). |
| `POST /v0/jobs/{job}/output-checks` | Check text written for one of the caller's own decisions: `{ executionId, text, output? }`. Returns `pass`, `revise`, `review_required` or `unavailable`. |
| `GET /v0/jobs/{job}/output-checks/{checkId}` | Read one of the caller's own output checks back, for an action gate |
| `POST /v0/executions/{executionObjectId}/verify` | Replay a decision and compare its receipt. Agents can verify only their own decisions. |

A person calling the job routes gets the job's newest release.

### Release-specific routes

To work against one exact release:

| Method and path | Does |
| --- | --- |
| `GET /v0/releases/{releaseId}/describe?job={job}` | The brief for that release |
| `POST /v0/releases/{releaseId}/jobs` | Decide one case on that release |

## Decision request

A fictional support desk's `triage-ticket` job, whose subject is a ticket with one related customer:

```json
{
  "mode": "facts",
  "executionId": "a-new-uuid",
  "jobId": "triage-ticket",
  "entityId": "48213",
  "related": { "customer": "cus-9f2" },
  "expectedReleaseDigest": "sha256:... (optional on job routes)",
  "facts": {
    "topic": {
      "text": "I was charged twice for the October invoice.",
      "source": { "class": "ticket", "id": "48213", "locator": "48213#message-1", "observedAt": "2026-10-21T09:00:00Z" }
    },
    "premium": {
      "text": "Plan: Premium (yearly), account manager: assigned",
      "source": { "class": "account", "id": "cus-9f2", "observedAt": "2026-10-20T00:00:00Z" }
    }
  }
}
```

- `entityId`: the case's ID, as the brief's `identity.send` and `subject.send` say. Felspar normalizes it by the subject's match rule (trimmed; lowercased too for `case-insensitive`) before it counts attempts, keeps history or writes a receipt. Required when the job uses the record's own ID (`provided`, and every job released before identity modes). Leave it out for a `fields` or `content` job, and on the first decision of an `issued` job; afterwards send the `case.id` Felspar returned. See [identity modes](/concepts/subjects-and-escalations#identity-modes).
- `identity`: only for a `fields` job, each identity field's value by name, such as `{ "email": "sam@example.com", "requisition": "REQ-114" }`. Felspar derives the case ID from them.
- `related`: only for questions `about` a related record. Each relationship's record ID, or a list of IDs for a `many` relationship; then each excerpt for that question names its record in `about`. At most 16 related IDs.

- `executionId`: unique per decision. Resending the same ID with the same body returns the original result and is never decided twice. A different body with a used ID is refused (`execution_id_reused`). A request whose earlier attempt is still running or unknown is never re-run automatically.
- `facts`: per question, one excerpt, a list of up to 3, or (number and date questions only) a typed `{ value, source }`. A typed value from a CRM looks like `{ "value": 1200, "source": { "class": "crm-record", "id": "acct-881", "observedAt": "..." } }`. `source` may also carry `excerpt`, the exact words that state the value.
- `expectedReleaseDigest`: optional on `/v0/jobs/{job}/decisions`; if left out, the decision is bound to the release Felspar chose, which the result records. Required on `/v0/releases/{id}/jobs`.

## Decision result

```json
{
  "status": "ready",
  "reason": null,
  "result": {
    "executionId": "a-new-uuid",
    "executionObjectId": "execution-...",
    "resultId": "result-...",
    "releaseId": "release-...",
    "releaseDigest": "sha256:...",
    "evaluationTime": "2026-10-21T09:00:03.120Z",
    "evidenceMode": "felspar-interpreted",
    "outcome": "billing",
    "scoring": null,
    "context": null,
    "subject": { "type": "ticket", "id": "48213", "related": [{ "relationship": "customer", "subject": "customer", "id": "cus-9f2", "questions": ["premium"] }] },
    "receipt": { "receiptDigest": "sha256:...", "...": "..." },
    "explanation": { "...": "see below" },
    "diagnostics": [],
    "interpretation": { "...": "how each excerpt was read", "judgmentsDigest": "sha256:...", "modelCalls": 3 }
  }
}
```

| Field | Meaning |
| --- | --- |
| `status`, `reason` | `ready` when a rule decided. Otherwise the most specific cause, such as `gap`, `stale`, `conflict` or a context reason. |
| `result.outcome` | The outcome, or `null` |
| `result.explanation.decision` | The decision's own status and reasons |
| `result.explanation.why` | The deciding rules, quoted from the policy, with any overrides |
| `result.explanation.needs[]` | Each open question: accepted source classes, maximum age, how each excerpt was read |
| `result.explanation.reviewNeeds` | Review the pack requires |
| `result.explanation.evidenceFreshUntil` | When the supporting evidence stops being fresh |
| `result.retry[]` | When undecided: only the open questions, each with `fix`, `notUsed`, the answers Felspar can choose and where to look |
| `result.attempts` | When undecided: `used`, `remaining`, `next` (`retry` or `escalate`) |
| `result.nextStep` | When undecided: the pack's "when unresolved" instruction, quoted |
| `result.interpretation` | Per excerpt: method, value, leading answer, probability, threshold, close alternatives, whether cached; plus `judgmentsDigest` and `modelCalls` |
| `result.outputs` | When decided: the outputs this outcome asks the agent to write, each with its approved `context` and `checks`. Missing or empty means write nothing. |
| `result.case` | `{ id, mode }` for a job released with identity modes: the case ID Felspar used (the record's own ID normalized, derived from fields or content, or issued) and the `mode`: `provided`, `fields`, `content` or `issued`. Send `case.id` as `entityId` for every later decision on the case. |
| `result.subject` | `{ type, id, related[] }`: the normalized case ID and, for each related record, the questions it supplied evidence for |
| `result.escalation` | `{ id, trigger, why }` when the release names a hand-off for this result: `trigger` is `outcome`, `unresolved` or `attempt_limit`, and `why` quotes the policy. Felspar names it and stops; your code acts on it. |
| `result.missing` | With status `gap`: required related records the request lacked, such as `["missing: counterparty"]`. Nothing was decided and there is no receipt. |
| `result.receipt` | The receipt. Verify it before acting. |
| `result.context` | Context the release allows for this outcome, if any |

The next step for a decided outcome is in the brief's `outcomes[].nextStep`.

A wrong or missing case ID is refused with one of the [identity refusals](/reference/refusal-codes#identity-refusals). A caller may open at most 200 new issued cases per job in any 24 hours (`issued_case_limit`).

## Verify request and result

```json
{ "receipt": { "...": "result.receipt" }, "expectedReceiptDigest": "sha256:...", "deliveredCapsule": null }
```

Result: `{ "authority": "unapproved", "status": "reproduced" | "mismatch", "mismatches": [], "decided": { "jobId", "entityId", "releaseId", "outcome", "case", "subject", "escalation" } }`. `case` (`{ id, mode }`) is recomputed from the retained request by the release's identity mode; `subject` (`{ type, related }`) and `escalation` (`{ id, trigger }`) are re-derived from the release and the replayed outcome. Each appears when the release declares it. See [Verify receipts](/guides/verify-receipts).

## Authoring and review routes

For signed-in people. The authoring MCP tools call these same routes.

| Method and path | Permission | Does |
| --- | --- | --- |
| `GET /v0/sources` | Inspect | Connected documents, with your access to each and watch state |
| `POST /v0/sources` | Add sources | Admit a source from supplied text (not available to an MCP sign-in) |
| `GET /v0/sources/{id}` | Inspect, plus your own document access | The exact stored source |
| `POST /v0/sources/{id}/current` | Add sources | Read the document's current version with your own connection and admit it as a new source |
| `POST /v0/drafts/check` | Propose | Check a `felspar.pack-draft` (v2, v1 or v0) against a source; saves nothing. Lists `breaking` changes against the current release. |
| `POST /v0/drafts/propose` | Propose | Check and submit as a proposal; returns the review link and any `breaking` changes |
| `POST /v0/proposals` | Propose | Submit a full proposal (advanced) |
| `GET /v0/proposals` | Inspect | The 25 newest proposals with their approvals and releases, and the 25 newest releases |
| `GET /v0/proposals/{id}` | Inspect, plus document access | One proposal |
| `POST /v0/proposals/{id}/backtest` | Propose and inspect, plus document access; never an agent | Backtest the proposal: `{ reinterpret?, executionIds?, limit? }`. Reports each side's escalation per case, `escalationChanged` and `breaking`. |
| `POST /v0/proposals/{id}/approve` | Approve (web app) | Record approval of that exact proposal |
| `GET /v0/approvals/{id}` | Inspect, plus document access | One approval |
| `POST /v0/approvals/{id}/release` | Release (web app) | Release that exact approval |
| `GET /v0/releases/{id}` | Inspect, plus document access | The verified release bundle |
| `GET /v0/runs/{id}` | Inspect | A compact decision result |
| `GET /v0/executions/{id}` | Inspect | The retained material needed to replay a decision |

Approval and release are used by the web app's review pages. An MCP sign-in and agent keys can never call them.

## Errors

Every deliberate refusal is JSON:

```json
{
  "error": "too_many_excerpts",
  "path": ["facts", "topic"],
  "issues": [
    { "code": "too_many_excerpts", "path": ["facts", "topic"], "fix": "Send at most 3 excerpts per question." },
    { "code": "unknown_question", "path": ["facts", "severity"], "fix": "This question is not in the released job. Use the question keys from describe." }
  ]
}
```

Every mistake in a request is reported together, so you can fix them all in one pass. Refusals name codes and paths, never your submitted values or source text. Anything not deliberately described is `403 operation_unavailable`. See [Refusal codes](/reference/refusal-codes).

## Health

`GET /health` answers when the service is running. It says nothing about your workspace.
