---
title: The pre-action gate
description: Check in code, before a tool acts, that Felspar decided on this case, that the released pack lets the outcome do this, and that the text is what Felspar checked.
sidebar:
  order: 9
---

An agent's model decides which tools to call. A model can be wrong, can misremember what Felspar said, or can be talked into something by text on a web page or in a ticket. So the decision to *act* shouldn't rest on the model's word.

The **pre-action gate** sits in front of a tool that acts, such as one that replies to a ticket, writes an email draft or updates a record. Before the tool runs, the gate checks in code that Felspar decided on this exact case, that the released pack lets that outcome do this, that Felspar still confirms the receipt and, where required, that Felspar passed exactly the text being sent. Anything else is denied, and the model is told why and what to do instead.

The principle: **whatever acts, verifies.**

## What the gate checks

A gated call is allowed only when all of these hold:

1. **Fields.** The call sets only the top-level fields its rule allows (matched without case), and none the gate refuses. A field the rule doesn't list, such as an attachment URL or a scheduled send time, denies the call.
2. **One case.** The call is about exactly one case Felspar decided on in this session (below).
3. **Still fresh.** The decision's evidence hasn't passed its freshness limit (`evidenceFreshUntil`).
4. **Verified now.** Felspar re-verifies that decision's receipt at the moment of the call, and its replay names the gate's job, that case and, if you restricted releases, an allowed release.
5. **The pack allows it.** With `writes`, the released job description lists that output under the outcome Felspar's replay reports. With `outcomes`, the outcome is one the rule lists.
6. **Checked text.** With `outputCheck`, Felspar's `check_output` passed exactly the text the call sends, for that decision (and, with `writes`, for that output).

Any error inside the gate, including a failed verification call or an unreadable job description, denies. The gate makes no model calls. Decisions and output checks come only from Felspar's own tool results, recorded as Felspar returned them, never from the model's account.

## Which case a call is about

- **A record** (a ticket, a contract, a candidate): give the rule `entity: { field }`. That input field must hold exactly one ID: the decision's `entity_id`, trimmed and otherwise exact, case included, the way Felspar keys cases (`HC-1201` and `hc-1201` are two cases). Recipients are then not tied to the case, so a tool that can address anyone should also limit them, with `testRecipients` or the tool's own settings.
- **A company**, by default: every recipient is one plain address at one company Felspar decided on: the exact domain or a subdomain, compared without case. Public suffixes and shared providers (`co.uk`, `github.io`, `gmail.com` and similar) are never treated as a company. One company per call.

### From SDK 0.2.1: the case from the job's subject

From `@felspar/sdk` 0.2.1, the gate reads which case a call is about from the released job's [subject and identity](/concepts/subjects-and-escalations#identity-modes), and supports jobs whose case IDs Felspar issues or derives:

- Each decision is recorded by the `case.id` its result names, so a decision the agent asked for without an `entity_id` (a `fields` or `content` job, or the first decision of an `issued` one) still counts. Felspar's replay must name the same case ID and identity mode.
- Without `entity`, a job whose subject is a provided, case-insensitive ID such as a web domain (or a release with no subject) takes the company from the recipients, as above. A decided ID that isn't a company domain then allows no recipient.
- Without `entity`, any other job's call is about the one case decided in the session for this job. With more than one, the call is denied until the rule names a field.
- With `entity: { field }`, the field holds the record's own ID or the `case.id` Felspar returned, matched by the subject's match rule.

SDK 0.2.0 needs an `entity_id` on every decision, keys cases only as described above, and ignores decisions made without one.

## Configure a gate

The gate is part of `@felspar/sdk` (0.2.0 or later). Its rules are the same in every runtime; only the adapter differs. This one guards a help desk's reply tool for a support-triage job:

```ts
import {
  createActionGate,
  felsparJobDescriber,
  felsparOutputCheckVerifier,
  felsparVerifier,
} from "@felspar/sdk/draft/action-gate";

const felspar = { origin: "https://api.felspar.ai", agentKey: process.env.FELSPAR_AGENT_KEY };

const gate = createActionGate({
  job: "triage-ticket",
  // Felspar's tools, by their exact names in this runtime.
  decisionTool: "felspar__get_job_decision",
  outputCheckTool: "felspar__check_output",
  tools: {
    helpdesk__reply: {
      // The output this tool writes, by its id in the released pack.
      writes: "billing-team-reply",
      // The case is the ticket in this field.
      entity: { field: "ticketId" },
      // Every field the tool may set. Anything else denies.
      fields: ["ticketId", "body"],
      // The body must be exactly what check_output passed.
      outputCheck: { fields: ["body"] },
    },
  },
  verify: felsparVerifier(felspar),
  describe: felsparJobDescriber(felspar),
  verifyOutputCheck: felsparOutputCheckVerifier(felspar),
  onVerdict: (tool, verdict) => console.info(`[felspar gate] ${tool}: ${verdict.reason}`),
});
```

What a tool may do comes from the released pack, not from the gate's config. When the policy changes which outcomes ask for a reply, a new release changes the gate's answer with no code change.

### Gate options

| Option | Meaning |
| --- | --- |
| `job` | The job the decision must be for |
| `decisionTool` | Felspar's `get_job_decision` by its exact full name in your runtime |
| `outputCheckTool` | Felspar's `check_output` by its exact full name, when a rule requires an output check |
| `tools` | The tools to gate, by exact full name, each with a rule (below) |
| `refuse` | Fields that deny a call even if allowed |
| `recipientFields`, `subjectFields` | Which input fields hold recipients and the subject. Defaults: `to`, `cc`, `bcc`, `replyTo`, `reply_to`; and `subject`. |
| `sharedDomains` | More domains no single company owns, added to the built-in list |
| `releases` | If set, only decisions from these releases allow a call |
| `testRecipients` | Test mode: the only recipients allowed |
| `verify` | How receipts are verified. `felsparVerifier` calls Felspar's verify endpoint with the agent's own key. |
| `describe` | How the gate reads the released job description, for `writes` and for escalation text. `felsparJobDescriber` reads it from Felspar and caches each release's description. |
| `verifyOutputCheck` | How output checks are verified. `felsparOutputCheckVerifier` reads the check back from Felspar. |
| `onVerdict` | Told every verdict, for logging. Its errors never change a verdict. |

### Tool rules

| Field | Meaning |
| --- | --- |
| `writes` | The output the tool writes, by its id in the pack. Allowed only when the released description lists it under the decided outcome. Give this or `outcomes`, not both. |
| `outcomes` | A list of outcome ids that allow the tool, for a tool that writes no declared output |
| `fields` | Every top-level input field the tool may set |
| `entity` | `{ field }`: the input field holding the case's ID, for a case that is a record (from SDK 0.2.1, also a `case.id` Felspar issued or derived). Without it, recipients pick the company; from SDK 0.2.1, the job's subject decides (above). |
| `outputCheck` | `{ fields, output? }`: the input fields whose text Felspar must have passed for this decision. With `writes`, the check must be for that output. |
| `recipientFields`, `subjectFields` | Per-tool overrides of the gate's fields |

A rule that gives both `writes` and `outcomes`, or neither, denies every call rather than guessing.

## Denials and escalation

A denial is `{ allow: false, reason, escalate? }`. `escalate` is the company's next step, quoted from the released description:

- the decided outcome's next step, when the pack doesn't let that outcome do this;
- what to do when nothing is decided (`whenUnresolved`), when Felspar left the case undecided, or when there is no decision for it at all and `releases` names a single release;
- for a missing or failed output check, the fixed instruction: "Fix what check_output flagged, check it again, then retry."

It is absent when the description doesn't say.

From SDK 0.2.1, a denial also carries `escalation: { id, trigger, why }` when the policy names a hand-off: the one Felspar's replay names for the decided outcome, or the unresolved path's (with a single pinned release, when there is no decision). `why` is quoted from the release. Route the case by its `id`.

Every adapter shows the model the reason followed by `Next step:` and, from SDK 0.2.1, `Escalation <id>`. The Felspar skill tells the agent to follow that step, never to rephrase or retry its way around the gate.

## Wire it into your runtime

| Runtime | Tool names look like | Wiring |
| --- | --- | --- |
| [Eve](/integrations/eve) | `helpdesk__reply` | `approval: felsparGate(gate)` on the acting connection, and a hook with `felsparDecisionEvents(gate)`, both from `@felspar/sdk/eve` |
| [Claude Agent SDK](/integrations/claude-agent-sdk) | `mcp__helpdesk__reply` | `query({ prompt, options: { hooks: felsparHooks(gate) } })` |
| [OpenAI Agents SDK](/integrations/openai-agents-sdk) | the tool's own name | `felsparToolInputGuardrail` on the acting server, `felsparDecisionOutputGuardrail` on Felspar's |
| Plain code | any | `await checkAction({ rules, toolName, input, decisions, verify, describe })` returns the same verdict |

For decisions you got through REST rather than the MCP tool, record each one with `gate.decisions.record(sessionId, entityId, result)` so the gate can find it. From SDK 0.2.1, pass `{ entity, job }` instead of the entity ID to record the job too, and leave `entity` out for a case Felspar identified.

## Test mode

While you test, set `testRecipients` to your own addresses. Calls may then go only to those addresses. For a company case, the subject must name the one company domain Felspar decided on. Everything else is checked the same way.

## What the gate doesn't do

- It doesn't judge wording itself. With `outputCheck`, it requires that Felspar's `check_output` passed the exact text; a pass means the text follows the released rules, not that it is true or approved to send.
- It only guards the tools you list. An agent that never calls Felspar, or a tool you didn't gate, isn't governed.
- It doesn't act on escalations. Felspar names the hand-off; your code decides what it does.
- For actions outside an agent framework, such as a nightly job that updates records, verify the receipt yourself with [`POST /v0/executions/{id}/verify`](/guides/verify-receipts) before acting, or call `checkAction`.

## About the SDK

`@felspar/sdk` is published to npm under Apache-2.0. Its modules under `draft/` may change between minor versions until 1.0. Everything the gate does rests on REST calls that work today from any language: receipt verification, the release's job description and output checks. See [Verify receipts](/guides/verify-receipts).
