@felspar/sdk
Felspar is a shared, versioned decision system for enterprise agents. A company's approved meaning, the evidence a decision needs and the decision itself travel together in a released Decision Pack. Agents ask Felspar for a decision, get a receipt they can verify, and act only on what Felspar decided.
This package connects an agent to Felspar: an Eve connection in two lines, a TypeScript client for the hosted Felspar API, and an action gate that keeps an agent's tools from acting without a verified Felspar decision. It runs on Node.js 24 or later, and its only dependency is zod. The SDK is a transport client: decisions and policy stay with the Felspar service and the released pack, and the SDK does not reimplement them.
The modules under draft/ may change between minor versions while the API settles, before 1.0.
Install
npm install @felspar/sdk
Connect an Eve agent
In agent/connections/felspar.ts:
import { defineMcpClientConnection } from "eve/connections";
import { felsparConnection } from "@felspar/sdk/eve";
export default defineMcpClientConnection(felsparConnection());
The agent gets Felspar's list_jobs, get_job_requirements, get_job_decision and check_output tools and nothing else. The key is the agent's own, created on the Felspar web page (Workspace → Company agents) and read from FELSPAR_AGENT_KEY when Eve connects; the endpoint is FELSPAR_MCP_URL, or production. Pass { url, agentKey, description } to override them. The tools carry the method: get_job_requirements says what to gather and how, and each result says what to do next, so the agent needs no Felspar skill or prompt of its own.
Over MCP, get_job_requirements and get_job_decision answer compactly by default. The brief's input lists the arguments and a short facts example from the job's own questions. The decision keeps what the agent acts on, plus executionObjectId, the full receipt, the context capsule and explanation.evidenceFreshUntil, which is what the action gate records and verifies. detail: "full" returns the REST brief or result unchanged. The agent sends no execution ID: calling get_job_decision again with the same arguments within the same hour returns the decision already made, and blocked means that first call is still running. See MCP tools.
Hosted client
import { createHostedFelsparClient } from "@felspar/sdk/draft/hosted-client";
const felspar = createHostedFelsparClient({
origin: "https://api.felspar.ai",
agentKey: process.env.FELSPAR_AGENT_KEY,
});
// The jobs this agent key may run, by ID.
const { jobs } = await felspar.listJobs();
// What the job needs (questions, evidence rules, outcomes and their outputs),
// on the release Felspar runs for this key now.
const job = await felspar.describeCurrentJob("route-support-ticket");
const releaseId = job.release.releaseId;
// Facts in, decision out. The release pin comes from the job description.
const response = await felspar.resolveFacts(releaseId, {
jobId: "route-support-ticket",
executionId: crypto.randomUUID(),
entityId: "48213",
facts: {
request_type: {
text: "I was charged twice for my March invoice.",
source: { class: "ticket-thread", id: "48213", observedAt: new Date().toISOString() },
},
},
});
if (response.status === "ready") {
console.log(response.result?.outcome, response.result?.receipt);
}
One call: decide
Since 0.4.4; the rule, next step, hand-off words, receipt and open facts since 0.4.5.
When the same kinds of evidence arrive every time (a coding agent's tool call and its change record, a support ticket and its account record), label each piece by kind and let Felspar file it under the job's questions:
const decision = await felspar.decide({
job: "gate-deploys",
case: "toolu_01ABC", // the case's ID; leave it out where the job derives its IDs
evidence: [
{ kind: "tool-call", toolCall: { name: "Bash", input: { command: "kubectl apply -f deploy/prod.yaml" } } },
{ kind: "change-record", id: "CHG-1042", text: "CHG-1042: approved by Ana Ruiz; production" },
],
timeoutMs: 10_000, // the default; keep it inside your caller's own timeout
});
// { action: "allow" | "flag" | "block", reason, rule, nextStep, missing, escalation,
// resultId, receiptUrl, open, toProceed, outcome, receipt }
if (decision.action === "block") {
console.error(decision.reason, decision.nextStep, decision.toProceed);
}
decide sends one request, with no execution ID or release: Felspar uses the agent's release (Latest or pinned), and the same call in the same hour returns the same decision. A tool call goes as toolCall exactly as the agent's runtime reports it; Felspar reads it. action is what to do. It is allow or flag only when Felspar decided an outcome that means so, and block for everything else: a blocking outcome, missing evidence, a refusal, no answer within timeoutMs, or no connection. So code that acts only on action fails closed. reason is the policy's quoted rule or, in plain words, why there is no decision. nextStep is what the policy says to do now, escalation.text who takes it from here, and resultId and receiptUrl the decision's receipt. When Felspar couldn't decide, open lists each fact it couldn't settle with a plain-words label, and toProceed says what evidence would settle it. Fields an older Felspar doesn't send come back null or empty. decide never throws, and retries once within the deadline when Felspar is busy or the same call is still being decided. Pass shadow: true when you won't act on the decision; Felspar records it. Each kind must be one the job's description lists under a question's accepted sources.
describeJob(releaseId, jobId) describes a release you already know, such as one you pinned.
Facts can come straight from a system of record, with no agent in between: send each question's evidence as the source's own words (text, such as a ticket's body or an account record's "Plan: Business (monthly)") with where it came from (source.class, source.id, source.observedAt). Felspar classifies those words for answer-choice and yes/no questions; a bare label such as { value: "business" } is refused with label_needs_mapping, because the released question decides what the words mean. Number and date questions take a typed { value } (a date as YYYY-MM-DD). Only the job's own question keys are accepted (unknown_question otherwise).
Words a person gave the agent (a line from a call, a message) are evidence too: send them as given with source: { class, manual: true, locator }, naming in locator the source they came from, and no id or observedAt; Felspar stamps the time it received them, and the result and its receipt list them under manualFacts. A job whose description carries manualFacts: { allowed: false } refuses them (manual_facts_refused): quote the source record itself.
verifyReceipt(executionObjectId, { receipt, expectedReceiptDigest, deliveredCapsule }) asks Felspar to replay a decision and compare its receipt, and checkOutput(job, { executionId, text, output? }) checks text written for one of your decisions (readOutputCheck(job, checkId) reads a check back).
How a case is identified comes from the job's description (identity): send entityId for the record's own ID (provided), identity field values (fields), or nothing (content, and the first decision of an issued job). The result's case gives the case's id and mode; send case.id as entityId for every later decision on the same case. Results are typed (FelsparJobResult): case, subject with its related records, escalation (id, trigger, why) and missing for a gap.
A refused request throws FelsparRequestError with the HTTP status and the service's refusal (its error code, where there is one a fix and a recovery of fix_input, retry or escalate, and any escalation it names, such as for attempt_limit_reached).
Action gate
The gate sits in front of a tool that acts, such as writing an email draft or replying to a ticket. It allows a call only when the call's fields are allowed, the call is about a case Felspar decided on in this session, Felspar re-verifies that decision's receipt, and the decided outcome permits the tool. Any error, and anything unknown or ambiguous, denies.
import {
createActionGate,
felsparJobDescriber,
felsparOutputCheckVerifier,
felsparVerifier,
} from "@felspar/sdk/draft/action-gate";
const felspar = { origin: "https://api.felspar.ai", agentKey };
const gate = createActionGate({
job: "route-support-ticket",
decisionTool: "felspar__get_job_decision", // exact tool names in your runtime
outputCheckTool: "felspar__check_output",
tools: {
helpdesk__reply: {
writes: "billing-team-reply",
entity: { field: "ticketId" },
fields: ["ticketId", "body"],
outputCheck: { fields: ["body"] },
},
},
verify: felsparVerifier(felspar),
describe: felsparJobDescriber(felspar),
verifyOutputCheck: felsparOutputCheckVerifier(felspar),
});
What a tool may do comes from the released pack, not from the gate's config:
writesnames the output the tool writes, by its id in the pack. The call is allowed only when the released job description lists that output under the outcome Felspar's replay reports. (A rule may giveoutcomes, a list of outcome ids, instead; exactly one of the two.)- Which case a call is about comes from the released job's subject and identity. A decision is recorded by the
case.idFelspar's result names (so an agent that sends noentity_id, for an issued, fields or content job, still gets one), and Felspar's replay must name the same case ID and mode. By default:- for a job whose subject is a provided, case-insensitive ID such as a web domain (or a release with no subject), every recipient's email domain must belong to the one company Felspar decided on; a decided ID that isn't a company domain allows no recipient, so give such a rule an entity field;
- for any other job, the call is about the one case decided in the session for this job; with several, the call is denied until the rule names a field.
entity: { field }overrides that: the input field must hold the case's ID, the record's own ID (such as a ticket ID) or thecase.idFelspar issued or derived, trimmed and matched by the subject's match rule (exact unless the subject is case-insensitive; with no subject,HC-1201andhc-1201are two cases). Recipients are then not tied to the case, so a tool that can address anyone should also limit them (testRecipients, or the tool's own settings).outputCheckrequires that Felspar passed exactly the text the call sends for that decision (check_output); withwrites, for that output.
A denial is { allow: false, reason, escalate?, escalation? }. escalate is the company's next step, quoted from the released description: the outcome's next step when the outcome doesn't permit the tool, or what to do when nothing is decided. For a missing or failed output check it is the fixed instruction to fix what check_output flagged and check again. escalation is the hand-off the policy names: { id, trigger, why }, with the id from Felspar's replay of the outcome (or the job's unresolved path) and why quoted from the release. Both are absent when the release doesn't say. Every adapter below shows them to the model with the reason.
Then connect the gate to your agent framework. Tool names must match exactly as the runtime reports them.
Eve
import { felsparDecisionEvents, felsparGate } from "@felspar/sdk/eve";
// On the connection whose tools act:
approval: felsparGate(gate),
// And in a hook that records Felspar's decisions and output checks:
export default defineHook({ events: felsparDecisionEvents(gate) });
Claude Agent SDK
import { felsparHooks } from "@felspar/sdk/draft/action-gate/claude-agent-sdk";
// Tool names are mcp__<server>__<tool>.
for await (const message of query({ prompt, options: { hooks: felsparHooks(gate) } })) {
// ...
}
OpenAI Agents SDK
import {
felsparDecisionOutputGuardrail,
felsparToolInputGuardrail,
} from "@felspar/sdk/draft/action-gate/openai-agents";
// sessionOf must return an ID unique to the conversation.
const sessionOf = (runContext) => (runContext.context as { conversationId: string }).conversationId;
// On the MCP server whose tools act:
toolInputGuardrails: [felsparToolInputGuardrail(gate, { sessionOf })],
// On Felspar's MCP server:
toolOutputGuardrails: [felsparDecisionOutputGuardrail(gate, { sessionOf })],
Outside an agent framework, checkAction({ rules, toolName, input, decisions, verify, describe }) returns the same verdict.
For production actions, set environment: "production" in the gate rules; use
"staging" for a staging gate. This option is unreleased after 0.4.3. A
configured gate denies other or missing environments, including staging decisions
whose release was later promoted. Omitting it preserves the existing behavior
without environment restrictions. Keep the setting host-owned; custom verifiers
must return the environment Felspar confirms. A releases pin alone does not
establish the decision's environment.
A Felspar receipt records what Felspar decided from the evidence it was given. It is not a factual, legal or security certification, and a passed output check means the text follows the released rules, not that it is true or approved to send.
Examples
felspar-examples has complete projects: an Eve agent, the same agent with email drafts behind the action gate, and a script that gets a decision with no agent.
Documentation
Full documentation: docs.felspar.ai.
License
Apache-2.0
Decision diagrams for people
The hosted client's getReleaseGraph(releaseId), getProposalGraph(proposalId)
and getDecisionGraph(executionObjectId) read canonical Mermaid diagrams with
release/material pins and a dashboard link. These inspection methods require a
person's session and current source access; agent keys are refused. A decision
graph reproduces the saved receipt on its original release. Check status before
using mermaid: unavailable history is not an inferred path. Display the returned
source unchanged.
Use getReleaseDiagram, getProposalDiagram or getDecisionDiagram for the
compact overview. Each accepts { detail: "overview" | "full" }; the decision
helper also accepts { view: "run" | "release" }. Check availability, display
mermaid and the complete outcome next steps in overview, and offer the
original graph under canonical. The original graph helpers are unchanged.