Seal

Quickstart

Gate a risky agent action behind a human approval — run it locally with no account, or wire the SDK to the hosted API and approve in Slack.

Seal puts a human in the loop for the actions your agent should not take alone. A policy decides whether a human is needed, the call pauses until someone answers, and every decision is written to a tamper-evident audit chain.

There are two ways in. Pick the one that matches where you are.

Start with no account

If your agent reaches its tools over MCP, you do not have to sign up for anything. The proxy runs the policy engine, the approval prompt and the audit log on your own machine:

npx @seal-dev/mcp-proxy --config seal.config.ts

Your agent's code does not change — you point the MCP client at the proxy instead of at the upstream server. See Local mode for the config, the terminal prompt and --verify.

Local mode is one developer at one terminal, and it is complete for that. A second reviewer, an agent running in CI, or an auditor who needs more than a JSONL file is what the hosted product is for — and switching is one added block in the same config.

Or gate a call from your own code

The rest of this page uses the SDK against the hosted API, which is the path to take when the call you want to gate is a function rather than an MCP tool. It ends with your first Slack approval.

1. Install the SDK

npm install @seal-dev/sdk

The SDK is ESM + CJS and ships its own types. It talks to the REST API over fetch, so it runs on Node 18+ or any runtime with a global fetch.

2. Create a gate

You need two things: an API key with the approvals:write and approvals:read scopes, and the base URL of your Seal API.

gate.ts
import { createGate } from "@seal-dev/sdk";

export const gate = createGate({
  apiKey: process.env.SEAL_API_KEY!,
  baseUrl: process.env.SEAL_BASE_URL!, // e.g. https://api.your-org.com
});
.env
SEAL_API_KEY=sk_live_...
SEAL_BASE_URL=https://api.your-org.com

3. Require approval before acting

Wrap the risky action. require() creates the approval, then long-polls until it settles. It resolves with the approved request and throws a typed error on rejection, expiry, or timeout — so the happy path is a straight line.

transfer.ts
import { ApprovalRejectedError } from "@seal-dev/sdk";

import { gate } from "./gate.js";

async function wireTransfer(to: string, amount: number) {
  try {
    await gate.require({
      action: "wire.transfer",
      policyKey: "payments.high_value",
      payload: { to, amount },
      context: { reason: "Vendor invoice #4471 auto-paid by finance agent" },
    });

    // Reached only after a human approves.
    await bank.send({ to, amount });
  } catch (err) {
    if (err instanceof ApprovalRejectedError) {
      console.log("Rejected:", err.feedback);
      return;
    }
    throw err;
  }
}
  • action — what the agent wants to do.
  • policyKey — which policy decides the verdict.
  • payload — the data the policy evaluates and the reviewer sees.
  • context — the agent's reasoning, shown to the reviewer (never evaluated).

4. Wire the policy

A policy attached to payments.high_value decides whether this action needs a human, and who approves. The example below auto-allows small transfers and routes anything over 1000 to Slack:

{
  "match": { "action": "wire.transfer" },
  "rules": [
    {
      "when": [{ "field": "payload.amount", "op": "gt", "value": 1000 }],
      "then": "require_approval"
    },
    { "when": [], "then": "allow" }
  ],
  "approvers": {
    "channel": "slack",
    "users": ["U0123ABC"],
    "teams": [],
    "escalation": [],
    "timeoutSeconds": 900,
    "onTimeout": "deny"
  }
}

If no policy matches, Seal fails closed: the verdict is require_approval, never allow. You can never accidentally ship a permissive default.

Policies are managed in the dashboard. For a local dev instance, pnpm db:seed installs exactly this policy under the key payments.high_value.

5. Approve in Slack

Call wireTransfer("acct_999", 5000). Because 5000 > 1000, the policy returns require_approval, a card appears in Slack with Approve / Reject, and your require() call is still awaiting. Click Approve — the promise resolves and the transfer runs. Reject it and ApprovalRejectedError is thrown with the reviewer's feedback.

That is the whole loop. Next:

On this page