Developer docs

Ship AI Act controls in 60 seconds

Copy one line: AI notice, call receipts without prompt text, human approvals.

Not a developer?24h Done-For-You Setup · €199

spurvik 1.0.8spurvik-mcp-server 1.0.4MIT on npmHosted in Frankfurt

Install in 60 seconds

Pick your stack. One copy, then see it live in the sandbox.

01Claude · Cursor · Codex

AI coding agents

Then ask: “Use the eu-ai-act skill on this project.”

Agent skill
npx skills add spurvik/skills
Remote MCP (Streamable HTTP)
https://console.spurvik.com/api/mcp

MCP Registry com.spurvik/eu-ai-act · 7 read-only tools · no API key

02HTML · Shopify · Webflow

1 line website badge

Visible AI notice in 24 EU languages. No cookies.

Before </body>
<script src="https://spurvik.com/badge.js" data-system="Support AI" defer></script>

Free shows a small “Art. 50 · Spurvik” link. Pro (€19/mo) removes it with data-attribution="false".

03Next.js · React

shadcn & v0 registry

Adds <Article50Badge />, the same notice as a component.

shadcn CLI
npx shadcn@latest add https://spurvik.com/r/article-50-badge.json

Pro white label: attribution={false}

04Node · Vercel AI SDK · Python

Server SDK

Wrap your model client. Every call gets a keyed receipt.

npm
npm i spurvik ai @ai-sdk/openai

MIT · Node 20+ · Python and Go via the REST API

Packages, Registries & Endpoints

Instant copy cards
01

n8n Community Node

n8n-nodes-spurvik 1.0.4MIT
Install in n8n (community nodes)
n8n-nodes-spurvik@1.0.4
Install with npm
npm i n8n-nodes-spurvik@1.0.4
Import starter workflow URL
https://spurvik.com/r/n8n-art50-starter.json

In n8n, open Settings -> Community nodes and install n8n-nodes-spurvik@1.0.4, or use Import from URL with the starter workflow JSON (n8n node guide). The node type id is spurvikEuAiActGuard

The node can add an AI interaction notice to one text field, record a call as an HMAC SHA256 digest instead of the raw prompt text, and send an approval request. Prompt field names and the configured input field are not copied onto the node output, including when listed in passThroughFields. It does not mark content.

Channels: Slack, Telegram, Email (Resend), Discord, Microsoft Teams, Linear, Jira, Monday.com (item creation only; no Approve or Block links), and Asana. callbackBaseUrl is required for an approval request. The HTTP Request path in the channel panels below remains an alternative.

02

Runtime SDK (npm)

spurvik 1.0.8spurvik-sdk 1.0.8MIT
Install (current version)
npm i spurvik ai @ai-sdk/openai
Alias build (spurvik-sdk, same build)
npm i spurvik-sdk ai @ai-sdk/openai
  • Keyed HMAC SHA256 call receipts, 0 bytes of prompt text stored
  • Supports Vercel AI SDK, OpenAI, Anthropic, LangGraph & Mastra
  • Signed Approve and Block links, checked by verifyChannelApprovalToken()
  • ESM ready ("type": "module" or .mjs) · Node 20+
Setup notes: secret, ESM, TypeScript

Exports: ., ./ai, ./mastra, ./openai-agents, ./langgraph, ./channels, ./react. Source: github.com/spurvik/spurvik.

Set your own contentHash.secret (for example SPURVIK_CONTENT_HASH_SECRET, server side only) of at least 16 characters. Keyed hashing is the default, and setup throws if the secret is missing or too short. The secret stays on your side. Set it at build time too, because next build fails without it; on Vercel, add it to the project's environment variables.

Use spurvik@1.0.8 or later. It works with current ai and @ai-sdk/openai releases, so upgrade rather than pinning ai. Mastra is the exception: see its card for the @ai-sdk/openai pin.

The snippets use import and top level await. In a new project, run npm pkg set type=module after npm init -y, because current npm writes "type": "commonjs" and Node then stops with SyntaxError: Cannot use import statement outside a module. In an existing CommonJS project, name the file app.mjs instead. If a strict typecheck reports Cannot find name 'process', the same @types/node step below fixes it.

If a strict typecheck reports TS7016 for json-schema or Cannot find name 'Buffer', run npm i -D @types/json-schema @types/node and add "node" to "types" in your tsconfig.json compilerOptions. These errors come from the AI SDK's own type files, not your code. Next.js sets skipLibCheck: true by default, which avoids them. With Mastra or the OpenAI Agents SDK, set "skipLibCheck": true in compilerOptions yourself: their own type files report errors under a strict check, separate from your code.

03

Local MCP Server

spurvik-mcp-server 1.0.4stdioMIT
1. Install in your project (1.0.4)
npm i -D spurvik-mcp-server@1.0.4
2. Scan the repository for unwrapped model calls
./node_modules/.bin/spurvik-mcp check . --ci
3. Add GitHub Actions PR check
mkdir -p .github/workflows && curl -fsSL https://spurvik.com/r/github-action-spurvik-check.yml -o .github/workflows/spurvik-check.yml

Run inside your project root (a folder with package.json or .git). The heuristic scan makes zero network calls (Cursor, Claude Code & CI guide).

IDE MCP configs start the server with spurvik-mcp-server --root . (see the JSON configs).

Four tools over stdio, all read only. Node 20+.

04

Claude Code Plugin

Claude Codespurvik/spurvikMIT
1. Add marketplace
/plugin marketplace add spurvik/spurvik
2. Install plugin (marketplace)
/plugin install spurvik@spurvik-marketplace

Manifest: .claude-plugin/marketplace.json · plugins/spurvik.

05

v0 & shadcn Registry

EU AI Act StarterReact / Next.js
v0 Registry URL (console)
https://console.spurvik.com/r/spurvik-eu-ai-act-starter.json
shadcn CLI (console starter)
pnpm dlx shadcn@latest add https://console.spurvik.com/r/spurvik-eu-ai-act-starter.json
Site registry (/r/eu-ai-act.json)
https://spurvik.com/r/eu-ai-act.json

Renders visible Article 50(1) AI interaction notices across 24 EU languages.

06

Live Remote MCP (hosted in the EU)

Streamable HTTPRead only, no API keyHosted in the EU
Remote MCP Endpoint (POST)
https://console.spurvik.com/api/mcp
Discovery (/.well-known/mcp.json)
https://spurvik.com/.well-known/mcp.json

Protocol 2025-06-18. Discovery: spurvik.com · console.spurvik.com/.well-known/mcp.json. Tools: spurvik_setup_snippet, spurvik_find_unwrapped_calls, spurvik_explain_receipt, spurvik_marking_hooks

07

Live URL Scanner & Cockpit

Live SandboxPOST /api/v1/scan2x2 CockpitHosted in the EU
Live sandbox (no login)
https://console.spurvik.com/sandbox
Direct API (POST /api/v1/scan)
curl -s https://console.spurvik.com/api/v1/scan \
  -H "content-type: application/json" \
  -H "origin: https://console.spurvik.com" \
  -d '{"url":"https://your-app.com"}'

Scans public HTTPS surfaces for Art. 50(1)/(2)/(4) notices and keyed logs in the 2x2 Cockpit, or returns JSON for EU AI implementation agencies reviewing client domains.

08

AI Notice Badge (any website)

badge.jsAny HTML site24 EU languages
Add before </body>
<script src="https://spurvik.com/badge.js" data-system="Your AI" defer></script>

Works on Webflow, WordPress, Shopify and static HTML. Shows a small notice that people are interacting with an AI system and that replies are generated automatically, next to the name you set in data-system

Attributes: data-notice selects interaction (default), generated, deepfake, public-interest or emotion-biometric across all 24 official EU languages. data-system sets the name shown. data-locale picks the language (for example da). data-theme is dark (default), light or auto (follows OS prefers-color-scheme). data-position is bottom-right (default) or bottom-left. A small Art. 50 · Spurvik link is on by default; Pro can hide it.

Since badge.js 1.0.6 the notice is followed by a separate small Art. 50 · Spurvik link to https://spurvik.com/?utm_source=badge (rel="noopener noreferrer", target="_blank", with an aria-label). Pro and Team plans can use a white-label badge: add data-attribution="false" (or data-whitelabel="true") to the script tag or a [data-spurvik-badge] host, or call create({ attribution: false }). The link is styled through CSSOM only, so the documented CSP style-src fallback hashes are unchanged.

Inline mode: put <div data-spurvik-badge></div> where the notice should appear and load the script once. The badge renders inside each such element, and each one can set its own data-system, data-locale and data-theme. Without one, the badge floats in the corner set by data-position

Hiding and CSP: to hide the notice, set the hidden attribute on the badge element ([data-spurvik-art50]). This works in corner and inline mode. From your own CSS, visibility:hidden (the notice keeps its space) or display:none !important also hides it; plain display:none hides the corner badge but not the inline one. Styles load as constructable stylesheets, which a strict style-src policy doesn’t block. Older browsers without them fall back to a <style> tag, which needs 'unsafe-inline' or that style’s sha256 hash in style-src. In those older browsers, if your policy allows neither, the badge shows unstyled and hidden doesn’t hide it in inline mode. The hashes, by theme and position:

style-src hashes (fallback only)
dark, inline
'sha256-ZmybqEARjj9+js9RjdCmDyP8VG6jw5WLtUwuUP2juN8='
dark, bottom-right
'sha256-oMzvP9S2nDnqIg1b4u0HbYVwFhUTAe/XTV2FIpGzK2Q='
dark, bottom-left
'sha256-ZvRBwgeHtjMfFLhSYjrgt7ei7fs0rg63nRwvcpwRnyE='
light, inline
'sha256-BeLJnrhoGrG7v40cT13jYjKgrf2w1CcbFpbLbM+puts='
light, bottom-right
'sha256-dpe8+rePyzGf1XI+zN3mmZUBUD+vst6K0u2G3vpdskU='
light, bottom-left
'sha256-z7T6iUnQTCL0x5gdYnvDrDX/4iA1tVSvNFg1sx+9Cb8='

These hashes match the current build of the badge script. They change whenever its styles change, so check this card after an update.

Language order: data-locale, then your page’s <html lang>, then English. Default copy in 24 EU languages (review the wording before use): bg, cs, da, de, el, en, es, et, fi, fr, ga, hr, hu, it, lt, lv, mt, nl, pl, pt, ro, sk, sl, sv. The Danish default assumes a chat; for voice, set your own wording.

Privacy: no cookies, no storage, and no network calls beyond loading the script itself. Using React? Use AIDisclosureLabel from spurvik/react (spurvik@1.0.8) instead.

Designed to support Article 50 notice duties (Art. 50(1)/(3)/(4) and human-readable generated labels). You stay responsible for when and where it is shown. The badge is a visible notice only: it does not mark generated content and is not Article 50(2) machine readable marking.

09

Python & HTTP REST API

FastAPI · LangChainGo · PHP · cURL0B prompt textHosted in the EU
Python (stdlib hmac + httpx -> POST /api/v1/telemetry)
import hashlib, hmac, os, uuid
from datetime import datetime, timezone
import httpx

HASH_KEY = os.environ["SPURVIK_CONTENT_HASH_SECRET"].encode()  # >= 16 chars, stays on your server

def keyed_digest(text: str) -> str:
    return hmac.new(HASH_KEY, text.encode("utf-8"), hashlib.sha256).hexdigest()

async def log_spurvik_call(prompt: str, output: str, model: str = "gpt-4o", latency_ms: int = 120):
    ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
    async with httpx.AsyncClient(timeout=5.0) as client:
        r = await client.post(
            "https://console.spurvik.com/api/v1/telemetry",
            headers={"Authorization": f"Bearer {os.environ['SPURVIK_API_KEY']}"},
            json={"events": [{
                "eventId": str(uuid.uuid4()),
                "timestamp": ts,
                "modelProvider": "openai",
                "modelVersion": model,
                "inputHash": keyed_digest(prompt),
                "outputHash": keyed_digest(output),
                "contentHashMode": "keyed",
                "latencyMs": latency_ms,
                "temperature": 0.2,
                "contentMarkingApplied": False,
                "markingHookId": None,
                "hitlStatus": "not_required",
            }]},
        )
        r.raise_for_status()
Hosted Human Gate queue (POST /api/v1/hitl · Pro & Team)
curl -s https://console.spurvik.com/api/v1/hitl \
  -H "authorization: Bearer $SPURVIK_API_KEY" \
  -H "content-type: application/json" \
  -d '{"actionName":"stripe.refunds.create","payloadSummary":"{\"contentFingerprint\":null,\"keys\":[\"charge_id\",\"amount_cents\"],\"schema\":\"spurvik.payload_summary.v1\"}"}'

Works from Python (FastAPI, Django, LangChain, PydanticAI), Go, PHP, Ruby or Rust with zero extra SDK dependencies. Raw prompt or output fields are rejected at ingestion (400 raw_content_rejected); payloadSummary accepts canonical sorted JSON field names only (spurvik.payload_summary.v1). Pair with badge.js on your frontend for visible Article 50 notices.

10

Plans and onboarding

Prices exclude VAT. Stripe, our merchant of record (shown at checkout as Link), adds VAT where it applies. B2B only. Free €0/mo · Pro €19/mo · Team €79/mo (2M events/mo) · Enterprise from €499/mo.

What works right away, with no account?

  • The open source SDK: spurvik@1.0.8 and spurvik-sdk@1.0.8 on npm (MIT, Node 20+). It wraps your model calls, logs each one and attaches an HMAC SHA256 receipt made with a secret that stays in your process.
  • The React notice: AIDisclosureLabel from spurvik/react.
  • The AI notice badge for any HTML site: see the badge card.
  • The MCP server: spurvik-mcp-server@1.0.4 on npm (MIT, Node 20+, run with --root pointing at your project). See the MCP card.
  • The interactive sandbox at console.spurvik.com/sandbox, with no login.

What needs an account?

Hosted console workspaces on console.spurvik.com need an account, on every plan (Pro €19/mo, excl. VAT, B2B only). Create one at console.spurvik.com/signup. For Enterprise, email hello@spurvik.com from your work address with your company name, your VAT or CVR number, and the volume you need.

What do the hosted plans include?

  • Free: 10,000 events per month.
  • Pro: 250,000 events per month and 90 day retention in the EU (planned).
  • Human review gate: requireHumanApproval() in spurvik@1.0.8 (MIT) pauses an action until a reviewer decides in the hosted review queue (Pro and higher). If no reviewer decides in time, it stops with a timeout error (120 seconds by default). The queue records the decision, when it was made, who made it, and the reason given (not checked).

How do I keep staging and production apart?

API keys are scoped per project. Create a separate project for staging and another for production, each with its own keys.

Onboarding steps

  1. Open console.spurvik.com/signup and enter your name, work email and a password.
  2. When your workspace is ready, create a project for each environment you need, for example staging and production.
  3. In each project, create a spurvik_live_ API key. Keep it on the server.
  4. Install: npm i spurvik ai @ai-sdk/openai. For Mastra, see its card for the @ai-sdk/openai pin.
  5. Set four server side variables. SPURVIK_API_KEY holds the project key. SPURVIK_SIGNING_SECRET and SPURVIK_CONTENT_HASH_SECRET must each be at least 16 characters, or setup throws. SPURVIK_BASE_URL is https://console.spurvik.com.
  6. Pass riskTier. It is required, and it is your own classification: Spurvik does not classify your use case.
  7. Make one model call and check that the event appears in your project in the console.

Content hashing is keyed by default. The on mode uses unkeyed SHA256. A hash of personal data can still be personal data, so treat hashes accordingly.

Is Spurvik all I need for the AI Act?

No. Spurvik logs AI calls with HMAC receipts and gives you hooks for notices and marking. Its logs are evidence, not Article 50(2) machine readable marking; plug a marking tool into its hooks. Article 50 applies now. For generative systems already on the market before 2 August 2026, marking and detection are due by 2 December 2026 (Commission guidelines C(2026) 7702 final, para 153).

Your AI Act controls should live in a few lines of code, not in six months of legal memos.

Framework Quickstarts & IDE Configs

. · ./ai · ./mastra · ./openai-agents · ./langgraph · ./react
A

Core SDK (.)

spurvikwithSpurvikKeyed HMAC
server.ts — withSpurvik + provenanceOf + verifyProvenanceSignature
import OpenAI from "openai";
import {
  withSpurvik,
  SpurvikClient,
  provenanceOf,
  verifyProvenanceSignature,
} from "spurvik";

const config = {
  apiKey: process.env.SPURVIK_API_KEY!,
  signingSecret: process.env.SPURVIK_SIGNING_SECRET!,
  contentHash: { mode: "keyed" as const, secret: process.env.SPURVIK_CONTENT_HASH_SECRET! },
  baseUrl: process.env.SPURVIK_BASE_URL!,
  riskTier: "limited_art50" as const, // your own classification
  article50Disclosure: true,
};

const ai = withSpurvik(new OpenAI(), config);

const res = await ai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [{ role: "user", content: "Hello" }],
});
const record = await provenanceOf(res);
const ok = record
  ? await verifyProvenanceSignature(record, process.env.SPURVIK_SIGNING_SECRET!)
  : false;
const client = new SpurvikClient(config);

Exports: withSpurvik, SpurvikClient, provenanceOf, verifyProvenanceSignature

Install: npm i spurvik openai. riskTier is your own classification; Spurvik does not classify your use case.

B

Vercel AI SDK (./ai)

spurvik/aispurvikMiddleware
ai-sdk.ts — spurvikMiddleware + extractFinishReason + dedupeToolApprovalResponses
import { generateText, wrapLanguageModel } from "ai";
import { openai } from "@ai-sdk/openai";
import {
  spurvikMiddleware,
  provenanceFromMetadata,
  extractFinishReason,
  isInterruptedFinishReason,
  dedupeToolApprovalResponses,
} from "spurvik/ai";

const mw = spurvikMiddleware({
  apiKey: process.env.SPURVIK_API_KEY!,
  signingSecret: process.env.SPURVIK_SIGNING_SECRET!,
  contentHash: { mode: "keyed", secret: process.env.SPURVIK_CONTENT_HASH_SECRET! },
  baseUrl: process.env.SPURVIK_BASE_URL!,
  riskTier: "limited_art50",
  article50Disclosure: true,
});

const model = wrapLanguageModel({ model: openai("gpt-4o-mini"), middleware: mw });
const result = await generateText({ model, prompt: "Summarise Annex IV scope." });
const record = provenanceFromMetadata(result.providerMetadata);
const reason = extractFinishReason(result.finishReason);
const paused = isInterruptedFinishReason(reason);
const approvals = dedupeToolApprovalResponses([]);

Exports: spurvikMiddleware, provenanceFromMetadata, extractFinishReason, isInterruptedFinishReason, dedupeToolApprovalResponses

Install: npm i spurvik ai @ai-sdk/openai

C

Mastra (./mastra)

spurvik/mastrawithSpurvikMastra
mastra.ts — withSpurvikMastra + provenanceFromMetadata
import { Agent } from "@mastra/core/agent";
import { openai } from "@ai-sdk/openai";
import { withSpurvikMastra, provenanceFromMetadata } from "spurvik/mastra";

const spurvik = withSpurvikMastra(openai("gpt-4o-mini"), {
  apiKey: process.env.SPURVIK_API_KEY!,
  signingSecret: process.env.SPURVIK_SIGNING_SECRET!,
  contentHash: { mode: "keyed", secret: process.env.SPURVIK_CONTENT_HASH_SECRET! },
  baseUrl: process.env.SPURVIK_BASE_URL!,
  riskTier: "limited_art50",
  article50Disclosure: true,
});

const agent = new Agent({
  id: "support-agent",
  name: "Support Agent",
  instructions: "Be concise.",
  model: spurvik.model,
});
const out = await agent.generate("Draft customer note.");
const record = provenanceFromMetadata(out.providerMetadata);
await spurvik.flush();

Exports: withSpurvikMastra, provenanceFromMetadata

Install: npm i spurvik ai @ai-sdk/openai@4.0.99 @mastra/core. With @mastra/core 1.79 or 1.80, keep this pin: they ship their own @ai-sdk/provider version, and a newer @ai-sdk/openai makes a strict typecheck fail. @mastra/core 1.81 also typechecks with the current @ai-sdk/openai

D

OpenAI Agents

spurvik/openai-agentsHITL Gate
openai-agents.ts — withSpurvikModel + createSpurvikNeedsApproval
import { Agent, OpenAIProvider, run } from "@openai/agents";
import {
  withSpurvikModel,
  provenanceFromAgentsResponse,
  createSpurvikNeedsApproval,
} from "spurvik/openai-agents";

const config = {
  apiKey: process.env.SPURVIK_API_KEY!,
  signingSecret: process.env.SPURVIK_SIGNING_SECRET!,
  contentHash: { mode: "keyed" as const, secret: process.env.SPURVIK_CONTENT_HASH_SECRET! },
  baseUrl: process.env.SPURVIK_BASE_URL!,
  riskTier: "high_annex3" as const,
  article50Disclosure: true,
};

const baseModel = await new OpenAIProvider().getModel("gpt-4o-mini");
const model = withSpurvikModel(baseModel, config);
const needsApproval = createSpurvikNeedsApproval(config, { actionName: "issue_refund" }); // pass to your tool's needsApproval
const result = await run(new Agent({ name: "ops", model }), "Process refund #42");
const record = provenanceFromAgentsResponse(result.rawResponses.at(-1));
await model.flush();

Exports: withSpurvikModel, withSpurvikModelProvider, provenanceFromAgentsResponse, createSpurvikNeedsApproval, resolveAgentInterruptions

Install: npm i spurvik @openai/agents. Pass needsApproval to the tool that needs a human gate.

E

LangGraph (./langgraph)

spurvik/langgraphState & Interrupts
langgraph.ts — withSpurvikLangGraph + provenanceFromLangGraphState
import { StateGraph, MessagesAnnotation, START, END } from "@langchain/langgraph";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
import {
  withSpurvikLangGraph,
  provenanceFromLangGraphState,
  spurvikInterrupt,
  resolveLangGraphInterrupts,
} from "spurvik/langgraph";

// Your own graph and input (replace with yours):
const compiledGraph = new StateGraph(MessagesAnnotation)
  .addNode("reply", async () => ({ messages: [new AIMessage("ok")] }))
  .addEdge(START, "reply").addEdge("reply", END).compile();
const messages = [new HumanMessage("Hello")];

const graph = withSpurvikLangGraph(compiledGraph, {
  apiKey: process.env.SPURVIK_API_KEY!,
  signingSecret: process.env.SPURVIK_SIGNING_SECRET!,
  contentHash: { mode: "keyed", secret: process.env.SPURVIK_CONTENT_HASH_SECRET! },
  baseUrl: process.env.SPURVIK_BASE_URL!,
  riskTier: "high_annex3",
  article50Disclosure: true,
});

const state = await graph.invoke({ messages }, { configurable: { thread_id: "t-1" } });
const records = provenanceFromLangGraphState(state);
await graph.flush();

Exports: withSpurvikLangGraph, provenanceFromLangGraphState, spurvikInterrupt, resolveLangGraphInterrupts

Install: npm i spurvik ai @ai-sdk/openai @langchain/langgraph @langchain/core

F

IDE & cURL Configs

.cursor/mcp.json.vscode/mcp.jsoncURL
.cursor/mcp.json (Cursor stdio)
{
  "mcpServers": {
    "spurvik": {
      "type": "stdio",
      "command": "node",
      "args": ["${workspaceFolder}/node_modules/spurvik-mcp-server/dist/index.js", "--root", "${workspaceFolder}"]
    }
  }
}
.vscode/mcp.json (VS Code stdio)
{
  "servers": {
    "spurvik": {
      "type": "stdio",
      "command": "node",
      "args": ["${workspaceFolder}/node_modules/spurvik-mcp-server/dist/index.js", "--root", "${workspaceFolder}"]
    }
  }
}
shadcn CLI (/r/eu-ai-act.json)
pnpm dlx shadcn@latest add https://spurvik.com/r/eu-ai-act.json
cURL — Remote MCP tools/list
curl -s https://console.spurvik.com/api/mcp \
  -H "content-type: application/json" \
  -H "accept: application/json, text/event-stream" \
  -H "mcp-protocol-version: 2025-06-18" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
G

Art. 50(1) Notice (React)

spurvik/reactspurvik 1.0.824 EU languages
AIDisclosureLabel
import { AIDisclosureLabel } from "spurvik/react";

<AIDisclosureLabel notice="interaction" systemName="Support Assistant" locale="en" presentation="inline" />

Shows a visible notice that people are interacting with an AI system. presentation is inline or block. Default copy in 24 EU languages (review the wording before use). Set article50Disclosure: true in your config only when your UI shows this notice. Not using React? Use the badge snippet.

Canonical Repository Guides on GitHub

MIT docs/ directory

Human gate channels: preview

The request createMultiChannelApprovalRouter() from spurvik-sdk/channels sends to each channel before a risky agent action runs. The payloads below are generated by spurvik-sdk 1.0.7 with callbackBaseUrl: "https://your-app.example/api/spurvik/callback", placeholder tokens and an example signing secret. callbackBaseUrl is required from 1.0.5: point it at your own endpoint that accepts GET clicks. The 64 character hmac, nonce and sig values are example values, shortened here (8…4). Nothing on this page makes a network call.

npm i spurvik-sdk
# or the same build under the main package name:
npm i spurvik
import {
  createMultiChannelApprovalRouter,
  createSignedApprovalLinks,
  verifyChannelApprovalToken,
  buildChannelOutboundRequest,
  dispatchHumanApprovalChannel,
} from "spurvik-sdk/channels"; // or "spurvik/channels"

Incoming webhook

POST <SLACK_WEBHOOK_URL>
content-type: application/json

{
  "text": "[Spurvik Art. 14 Gate] stripe.refunds.create: Refund €240 on order #4821 (gate=gate_7f3a, hmac=bec31a723e52)",
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*[Spurvik Art. 14 Gate] stripe.refunds.create*\nRefund €240 on order #4821\n`gate=gate_7f3a` · `hmac=bec31a723e52`"
      }
    },
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": "Approve Action"
          },
          "style": "primary",
          "url": "https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=approved&sig=f1071099…fe5e",
          "action_id": "spurvik_approve_gate_7f3a"
        },
        {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": "Block Agent"
          },
          "style": "danger",
          "url": "https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=rejected&sig=13893a60…a208",
          "action_id": "spurvik_reject_gate_7f3a"
        }
      ]
    }
  ]
}
  1. createSignedApprovalLinks() computes an HMAC SHA256 over the gate ID, action name and context, using your signing secret (at least 16 characters).
  2. It signs separate Approve and Block tokens with a timestamp and a random nonce and adds them to the callbackBaseUrl you pass (…&nonce=…&decision=approved&sig=…).
  3. The reviewer opens one link in the channel. Your callback runs verifyChannelApprovalToken(), a constant time check with a maximum link age, before the agent continues or stops. Pass the nonce from the link, record it, and refuse a repeat: a link can be reused until it expires.
Monorepo Quickstart5-minute-quickstart.mdInstall spurvik 1.0.8, wrap an LLM client, and check an HMAC SHA256 receipt.Full SDK Quickstartquickstart.mdServer setup, environment variables, keyed content hashes, and streaming receipts.LangGraph Quickstartlanggraph-quickstart.mdWrap compiled graphs with withSpurvikLangGraph and map human interrupts.HITL Approval Mappinghitl-approval-mapping.mdMap Article 14 human oversight approvals across OpenAI Agents, AI SDK, and LangGraph.Claude Code Plugin Guideclaude-code-plugin.mdAdd spurvik/spurvik from the plugin marketplace and run local MCP checks.EU Console Onboardingconsole-onboarding.mdConfigure projects, API keys, and reviewer tokens on console.spurvik.com.Adapter Quick Cardsadapters-quick-cards.mdCopy and paste reference for Mastra, OpenAI Agents, Vercel AI SDK, and LangGraph.Developer FAQfaq-additions.mdScope boundaries, Article 50(1)/(2) marking hooks, and keyed pseudonymisation.Troubleshootingtroubleshooting.mdResolve missing env secrets, halt exceptions, and telemetry flush timing.
H

Art. 50(2) marking hooks

Spurvik logs AI calls with keyed HMAC SHA256 receipts and offers disclosure and human oversight hooks. Its provenance record supports, but does not replace, Article 50(2) machine readable marking: plug your own marking tool into config.marking

Print the marking hook guide
./node_modules/.bin/spurvik-mcp marking-hooks both
Prices Free €0/mo Pro €19/mo Team €79/mo (2M events) Enterprise from €499/mo Prices exclude VAT. Stripe, our merchant of record (shown at checkout as Link), adds VAT where it applies. B2B only: hello@spurvik.com.