Skip to main content
Glama
ai-content-labels
by JK-G

ai-content-labels

tests

TL;DR

ai-content-labels marks AI-generated and human-made content with the official EU icons, from any AI agent. It tells the agent whether the EU AI Act requires a label, burns the label into the image when it does or when nobody can tell, and returns the alt text. It ships as a CLI, an MCP server, a Node library and an Agent Skill for Claude Code, Codex, Gemini CLI, Copilot and Cursor.

Install it in Claude Code:

/plugin marketplace add JK-G/ai-content-labels
/plugin install ai-content-labels@ai-content-labels

Or run the CLI with no install (Node.js 20.9 or newer):

npx -y -p github:JK-G/ai-content-labels ai-labels label image.png --realistic --description "what it shows"

Any MCP client starts the server with npx -y -p github:JK-G/ai-content-labels ai-content-labels-mcp; client settings are under MCP server.

To place labels by hand, download a ready-made pack:

Download AI labels (ZIP) Download "Made by a human" labels (ZIP)

Labels in a light and a dark theme: "Generated with AI" with an image icon, "Modified with AI" with image and voice icons, a custom "Voice generated with AI", an AI label next to a "Human-made" label, a Polish label and an upper-case label

What it does for an agent:

  • One call right after it generates an image: ai-labels label, or label_image over MCP.

  • A label only where the AI Act requires one (deep fakes, AI text on a matter of public interest without editorial review) or where nobody can tell, as a precaution.

  • Nothing where a label would only be voluntary, unless the user opts in with --voluntary.

  • A small label, the EU "AI" icon alone, legible in a desktop feed, in the spot where it covers the least, and the alt text for screen readers.

  • Labels for web pages as HTML, and ready-made AI and "Made by a human" label packs to place by hand.

Unofficial. Not affiliated with the European Commission, and not legal advice.

Related MCP server: disclos-article-50

Why

Article 50(4) of the AI Act applies from 2 August 2026. Deep fakes, and AI-generated text published to inform the public on matters of public interest, must be disclosed by the professional user of the AI system (the deployer). The deployer duty was not delayed by the AI Omnibus. On 10 June 2026 the Commission published three icons for this, free for anyone to use. In the Commission's words, "the use of these EU icons is optional, but the labelling requirements under Article 50 AI Act are not."

The icons leave the practical questions open. Does this image need a label at all? Which of the three icons? Where does it go, how big, and what does a screen reader hear? How do you show that only the voice was AI and the script was written by a person? This project answers those questions in code, so a person or an agent gets the same answer every time.

What it does

  • label is the one-call version for an image an agent has just generated or edited. You say whether people could take it for real (--realistic, --no-realistic, or --realistic unsure when nobody can tell); it runs decide and stamps the label decide picked when the label is required or the answer is unsure. A voluntary label is not stamped unless you pass --voluntary. Over MCP it is label_image, which also takes the image as a URL or base64 and can return the labelled image instead of writing a file.

  • decide goes through the Article 50(4) questions: would the audience take the content for real, is it an artistic work, is the text published on a matter of public interest, did a person review it. It returns whether the label is a duty or a voluntary choice, which label fits (and the official EU icon with the same meaning), where to place it, the label sentence and the next step, as a command and, in JSON and over MCP, as the tool call to make. A voluntary AI label gets no next step unless you pass --voluntary. When a deciding fact is missing, it asks one question instead of guessing; when nobody knows the answer, unsure labels as a precaution. The answer is a few lines; --explain adds the legal basis, notes and sources.

  • stamp burns the label into a JPG, PNG, WebP, AVIF or TIFF, so it survives downloads and reshares as the EU guidance asks. You always say which label (--kind); stamp never assumes "Generated with AI". By default the label is the EU "AI" icon alone, as small as it can be while still readable on an image shown 552 px wide, a desktop feed column, and it sits where it covers the least, bottom right first; see Size, place and colour. For generated and modified labels an IPTC DigitalSourceType tag goes into the XMP; basic and human-made labels get only the project's marker, because the IPTC vocabulary has no term for them.

  • place works out where stamp would put the label, how big and in which colour, and writes nothing. It returns the box in px and in % of the image, and the render options that draw the same label, so a page or an HTML ad template can draw it as HTML or SVG over the image. Over MCP it is place_label.

  • render draws the same label as SVG, PNG or WebP, with role="img", lang and an aria-label. With --format html it writes the label as real text and inline icons, for web pages and apps.

  • Content-type icons show which part was AI: image, text, audio, voice, music, video. A second label, "Made by a human", shows which part was not.

  • Ready-made label packs for people who place labels by hand, no agent needed: AI labels on the EU icon, and "Made by a human" labels. SVG and PNG, English and Polish, light and dark. See below.

  • eu gives you the twelve official EU files, unchanged.

  • Languages: English and Polish wording is built in. For the other 22 official EU languages, pass --lang with your own --text in that language (--lang de --text "Mit KI generiert"), so screen readers read the label with the right voice.

Ready-made labels

Both packs are plain files you place yourself, drawn by this project's renderer. Download them with the buttons at the top, from the latest release, or pick single files from the folders. Every label comes as SVG and as PNG at 48 and 128 px on a transparent background, in English and Polish, light and dark.

AI labels

The AI labels in English and Polish, light and dark

labels/ai-generated has 120 labels on the EU "AI" icon: the icon alone, the icon with one content-type icon, one word ("Generated", "Modified"), the full wording ("Generated with AI", "Modified with AI", "Made with AI"), and the wording with one content-type icon. Use them where the label is required, or where you choose to label; the pack's README says when the AI Act asks for one.

"Made by a human" labels

An AI agent should not be the one to call your work human-made, so these labels are for you to place.

The "Made by a human" labels in English and Polish, light and dark

labels/human-made has 58 labels: the label with text, a short "Human-made" in English, the hand alone, and each of these with one content-type icon (image, text, audio, voice, music, video). Use them only where no generative AI was involved; the pack's README has the rules, placement tips and credits.

Use it with your agent

Requires Node.js 20.9 or newer. Drawing SVG labels and decide need nothing else. Stamping and PNG output use sharp, an optional dependency that npm installs with the package.

Agent or tool

Way in

Setup

Claude Code

Plugin: skill and MCP server

Claude Code

Codex, Gemini CLI, GitHub Copilot in VS Code, Cursor

Agent Skill, or the MCP server

Agent Skill, MCP server

Claude Desktop, Windsurf (Devin Desktop)

MCP server

MCP server

OpenAI Agents SDK, Vercel AI SDK, LangChain

MCP server from code

In code

n8n, ChatGPT apps, cloud agents

MCP server in HTTP mode

HTTP mode

Scripts, CI, ComfyUI

CLI

CLI

Your own Node.js code

Library

Node library

Our test suite covers the CLI, the library, the stdio MCP server and the HTTP mode on Windows, macOS and Linux. The client setups below follow each client's documentation; we have not run them in every client, so treat the ones marked untested as a starting point.

Claude Code

Install the plugin with the two /plugin commands in the TL;DR. The plugin system does not run npm, so after a plugin install ask Claude to "run ai-content-labels doctor" and it will tell you the one command that adds sharp.

Agent Skill

The skill folder works in any agent that reads Agent Skills. Per the vendors' docs, Codex, Gemini CLI, Copilot in VS Code and Cursor read ~/.agents/skills/, and Claude Code reads ~/.claude/skills/; check your client's docs if the skill does not show up. For one project only, use .agents/skills/ inside it.

git clone https://github.com/JK-G/ai-content-labels
mkdir -p ~/.agents/skills
cp -r ai-content-labels/skills/ai-content-labels ~/.agents/skills/
cd ~/.agents/skills/ai-content-labels && npm install sharp@0.35

On Windows PowerShell, copy with Copy-Item -Recurse ai-content-labels\skills\ai-content-labels $HOME\.agents\skills\. The last step adds sharp to the copied skill; skip it if you only need SVG labels and decide. The folder carries its own licence notices (NOTICE.md), so copy it whole.

MCP server

Every client starts the same command: npx -y -p github:JK-G/ai-content-labels ai-content-labels-mcp. The first start downloads the package and sharp, which can take a minute. On Windows some clients need "command": "cmd" with "/c", "npx" at the start of the arguments.

Codex (untested), in ~/.codex/config.toml:

[mcp_servers.ai-content-labels]
command = "npx"
args = ["-y", "-p", "github:JK-G/ai-content-labels", "ai-content-labels-mcp"]
startup_timeout_sec = 60

or codex mcp add ai-content-labels -- npx -y -p github:JK-G/ai-content-labels ai-content-labels-mcp.

Gemini CLI (~/.gemini/settings.json), Claude Desktop (claude_desktop_config.json) and Windsurf (mcp_config.json), untested:

{
  "mcpServers": {
    "ai-content-labels": {
      "command": "npx",
      "args": ["-y", "-p", "github:JK-G/ai-content-labels", "ai-content-labels-mcp"]
    }
  }
}

Cursor (.cursor/mcp.json) takes the same block with "type": "stdio" added. VS Code (.vscode/mcp.json) names the top key servers (untested):

{
  "servers": {
    "ai-content-labels": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "-p", "github:JK-G/ai-content-labels", "ai-content-labels-mcp"]
    }
  }
}

The tools are label_image, decide_label, render_label, stamp_image, place_label and eu_icon. Each has an output schema and returns structured content next to the same JSON as text, with the version that made it. The server speaks MCP 2024-11-05 to 2025-11-25 (clients that start with initialize) and 2026-07-28 (server/discover and per-request metadata).

In code

These pass the same command to the framework's MCP client. All untested.

OpenAI Agents SDK (Python):

from agents.mcp import MCPServerStdio

labels = MCPServerStdio(params={"command": "npx", "args": ["-y", "-p", "github:JK-G/ai-content-labels", "ai-content-labels-mcp"]})

Vercel AI SDK, with the stdio transport from the MCP TypeScript SDK (@modelcontextprotocol/sdk):

import { createMCPClient } from '@ai-sdk/mcp';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

const labels = await createMCPClient({
  transport: new StdioClientTransport({ command: 'npx', args: ['-y', '-p', 'github:JK-G/ai-content-labels', 'ai-content-labels-mcp'] }),
});

LangChain (langchain-mcp-adapters):

from langchain_mcp_adapters.client import MultiServerMCPClient

client = MultiServerMCPClient({"labels": {"command": "npx", "args": ["-y", "-p", "github:JK-G/ai-content-labels", "ai-content-labels-mcp"], "transport": "stdio"}})

The Agents SDK can also reach the server in HTTP mode with MCPServerStreamableHttp.

HTTP mode

For clients that cannot start a local process:

npx -y -p github:JK-G/ai-content-labels ai-content-labels-mcp --http

It serves Streamable HTTP on http://127.0.0.1:8787/mcp with JSON responses. The defaults are the safe ones:

  • It listens on 127.0.0.1 only. --host 0.0.0.0 (or any other address) also needs --token <secret> (or AI_CONTENT_LABELS_TOKEN), and every request must then send Authorization: Bearer <secret>.

  • File paths are off. Images come in as an https URL or base64 and go back as image content in the result. --allow-files turns paths on when the server runs on your own machine.

  • Plain http URLs, and URLs that point at loopback or private addresses, are refused, so the server cannot be used to read your network. (The CLI, the stdio server and the library also fetch http URLs.)

  • At most 3 tool calls run at once; more get 503 with Retry-After. Images over 50 megapixels, and SVG input, are refused before they are decoded.

  • Browser requests need --allow-origin <origin>; any other Origin header gets 403.

The built-in n8n MCP Client node speaks HTTP only, so point it at the /mcp address with the bearer token. ChatGPT apps and connectors need a public HTTPS address: run HTTP mode behind a TLS proxy with a token. We have not tested either.

CLI

The npx line in the TL;DR runs the CLI with no install. Installed with npm (npm install -g github:JK-G/ai-content-labels), the command is ai-labels, also available as ai-content-labels. From a clone, run node skills/ai-content-labels/scripts/ai-labels.mjs.

Node library

import { labelImage } from 'ai-content-labels';

const result = await labelImage(imageBuffer, { realistic: true, description: 'What the image shows' });
// result.buffer: the labelled image; result.alt: the alt text to publish; result.required: a legal duty or not
// realistic: 'unsure' labels as a precaution; realistic: false stamps nothing (stamped: false) unless voluntary: true

labelImage also takes a file path or an http(s) URL. The package exports decide, renderLabel, renderHtml, stampImage, stampBuffer, placeLabel and officialIcon as well. Every result has a version field.

After your agent generates an image

Run ai-labels init --write once in a project. It adds a short block to AGENTS.md, or to the file you name with --file, such as GEMINI.md. The block tells the agent to run label on every image it makes for publication, to answer the realism question honestly (unsure when nobody can tell) and to publish the alt text. Images whose label is only voluntary stay unlabelled; init --write --voluntary writes a block that labels them too. Running it again updates the block in place.

Where the common image tools put their files, and the step after each:

  • Codex. Per Codex's imagegen skill, built-in image generation saves to $CODEX_HOME/generated_images/<session>/; check the Codex docs for your version. Copy the image into the workspace, then run ai-labels label <file> --realistic or --no-realistic. Untested.

  • Gemini CLI with the nanobanana extension. Images land in ./nanobanana-output/; run ai-labels label nanobanana-output/<file> with the realism answer. Untested.

  • OpenAI Agents SDK. The image generation tool returns base64. Pass it to label_image as input_base64 and take the labelled image from the image content of the result, or save it and run the CLI. Untested.

  • ComfyUI. Images go to ComfyUI/output. Add a step after the queue finishes that runs ai-labels label on the new file. Untested.

  • n8n. Use HTTP mode with the MCP Client node, or an Execute Command node that writes the binary to disk and runs ai-labels label. Untested.

  • C2PA. Stamping changes the pixels, so stamp first and sign after. Run ai-labels label with --out. Then, in the manifest you sign the labelled file with using c2patool, add a c2pa.created action whose digitalSourceType is http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia (generated) or compositeWithTrainedAlgorithmicMedia (modified). c2patool writes only what your manifest contains. A manifest signed before the stamp no longer verifies. Untested.

Quick start

Ask your agent in your own words: "label this image as AI-generated", "does this AI photo need a label under the AI Act?", or in Polish "oznacz tę grafikę jako wygenerowaną przez AI". The skill works out the facts, runs decide, stamps or renders the label and gives you the alt text to publish.

From the command line. ai-labels is the command the package installs; from a clone, run node skills/ai-content-labels/scripts/ai-labels.mjs instead.

For an image an agent just made, label decides and, when a label is needed, stamps it in one step. A low-poly wolf would not be taken for a photo, so its label is voluntary and nothing is written:

$ ai-labels label wolf.png --no-realistic --description "Low-poly grey wolf standing, front view"
NOT REQUIRED: not a deep fake (would not be taken for real)
Not labelled, and no file written: the label is voluntary here, so publish the original. To label it anyway, rerun with --voluntary.
alt: Low-poly grey wolf standing, front view.

With --voluntary the same call stamps it:

$ ai-labels label wolf.png --no-realistic --voluntary --description "Low-poly grey wolf standing, front view"
NOT REQUIRED: not a deep fake (would not be taken for real) (labelled because --voluntary is set)
wolf.ai-label.png
label: 39×39 px, icon, white disc, bottom-right (auto: bottom-right is free), mark contrast 16.88:1, stands out 16.01:1 from the image
display: 17.2 px tall when the image is shown 552 px wide (the default, a desktop feed column; --display-width for other sizes); 12.1 px on a phone 390 px wide
disclosure: Generated with AI: image.
alt: Low-poly grey wolf standing, front view. Generated with AI: image.
metadata: XMP DigitalSourceType trainedAlgorithmicMedia

--realistic stamps the same label because it is required, and --realistic unsure stamps it as a precaution (the first line then starts with UNSURE, LABEL AS A PRECAUTION). The label is the EU "AI" icon alone, with the words in the alt text. --short adds the word "Generated" next to the icon, and --modified is for a real photo that AI changed. Without a realism answer label asks the question below and exits with code 2.

For anything else, start with decide:

$ ai-labels decide --media image --origin generated
UNKNOWN: Would the people who will see it take it for a genuine photo, recording or event? Rerun with --realistic or --no-realistic; if nobody can tell, with --realistic unsure, and the label is added as a precaution.

$ ai-labels decide --media image --origin generated --realistic
REQUIRED: realistic AI image, a deep fake under Article 50(4)
Next: ai-labels stamp <image> --kind generated --media image --description "what it shows"
Label: --kind generated --media image, says "Generated with AI: image." (official EU icon with this meaning: generated)
- Burn the label into the image pixels (the `stamp` command), so it stays visible when the image is downloaded or reshared.
- stamp puts the label in the least invasive spot, with nothing covering it: the bottom-right corner when it is free, ...
Accessibility: Give the image an alt text that says what it shows, then the label sentence (alt="<what the image shows>. Generated with AI: image."); ...
Do not animate the label (WCAG 2.2.2) or make it flash (WCAG 2.3.1). Not legal advice. More: rerun with --explain

$ ai-labels stamp wolf.png --kind generated --media image --short --description "Low-poly grey wolf standing, front view"
wolf.ai-label.png
label: 237×46 px, short, light pill, bottom-right (auto: bottom-right is free), text contrast 14.78:1, stands out 14.21:1 from the image
display: 20.2 px tall when the image is shown 552 px wide (the default, a desktop feed column; --display-width for other sizes); 14.3 px on a phone 390 px wide
disclosure: Generated with AI: image.
alt: Low-poly grey wolf standing, front view. Generated with AI: image.
metadata: XMP DigitalSourceType trainedAlgorithmicMedia

$ ai-labels render --kind modified --media image,voice --human text --lang pl --out label.svg
$ ai-labels render --format html --kind generated --media text

decide exits with code 2 when it has to ask, so a script cannot mistake a question for a verdict. For text published on a matter of public interest it also asks whether a person reviewed it (--editorial-review), because that decides the case. With --json, the field missing names the argument to answer, and next holds the next call: the MCP tool, the arguments to pass unchanged, the ones still to fill in (fill) and the CLI line. While decide still asks, next is the same decide call with the answer unsure, for when nobody can answer. For a voluntary AI label next is null and optIn says how to label anyway. ai-labels help lists every option.

Which label

Kind

Use it when

Text (en / pl), default

With --full

generated

All of the content comes from AI, apart from the prompt

Generated / Wygenerowane

Generated with AI / Wygenerowane przez AI

modified

Real or human-made content was partly changed by AI: a swapped face, added objects, a replaced voice

Modified / Zmodyfikowane

Modified with AI / Zmodyfikowane przez AI

basic

AI was involved and you want your own words, such as "Voice generated with AI"

Made with AI / Z udziałem AI

the same

human

A person made it without generative AI, and you want to say so

Human-made / Stworzone przez człowieka

Made by a human / Stworzone przez człowieka

Forms. stamp, label and place use the icon form for AI labels: the EU "AI" disc alone, which is what Measure 1.1(a) of the Code of Practice asks for. A word such as "Generated" is only encouraged (1.1(b)), so it comes on request: --short for one word, --full for the whole phrase, --minimal for the disc with the content-type icons. Human-made labels keep their word, and so does a label in your colour (--color, --palette), which needs a pill. render keeps its default, the short form, so existing calls draw the same SVG.

Ads and feeds. The label sentence in a caption only counts if people see it at first exposure (Code of Practice Sub-measure 1.2.1). In a feed the end of an ad text often sits behind a "See more" fold, and some ad formats have a short text field, so the sentence may not be seen. Put the word on the image (--short) unless the label sentence is in the first visible line of the caption.

The human-made mark is this project's own, not an EU icon. Next to the "AI" disc, the default word reads like the EU icons themselves ("AI Generated", "AI Modified"). --media adds the content-type icons. --human text puts a "Made by a human" label next to an AI label. --uppercase sets the text in spaced capitals like the icons.

Minimal form

Minimal labels: the AI disc with an image icon, with image and voice icons, an AI label next to a hand with a text icon, and a hand with text and image icons

--minimal drops the text but keeps the pill. The left side says who made it (the EU "AI" disc, or a hand in the same disc), the right side says what (the content-type icons). --icon goes further: the EU disc alone, with a thin ring, the default for AI labels in stamp, label and place. Two things to know before you drop the words:

  • In user testing for the EU icons, labels with text were noticed and understood better. Add the word (--short) when the user wants it, and say the label sentence in the caption when the label is a legal duty.

  • Without text, "generated" and "modified" look the same. Only the alt text tells them apart.

The icon goes down to 17 px, the minimal form and labels with words to 20 px; see Size, place and colour for why. The hand is a filled raised hand with five fingers, 0.62 of its disc tall at every size, in SVG, PNG and HTML alike. Being solid, it matches the weight of the bold "AI" letters, and at the smallest sizes it still reads as a hand by its outline.

Minimal AI and human-made labels at 20, 24, 28, 32 and 40 px, at actual size and enlarged four times, in a light and a dark theme

Size, place and colour

Unless you or your agent choose otherwise, a stamped label takes the smallest form and size that still do the job, in the spot where it covers the least.

  • Form. The EU "AI" icon alone for an AI label, whether it is required, added as a precaution or voluntary (with --voluntary), with the words in the alt text. The Code of Practice on Transparency of AI-Generated Content asks for an icon whose main element is the letters "AI" (Measure 1.1(a)); text such as "generated" is encouraged, not required (1.1(b)). --short adds the word, --full the phrase. Human-made labels keep their words, because a hand alone is not a sign people know yet.

  • Size. 17 px tall for the icon and 20 px for a pill (minimal, or with words), as the image is shown 552 CSS px wide: about the column of a desktop feed, the width the project owner chose as the default (LinkedIn does not publish it). A 1080 px wide image gets a 34 px icon or a 40 px pill, a 1600 px wide one 50 or 58 px. Larger screens show it larger. A phone that shows the image full width (390 CSS px) shows it about 0.7 as tall, 12 px for the icon and 14 px for a pill; reading its letters there takes a visual acuity of about 0.7 rather than 0.5, and label.phoneHeight reports that size. --display-width 390 sizes the label for the phone (48 or 56 px on a 1080 px image); for an image shown at another width on a page, pass that width. The platforms do not publish the width they show an image at, so there are no platform presets.

  • Why 17 and 20 px. The Code lets the icon "appear in different sizes depending on the context" as long as it is "readable and recognisable to natural persons" (Measure 1.1(c)) and sets no minimum; the Commission's icon page asks for a "clearly visible size". So the floor comes from legibility. The EU minimum eyesight for driving is a visual acuity of 0.5 (Commission Directive 2009/113/EC, Annex III), which recognises a letter 10 minutes of arc tall. People hold a phone about 32 cm away when reading a web page (Bababekova et al. 2011), where one CSS px is about 0.166 mm, so a letter needs 5.65 CSS px. The "AI" in the EU disc is 0.386 of the disc, so the disc needs 14.7 px. The icon is the disc with a thin ring (a sixteenth of the disc), so 17 px holds it. In a pill the disc keeps a clear margin of the pill around it at every size, an eighth of the pill's height on each side, so the disc is three quarters of the pill and the pill needs 20 px. A word needs an x-height of 5.65 px, which Inter reaches at 10.5 px type. Stamped labels are laid out at the size they are seen, so the type is 10.5 px or more where the image is shown 552 px wide, not just in the file.

  • Place. The bottom-right corner when it is free. If text, a logo or detail sits there, the freer of bottom centre and bottom left; if the whole bottom edge is taken, the top edge. The tool scores how busy each spot is (the mean brightness gradient under and around the label) and reports the scores with the spot it chose. --position picks a spot yourself.

  • Part of an image. When only part of a graphic is AI, such as a generated photo inside a designed ad, pass that part's box: --region 200,390,860,690 in px, or in % of the image. The label goes on that part, in its least invasive spot. For a rounded or angled part, pass its shape instead: --region-path "M300 200 H780 V800 L540 1000 L300 800 Z" (SVG path data in image px, here a shield) or --region-mask mask.png (inside where the mask's alpha, or its brightness when it has no transparency, is 50% or more). The label and its margin then lie fully inside the shape: each spot starts at its corner of the shape's bounding box and moves inward until it fits. When the part is too small for a legible label, the label goes on the whole image instead of shrinking over half of that part, and label.regionNote says so: then say in the alt text or the caption which part is AI. With --height the label stays in the part at the size you gave, with a warning when that is under the legible size.

  • What to keep clear. The busy score sees texture, not meaning: a smooth back, a face in soft light or a clean product surface can score as free. Pass --avoid x,y,w,h (px or %, repeat it for more boxes) for faces, people, the product and text, and the label keeps clear of them. Every spot it did not take is listed in label.why with the reason, such as overlaps avoid box 1 or busy (score 9.1, free is 6 or less). When no spot is left, stamp stops and says why instead of covering something.

  • Platform safe zones. --safe-zone keeps the label out of the edges a platform covers with its buttons and captions. meta-reels leaves 14% at the top, 35% at the bottom and 6% on each side, from the Meta Ads Guide for Instagram Reels ("Consider leaving at least 14% of the top, 35% of the bottom, and 6% on each side of your asset free from text"; checked 18 October 2026). For other platforms take the insets from the platform's own template and pass them as --safe-zone top,right,bottom,left in px or %, for example --safe-zone 14%,6%,35%,6%.

  • Colour. A white or dark pill by default, whichever stands out more from the spot. --color #1f7a52 uses your colour, and --palette #1f7a52,#f2c94c takes the brand colour that stands out most from the spot. The pill is then opaque and the text turns white or dark to keep 4.5:1 contrast; when no palette colour stands out from the spot, the neutral pill is used and colorNote says so. --color auto reads the colours of the image and takes its accent colour, toned darker or lighter until the text reaches 4.5:1, so a brand green may come out a shade deeper. With a region, auto reads only the part outside it (the designed part of a composite) when that part is at least 20% of the image, so a red cable or the sky in the AI photo does not set the label colour; label.color.sampled says which part it read. When you know the brand colour, pass it with --color or --palette instead of auto. The disc stays the official black or white EU icon.

  • HTML pages. When the page draws the label itself, run place (MCP place_label) with the same options instead of stamp. Pin the label over the image by the sides boxPercent.anchor names (right and bottom for a bottom-right label), so it stays in its corner whatever its drawn width, and draw it with render and renderArgs. As SVG, draw it with renderArgs and let it scale with the image. As HTML (--format html), the height is in CSS px: pass --height with label.cssHeight, the label's height where the image is shown, and the pill is exactly that tall (at least 20 px, 17 px for the icon).

Upgrading

0.7 sizes stamped labels for an image shown 552 px wide, a desktop feed column, instead of full width on a phone (390 px), so they are smaller in the file: a 1080 px wide image gets a 34 px icon (was 50) or a 40 px pill (was 50 with words, 56 minimal), a 1600 px wide one 50 or 58 px (was 74 and 83). On a phone they look about 0.7 as tall as in 0.6. The smallest icon is 17 px (was 18), and labels with words keep the margin around the disc at every size, so they start at 20 px (was 18). --display-width 390 gives 0.6's sizes back for the minimal pill, pixel for pixel, and 17 px icons. A region too small for a legible label no longer gets a shrunken label: the label goes on the whole image and label.regionNote says so. render draws the same SVG and HTML as 0.6 for the icon, for the minimal pill from 20 px and for words from 24 px. For the 0.6 stamp, pixel for pixel, pass --height with the 0.6 rule n = min(512, max(18, ceil(18 × image width / 390))) (20 for --minimal) and --display-width 390, for the icon and the minimal pill; with --height a small region keeps the label, as in 0.6. Labels with words cannot be reproduced: they keep the margin now, which also makes them narrower (381 px instead of 415 on a 1600 px wide image at the 0.6 size).

0.6 stops labelling where the label is only voluntary. label and label_image stamp only when the label is required or the realism answer is unsure; for --no-realistic they write nothing and return stamped: false. decide returns next: null for a voluntary AI label. To keep the 0.5 behaviour, pass --voluntary (MCP voluntary: true). --only-if-required (only_if_required) is still accepted and does nothing. Required and precautionary labels look exactly as in 0.5.

0.5 changed the stamped label again: stamp, label and place now give an AI label the EU icon alone, and the smallest size is 18 px as shown on a phone (50 px on a 1080 px wide image) instead of 24 px (67 px). Labels with words are laid out at the size they are seen, with a thinner inset under 24 px, so their pixels change too. render keeps its defaults: the same call draws the same SVG.

  • The 0.4 look at the new size: --short. One word next to the disc, at the smallest legible size as shown (18 px in 0.5 and 0.6, 20 px since 0.7).

  • The 0.4 size: --short --height <n> with the 0.4 rule n = min(512, max(24, ceil(24 × image width / 390))) (67 for a 1080 px wide image). Add --display-width <image width> for the 0.4 stamp pixel for pixel.

  • The 0.1 to 0.3 stamp, pixel for pixel:

ai-labels stamp image.png --kind generated --media image --full --position top-left --height <n> --display-width <image width>

where n = min(512, max(28, round(0.06 × shorter side), round(0.025 × longer side))): 65 for 1080×1080, 1080×1350 and 1080×1920, 72 for 1200×1200. --display-width with the image's own width lays the label out at its file size, as 0.1 to 0.3 did. In 0.3 a display width also raised the height to at least ceil(28 × image width / display width); work that into n yourself. Over MCP pass full: true, position: "top-left", height and display_width.

0.4 had changed three defaults of 0.1 to 0.3: one word ("Generated") instead of the full wording, the smallest legible size instead of about 6% of the shorter side, and auto placement, bottom right first, instead of the top-left corner. A minimal label from render (--minimal) is the same SVG from 0.1.0 to 0.6.0, so tests that hash it keep passing. Since 0.5 every JSON result carries version, so a pipeline can check which version made it.

Accessibility

Article 50(5) asks for disclosures that conform to the applicable accessibility requirements, and the EU icon guidance asks for alt text or ARIA labels. The labels follow WCAG 2.2:

  • Contrast: the text keeps at least 4.5:1 against its own background even when that background is semi-transparent and the image behind it is pure black or pure white. A label in your own colour (--color) is opaque, and its text is white or dark, whichever keeps 4.5:1. Options that would break this are refused unless you pass --allow-low-contrast.

  • Screen readers: render, stamp and alt return the label sentence as disclosure ("Generated with AI: image and voice."). Text burned into pixels is invisible to assistive technology, so the alt text of a labelled image is a short description of what it shows, then that sentence: pass --description and the commands return it composed as alt ("Modern house among pine trees, architectural visualisation. Generated with AI: image."). Without a description alt is null, so an agent cannot publish the sentence alone as the alt of an informative image. render also returns labelAlt, the alt for an <img> that shows the label on its own. When the label is a legal duty, put the sentence in a visible caption too. Rendered SVGs carry it in aria-label and <title>; when the image's alt or caption already says it, give a separate label alt="" or aria-hidden="true".

  • Real text on web pages: render --format html writes the label as text with decorative inline icons and a stylesheet (--css). By default the type follows the page and never drops below 12 px; --height 20 sets the pill to exactly 20 px, with the words at 10.5 px, as in the SVG label at that height. --icon gives the disc alone as inline SVG, with the label sentence for screen readers. It zooms, reflows, translates and follows the page's colour scheme and forced colours, which an image of text cannot do (WCAG 1.4.4, 1.4.5, 1.4.12). A page that declares only a light scheme keeps a light label when the system is dark; --theme pins one. In a narrow column the icons move under the words instead of the words breaking. Use SVG or PNG for images, video overlays and places that only accept an image.

  • Legible everywhere: in SVG, PNG and stamped labels the text is drawn as outlines from Inter SemiBold, so it looks the same in every browser and image viewer and covers the alphabets of all 24 official EU languages. No icon is smaller than 17 px tall in the file and no pill smaller than 20 px; an image too small for that gets an error instead of an unreadable label. By default labels are that size where the image is shown 552 px wide too, with the type at 10.5 px or more there; on a phone that shows the image full width they are about 0.7 as tall (label.phoneHeight), and --display-width 390 keeps them legible there. When a wide banner or a small region forces the label to shrink, stamp says so in a warning, and displayHeight always reports how tall the label looks. --display-width sizes it for another screen.

  • Still: labels are static. The skill tells agents not to animate them (WCAG 2.2.2) or make them flash (WCAG 2.3.1).

What the AI Act asks

The short version is in the Why section. The rules behind decide, with quotes and links to the Act, the EU icon page and the Code of Practice, are in references/rules.md. They include the Commission's Guidelines on Article 50 of 20 July 2026, with paragraph numbers. decide is a checklist, not legal advice.

Limits

  • Video and audio are not stamped yet. For video, render a PNG label and overlay it for the whole clip with ffmpeg (the skill shows the command). Labels that follow the timeline, such as a voice icon shown only while an AI voice speaks, are planned. Audio-only content needs a spoken disclaimer; decide says so.

  • One still image per stamp run. Batch mode is planned. Animated WebP and GIF are refused; export a still frame.

  • Stamping rewrites the file. The EXIF data, the colour profile and the XMP (rights, credit, captions) are carried over. The project's marker is always added to the XMP, so a labelled file is not stamped twice by mistake, even after a rename; --no-metadata skips only the IPTC DigitalSourceType. An older IPTC-IIM block is not carried over. The pixels change, so an existing C2PA manifest no longer verifies: sign again after stamping if you rely on C2PA.

  • Two built-in languages. English and Polish. The other EU languages need --lang and --text (and --human-text for a human-made part); the sentence is then your text alone, and the content-type icons stay decorative. Built-in wording for more languages is welcome as a pull request to i18n.mjs.

Roadmap

  1. A simple online generator: pick the options, download PNG or SVG.

  2. Video labels that follow the timeline.

  3. More built-in languages.

Credits and licences

  • EU icons: European Commission, EU icons for labelling AI-generated content. Free to use without attribution. Using them does not mean you signed the Code of Practice. The human-made mark (a hand in the same disc) is this project's own design, not an EU icon.

  • Content-type icons: Lucide, ISC licence; the music and text icons are derived from Feather, MIT licence.

  • The hand in the human-made mark: Heroicons "hand-raised" (solid) by Tailwind Labs, MIT licence. The full notices for both sets ship in the skill folder (assets/icons/LICENSE.txt).

  • Font: Inter by Rasmus Andersson, SIL Open Font License 1.1.

The code is MIT. Details for the third-party files are in NOTICE.md.

Related MCP Connectors

Related MCP Servers