01Claude · Cursor · Codex
AI coding agents
Then ask: “Use the eu-ai-act skill on this project.”
npx skills add spurvik/skillshttps://console.spurvik.com/api/mcp Developer docs
Copy one line: AI notice, call receipts without prompt text, human approvals.
Not a developer?24h Done-For-You Setup · €199spurvik 1.0.8spurvik-mcp-server 1.0.4MIT on npmHosted in Frankfurt
Pick your stack. One copy, then see it live in the sandbox.
01Claude · Cursor · Codex
Then ask: “Use the eu-ai-act skill on this project.”
npx skills add spurvik/skillshttps://console.spurvik.com/api/mcp 02HTML · Shopify · Webflow
Visible AI notice in 24 EU languages. No cookies.
<script src="https://spurvik.com/badge.js" data-system="Support AI" defer></script>03Next.js · React
Adds <Article50Badge />, the same notice as a component.
npx shadcn@latest add https://spurvik.com/r/article-50-badge.json 04Node · Vercel AI SDK · Python
Wrap your model client. Every call gets a keyed receipt.
npm i spurvik ai @ai-sdk/openain8n-nodes-spurvik@1.0.4npm i n8n-nodes-spurvik@1.0.4https://spurvik.com/r/n8n-art50-starter.jsonIn 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.
spurvik-sdk 1.0.8MITnpm i spurvik ai @ai-sdk/openaispurvik-sdk, same build)npm i spurvik-sdk ai @ai-sdk/openaiverifyChannelApprovalToken()"type": "module" or .mjs) · Node 20+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.
npm i -D spurvik-mcp-server@1.0.4./node_modules/.bin/spurvik-mcp check . --cimkdir -p .github/workflows && curl -fsSL https://spurvik.com/r/github-action-spurvik-check.yml -o .github/workflows/spurvik-check.ymlRun 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+.
/plugin marketplace add spurvik/spurvik/plugin install spurvik@spurvik-marketplaceManifest: .claude-plugin/marketplace.json · plugins/spurvik.
https://console.spurvik.com/r/spurvik-eu-ai-act-starter.jsonpnpm dlx shadcn@latest add https://console.spurvik.com/r/spurvik-eu-ai-act-starter.json/r/eu-ai-act.json)https://spurvik.com/r/eu-ai-act.jsonRenders visible Article 50(1) AI interaction notices across 24 EU languages.
https://console.spurvik.com/api/mcp/.well-known/mcp.json)https://spurvik.com/.well-known/mcp.jsonProtocol 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
https://console.spurvik.com/sandboxcurl -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.
<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.
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()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.
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?
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.AIDisclosureLabel from spurvik/react.spurvik-mcp-server@1.0.4 on npm (MIT, Node 20+, run with --root pointing at your project). See the MCP card.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?
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
console.spurvik.com/signup and enter your name, work email and a password.spurvik_live_ API key. Keep it on the server.npm i spurvik ai @ai-sdk/openai. For Mastra, see its card for the @ai-sdk/openai pin.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.riskTier. It is required, and it is your own classification: Spurvik does not classify your use case.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.
. · ./ai · ./mastra · ./openai-agents · ./langgraph · ./react
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.
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
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
spurvik/openai-agentsHITL Gateimport { 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.
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
{
"mcpServers": {
"spurvik": {
"type": "stdio",
"command": "node",
"args": ["${workspaceFolder}/node_modules/spurvik-mcp-server/dist/index.js", "--root", "${workspaceFolder}"]
}
}
}{
"servers": {
"spurvik": {
"type": "stdio",
"command": "node",
"args": ["${workspaceFolder}/node_modules/spurvik-mcp-server/dist/index.js", "--root", "${workspaceFolder}"]
}
}
}/r/eu-ai-act.json)pnpm dlx shadcn@latest add https://spurvik.com/r/eu-ai-act.jsoncurl -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"}'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.
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"
}
]
}
]
}Bot API sendMessage
POST https://api.telegram.org/bot<BOT_TOKEN>/sendMessage
content-type: application/json
{
"chat_id": "<CHAT_ID>",
"text": "[Spurvik Art. 14 Gate] stripe.refunds.create\nRefund €240 on order #4821\ngate=gate_7f3a · hmac=bec31a723e52",
"reply_markup": {
"inline_keyboard": [
[
{
"text": "Approve Action",
"url": "https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=approved&sig=f1071099…fe5e"
},
{
"text": "Block Agent",
"url": "https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=rejected&sig=13893a60…a208"
}
]
]
}
}Resend API
POST https://api.resend.com/emails
authorization: Bearer <RESEND_API_KEY>
content-type: application/json
{
"from": "<FROM_ADDRESS>",
"to": [
"<REVIEWER_ADDRESS>"
],
"subject": "[Spurvik Art. 14 Gate] stripe.refunds.create — Human approval required",
"text": "[Spurvik Art. 14 Gate] stripe.refunds.create\n\nRefund €240 on order #4821\nGate ID: gate_7f3a\nHMAC-SHA256: bec31a72…b930\n\nApprove: https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=approved&sig=f1071099…fe5e\nBlock: https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=rejected&sig=13893a60…a208",
"html": "<div style=\"font-family:sans-serif;background:#05080F;color:#F8FAFC;padding:24px;border-radius:12px;\">\n <h2 style=\"margin:0 0 8px;color:#10B981;\">[Spurvik Art. 14 Gate] stripe.refunds.create</h2>\n <p style=\"margin:0 0 16px;color:#CBD5E1;\">Refund €240 on order #4821</p>\n <p style=\"font-family:monospace;font-size:12px;color:#94A3B8;\">gate=gate_7f3a · hmac=bec31a723e52</p>\n <div style=\"margin-top:20px;\">\n <a href=\"https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=approved&sig=f1071099…fe5e\" style=\"background:#10B981;color:#05080F;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600;margin-right:12px;\">Approve Action</a>\n <a href=\"https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=rejected&sig=13893a60…a208\" style=\"background:#EF4444;color:#FFFFFF;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600;\">Block Agent</a>\n </div>\n</div>"
}Channel webhook
POST <DISCORD_WEBHOOK_URL>
content-type: application/json
{
"content": "**[Spurvik Art. 14 Gate] stripe.refunds.create**\nRefund €240 on order #4821\n`gate=gate_7f3a` · `hmac=bec31a723e52`\nApprove: <https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=approved&sig=f1071099…fe5e> | Block: <https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=rejected&sig=13893a60…a208>"
}Incoming webhook, Adaptive Card 1.4
POST <TEAMS_WEBHOOK_URL>
content-type: application/json
{
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{
"type": "TextBlock",
"text": "[Spurvik Art. 14 Gate] stripe.refunds.create",
"weight": "Bolder",
"size": "Medium"
},
{
"type": "TextBlock",
"text": "Refund €240 on order #4821",
"wrap": true
},
{
"type": "TextBlock",
"text": "gate=gate_7f3a · hmac=bec31a723e52",
"isSubtle": true,
"wrap": true
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "Approve Action",
"url": "https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=approved&sig=f1071099…fe5e"
},
{
"type": "Action.OpenUrl",
"title": "Block Agent",
"url": "https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=rejected&sig=13893a60…a208"
}
]
}
}
]
}GraphQL issueCreate
POST https://api.linear.app/graphql
authorization: <LINEAR_API_KEY>
content-type: application/json
{
"query": "mutation SpurvikCreateHitlIssue($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier url } } }",
"variables": {
"input": {
"teamId": "<TEAM_ID>",
"title": "[Spurvik Art. 14 Gate] stripe.refunds.create",
"description": "Refund €240 on order #4821\n\n- **Gate ID**: `gate_7f3a`\n- **Art. 12 HMAC-SHA256**: `bec31a72…b930`\n- [Approve Action](https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=approved&sig=f1071099…fe5e)\n- [Block Agent](https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=rejected&sig=13893a60…a208)"
}
}
}REST API v3 issue
POST https://<your-site>.atlassian.net/rest/api/3/issue
authorization: Basic <base64(EMAIL:API_TOKEN)>
content-type: application/json
{
"fields": {
"project": {
"key": "<PROJECT_KEY>"
},
"summary": "[Spurvik Art. 14 Gate] stripe.refunds.create",
"issuetype": {
"name": "Task"
},
"description": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Refund €240 on order #4821 (gate=gate_7f3a, hmac=bec31a723e52)\nApprove: https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=approved&sig=f1071099…fe5e\nBlock: https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=rejected&sig=13893a60…a208"
}
]
}
]
}
}
}GraphQL create_item. The item carries the gate ID, not the Approve/Block links.
POST https://api.monday.com/v2
authorization: <MONDAY_API_TOKEN>
content-type: application/json
{
"query": "mutation ($boardId: ID!, $groupId: String, $itemName: String!) { create_item (board_id: $boardId, group_id: $groupId, item_name: $itemName) { id } }",
"variables": {
"boardId": "<BOARD_ID>",
"groupId": "<GROUP_ID>",
"itemName": "[Spurvik Art. 14 Gate] stripe.refunds.create — Refund €240 on order #4821 [gate=gate_7f3a]"
}
}REST tasks
POST https://app.asana.com/api/1.0/tasks
authorization: Bearer <ASANA_ACCESS_TOKEN>
content-type: application/json
{
"data": {
"name": "[Spurvik Art. 14 Gate] stripe.refunds.create",
"projects": [
"<PROJECT_GID>"
],
"notes": "Refund €240 on order #4821\n\nGate ID: gate_7f3a\nHMAC-SHA256: bec31a72…b930\nApprove: https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=approved&sig=f1071099…fe5e\nBlock: https://your-app.example/api/spurvik/callback?gateId=gate_7f3a&ts=1762250400&hmac=bec31a72…b930&nonce=bde29b74…f032&decision=rejected&sig=13893a60…a208"
}
}n8n-nodes-spurvik 1.0.4 is on npm as a community node. It can add an AI interaction notice, record 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. The HTTP Request path below remains an alternative.
// 1. Your service sends the request (spurvik-sdk@1.0.8 or spurvik@1.0.8)
import { createMultiChannelApprovalRouter } from "spurvik-sdk/channels"; // or "spurvik/channels"
const router = createMultiChannelApprovalRouter({ signingSecret, channels });
await router.requestApproval({
gateId, actionName, summary,
callbackBaseUrl: "https://<your-n8n-host>/webhook/spurvik-gate",
});
// 2. n8n Webhook node receives the Approve / Block link:
GET /webhook/spurvik-gate?gateId=…&ts=…&hmac=…&nonce=…&decision=approved&sig=…
// 3. n8n HTTP Request node posts it back to your service:
POST https://<your-service>/spurvik/verify
content-type: application/json
{
"gateId": "{{$json.query.gateId}}",
"actionHmac": "{{$json.query.hmac}}",
"decision": "{{$json.query.decision}}",
"issuedAtUnix": {{$json.query.ts}},
"nonce": "{{$json.query.nonce}}",
"signature": "{{$json.query.sig}}"
}
// 4. Your service answers with router.verifyCallback(body)
// (or verifyChannelApprovalToken), and the workflow continues only on true.Blueprint: custom webhook, then HTTP
Source: github.com/spurvik/spurvik, packages/n8n-nodes-spurvik/make-com-blueprint.json
Import it in Make.com as a scenario blueprint. "flow":
1. gateway:CustomWebHook "Incoming AI Agent Action or Chat Turn"
2. http:ActionSendData POST https://console.spurvik.com/api/v1/hitl
authorization: Bearer {{var.organization.spurvikApiKey}}The token is stored as plain text in the Make.com scenario. The organization variable is available on Make.com Pro, Team and Enterprise; if organization custom variables are unavailable, the fallback is {{var.input.spurvikApiKey}}
createSignedApprovalLinks() computes an HMAC SHA256 over the gate ID, action name and context, using your signing secret (at least 16 characters).callbackBaseUrl you pass (…&nonce=…&decision=approved&sig=…).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.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
./node_modules/.bin/spurvik-mcp marking-hooks both