A self-hostable toolkit that gives a deployer everything the EU AI Act Article 50 transparency obligations actually need: signed, machine-readable provenance for AI outputs, the visible disclosures users have to see, and the governance trail that proves you did both. It runs on the Node standard library with no runtime dependencies, fully offline, and needs no API keys.
The Article 50 transparency duties apply from 2 August 2026, and they carry real money: Article 99(4) sets fines for non-compliance at up to EUR 15 million or, for a company, up to 3 percent of total worldwide annual turnover, whichever is higher. Yet most of the "AI Act compliance" tooling I looked at either hand-waves the hard part or pretends to solve a problem that is not actually solvable. This kit takes the opposite approach. It is honest about what Article 50 puts on a deployer versus a provider, honest about what content marking can and cannot do (especially for text), and it ships the parts that genuinely hold up.
This is engineering tooling, not legal advice. How you deploy it, your editorial processes, and the rest of your obligations under the Act are yours to get right, with qualified counsel where it matters.
Article 50 of Regulation (EU) 2024/1689 (the EU AI Act) sets transparency duties that apply from 2 August 2026. The full plain-language breakdown is in docs/article-50.md. The short version:
- 50(1): people must be told when they are interacting with an AI system (the chatbot interaction notice). This is a provider design duty, but a deployer is the one running the chatbot and has to make sure the notice is shown.
- 50(2): providers must mark synthetic audio, image, video, and text so it is machine-readable and detectable as AI, "as far as is technically feasible." This embed-at-generation duty is on the model provider, not the deployer.
- 50(4): deployers must disclose deepfakes (image, audio, video) and must disclose AI-generated text published to inform the public on matters of public interest. These are the deployer duties this kit is built around.
- 50(5): the disclosure has to be clear, distinguishable, accessible, and given at the latest on first exposure.
This is the part most products get wrong, so I want to be direct about it.
For images, audio, and video, you can embed a cryptographically signed provenance manifest inside the file. This is what C2PA Content Credentials do, and the EU work on labelling names C2PA as an example approach. Removing or altering that manifest is detectable. This is robust and it is the right answer.
For text, there is no robust equivalent, and there is no honest way to claim otherwise:
- Signed provenance carried with the text (a sidecar or an embedded token) is strong and tamper-evident whenever the manifest is present, but nothing stops someone from copying the words and dropping the manifest.
- Statistical or steganographic text watermarks (token biasing, zero-width characters, and similar) are cheaply removed by paraphrasing, translating, or passing the text through any editor that normalizes characters. The published research is consistent that these are not robust.
So this kit does not pretend to make text self-marking in a way that survives a determined or even a casual strip. It includes an optional zero-width text watermark, but it is off by default and it is documented in the code and the tests as bypassable. There is literally a test that strips it to prove the point.
Because no single mechanism is sufficient for text, the defensible answer is to layer the mechanisms that are real and not lean on the one that is not:
- A signed C2PA-aligned provenance manifest. Robust and verifiable whenever it travels with the content.
- An embedded reference token, openly documented, that links copied content back to its manifest when the carrier (an HTTP header, an HTML meta tag, or a text footer) survives.
- The visible disclosure that Article 50(4) requires from deployers anyway, regardless of any cryptography.
- The optional zero-width watermark as a clearly-labelled, best-effort extra, never as proof.
On top of all four sits the governance layer: an audit log, an inventory, and an exportable compliance report, because being able to show what you did is itself part of what an enforcement review asks for.
You keep your model, your provider, and your publishing flow. The kit is the step between "the model produced something" and "we published it", plus the verify and reporting paths that hang off that step.
flowchart LR
APP["Your app<br>produces AI output<br>(text, image, audio, video)"] --> KIT["ai-act-kit<br>SDK mark() or POST /api/mark"]
KIT --> PUB["Published artifact<br>visible disclosure + reference token"]
KIT --> SIDE["Signed sidecar manifest<br>(C2PA-aligned, Ed25519)"]
KIT --> LOG["Append-only audit log"]
PUB --> USERS["Your users<br>see the disclosure on first exposure"]
PUB -. copied text can carry the token .-> VER
SIDE --> VER["Anyone verifies offline<br>detect + verify, no secrets needed"]
LOG --> REP["Inventory + compliance report<br>JSON or Markdown"]
REP --> REV["What you hand a reviewer<br>dashboard or export"]
The internal layering, the manifest format, and the trust and threat model are in ARCHITECTURE.md.
Requirements: Node 20 or newer. There are no other runtime dependencies.
git clone https://github.com/EdgeF-4/ai-act-kit.git
cd ai-act-kit
npm install # fetches TypeScript and the Node types, the only dev dependencies
npm test # builds, then runs the suiteThe test suite makes no network calls and needs no keys. It signs with ephemeral
in-memory keys, so a marked output round-trips (mark, then detect, then verify)
completely offline. The only step that touches the network is the one-time
npm install of the build tooling, the same as any Node project.
import { AiActKit } from "ai-act-kit";
// With no config and no key file, the kit uses safe defaults and an ephemeral
// signing key. For production, run `ai-act-kit keygen` and point config.json at
// the private key.
const kit = new AiActKit();
const result = kit.mark({
content: "The committee approved the budget this morning.",
contentType: "text",
purpose: "public-interest-text",
generator: { name: "your-model", version: "1.0" },
});
// result.markedContent is the published text: the visible disclosure label plus
// an embedded reference token. result.signedManifest is the sidecar you store.
console.log(result.markedContent);
// Later, anywhere, verify it:
const detected = kit.detect(result.markedContent!);
const verdict = kit.verify(detected!.content, result.signedManifest);
console.log(verdict.valid); // trueFor media, the manifest binds the raw bytes and the kit returns a caption to render next to the file rather than mangling the bytes:
const img = kit.mark({
content: pngBuffer,
contentType: "image",
purpose: "deepfake",
generator: { name: "your-image-model" },
});
console.log(img.caption); // "This image, audio, or video was generated or altered by AI."
console.log(kit.verify(pngBuffer, img.signedManifest).valid); // trueA framework-agnostic middleware sets a notice header and exposes the text on
res.locals. It depends on a minimal request and response shape, so it works
with Express, Connect, and similar without taking a dependency on any of them.
import express from "express";
import { AiActKit, interactionNoticeMiddleware } from "ai-act-kit";
const kit = new AiActKit();
const app = express();
app.use(interactionNoticeMiddleware(kit.config));Everything the SDK does for marking and verifying is also one HTTP call away, so a workflow tool or a non-Node service can integrate without touching TypeScript. Start the server and post to it:
curl -X POST http://localhost:8099/api/mark \
-H 'content-type: application/json' \
-d '{
"content": "The committee approved the budget this morning.",
"contentType": "text",
"purpose": "public-interest-text",
"generator": { "name": "your-model", "version": "1.0" }
}'| Endpoint | What it does |
|---|---|
POST /api/mark |
mark content; returns the published text, the signed sidecar manifest, the reference token, and the audit record |
POST /api/verify |
check content bytes against a signed manifest; body is { "content", "signedManifest" } |
GET /api/health |
liveness, version, and whether the signer is ephemeral |
GET /api/config |
the sanitized disclosure config, never key material or paths |
GET /api/inventory |
aggregate view of everything marked |
GET /api/audit?limit=N |
recent audit records |
GET /api/report |
compliance report as JSON; ?format=md for Markdown |
One detail worth knowing: verify checks the exact bytes the manifest binds.
For text that is the boundContent field mark returned (the disclosed text
without the carriers), not the full markedContent. The SDK's detect()
recovers those bytes from a marked text by stripping the footer and the
optional watermark.
npm run build
npm start # serves http://localhost:8099The dashboard is read-only over the audit log. It shows the inventory, the disclosure configuration, the recent marked outputs, and links to download the compliance report as Markdown or JSON. The API never exposes key material.
npm run build
node dist/src/cli.js version
node dist/src/cli.js keygen --out ./keys # writes the private key with 0600docker compose up --build # dashboard on http://localhost:8099The image is Node plus the compiled output, since there are no runtime
dependencies. The audit log and reports persist in the mounted ./data
directory. To use a stable signing key, create config.json from
config.example.json, point it at your key, and uncomment the config mount in
docker-compose.yml.
Two worked examples live in examples/:
- examples/n8n-workflow.json: an importable
two-node workflow (a manual trigger and one HTTP Request node) that marks a
piece of text through
POST /api/mark. Import it, point the URL at your kit, run it, and the output is the published text plus the signed manifest. - examples/fastapi_app.py: a FastAPI service whose
/publishendpoint sends AI-drafted text through the kit before returning it, and stores the sidecar manifest next to the response.
Both talk to the same POST /api/mark endpoint, so the pattern carries to any
stack that can make an HTTP call.
Copy config.example.json to config.json and edit it. The real config.json
is gitignored and should be chmod 600. Everything has a safe default, so the
kit runs with no configuration at all.
Key settings: the per-purpose disclosure labels and placement, the signing key paths, the governance data directory, an optional trust list of signer key ids, your documented implementation choices (which appear in the compliance report), and whether the optional watermark is on.
- It does not generate content and does not embed marks at generation time. Under Article 50 the embed-at-generation marking duty in 50(2) sits with the model provider. This is the deployer-side toolkit.
- It does not make text self-protecting. It makes provenance available and verifiable when present. Stripping a sidecar or a token from text is trivial and not prevented.
- It does not implement the full C2PA binary embedding (JUMBF, COSE, CBOR) into media files. It produces a signed, C2PA-aligned manifest; for media you would hand that to the official C2PA toolkit to embed it in the file.
- It is not legal advice and does not by itself make you compliant.
src/
c2pa/ manifest building, signing, verification, embedded references
crypto/ Ed25519 key handling
disclosure/ visible labels and the interaction-notice middleware
governance/ audit log, inventory, compliance report
watermark/ optional zero-width text watermark
server/ dashboard and JSON API
kit.ts the AiActKit facade
test/ offline tests, including the mark/detect/verify round trip
docs/ the Article 50 grounding
examples/ 30-second n8n and FastAPI integrations against the HTTP API
MIT. See LICENSE.