The surface at a glance
Filters
Thirteen, all in the free plugin. There are no custom actions: nothing in the plugin fires a do_action of its own, so a filter is the only extension point.
aicl_optimiser_hooks 2.2.0
The action names the plugin listens on to notice an image optimiser has finished. Default: nine hooks covering ShortPixel, Imagify, Smush and EWWW. Those plugins re-encode a file on their own schedule, often minutes after the upload, and re-encoding drops the embedded mark. Before 2.2.0 the mark stayed missing until the next library scan noticed; now the plugin rewrites it as soon as the optimiser signals it is done.
The names were read from each plugin’s own source rather than guessed, but they are filterable so a rename can be corrected on a site without waiting for a release. Subscribing to a hook that never fires costs nothing, so adding one speculatively is safe.
// Add an optimiser this list does not know about.
add_filter( 'aicl_optimiser_hooks', function ( $hooks ) {
$hooks[] = 'my_optimiser_finished';
return $hooks;
} );
aicl_universal_exclude 2.2.0
Attachment IDs to keep out of the universal badge map, the JS pass that covers themes printing their own <img> markup. Default: an empty array. Use it for an image you have marked for the record but do not want the JS pass badging on the front end.
add_filter( 'aicl_universal_exclude', function ( $ids ) {
$ids[] = 1234;
return $ids;
} );
aicl_universal_basename_fallback 2.2.0
Whether the universal map may match on a bare filename when it cannot resolve a path. Default: true. The map is keyed on the relative upload path, which is specific enough that two different images cannot collide. A bare basename is not: WordPress files uploads by year and month, so hero.jpg can exist in two folders, and a CDN or theme asset can share a name with something you marked.
The basename is still offered as a secondary key because hand-written theme markup sometimes carries no resolvable path. Return false if you would rather miss a badge than risk a wrong one. Badging an image the site never marked is a false statement about your own content, which is worse than a missing badge, so this is the safer default to change on a site with imagery from mixed sources.
// Exact paths only. Never guess from a filename.
add_filter( 'aicl_universal_basename_fallback', '__return_false' );
aicl_guidance_basis 2.2.0
The guidance reference recorded in the compliance export. Default: European Commission guidelines on Article 50, 20 July 2026. The export names the basis it was produced against so a reader can tell which version of the guidance the record reflects. Change it if your organisation cites a different or later source.
add_filter( 'aicl_guidance_basis', function () {
return 'Our internal AI disclosure policy, rev 4';
} );
aicl_text_post_types 2.1.0
Which post types get the Article 50(4) text-disclosure control. Default ['post', 'page']. Products, attachments and custom types are not included, because a product description is not text published to inform the public on a matter of public interest. A newsroom that keeps its reporting in a custom type does need this filter.
It gates five things at once: meta registration, the block panel, the classic meta box, the save handler and the REST read filter. Add a type here and all five follow.
add_filter( 'aicl_text_post_types', function ( $types ) {
$types[] = 'news_article';
return $types;
} );
Register the filter before init, since that is when the meta is registered. A plugin file or an mu-plugin is fine; a theme's after_setup_theme is fine. Adding it on wp_loaded is too late.
aicl_mark_original_image 2.1.5
Whether the untouched full-size original is marked alongside the copy WordPress serves. Default: true. Upload a photo above the big-image threshold and WordPress keeps your original beside the scaled copy, reachable by removing -scaled from the URL. Before 2.1.5 only the served copy carried the disclosure, which left the largest and most re-shareable version of the picture as the bare one.
Return false to leave originals untouched. The served copy and every generated size are still marked either way, so disclosure on your pages is unaffected. Both the writer and the Media screen read this filter, so a site that turns it off is told the original is left unmarked rather than being shown a claim that is not true.
// Leave every original alone.
add_filter( 'aicl_mark_original_image', '__return_false' );
// Or decide per attachment.
add_filter( 'aicl_mark_original_image', function ( $mark, $attachment_id ) {
return 'image/tiff' === get_post_mime_type( $attachment_id ) ? false : $mark;
}, 10, 2 );
aicl_featured_post_types 2.1.3
Which post types offer the AI image source control on their featured image. Default: every post type that supports thumbnail. The block panel registers globally, so before this existed the control offered to describe a featured image on post types that cannot have one.
The counterpart to aicl_text_post_types, and there for the same reason: which types a site actually discloses for is a judgement, not a capability.
add_filter( 'aicl_featured_post_types', function ( $types ) {
return array_diff( $types, array( 'product' ) );
} );
Returning an empty array removes the control everywhere without disabling anything else; the Media Library flag and the badge are unaffected.
aicl_scan_urls 2.0.0
The pages the detector fetches when it looks for chat widgets and AI scripts. Six URLs by default, drawn from your own site. Useful when a widget only loads somewhere the defaults never reach, such as a checkout or a members area.
add_filter( 'aicl_scan_urls', function ( $urls ) {
$urls[] = home_url( '/contact/' );
return $urls;
} );
These are fetched by your own server, in a loopback request. A URL behind a login will come back as the login page, not as the page you meant.
aicl_ai_systems 1.2.6
The AI systems registry the detector matches against. 672 entries ship with the plugin, in data/ai-registry.json alongside 27 generator signatures. Each entry is id, name, category, article, makes_media, confidence, installs and signals. Use it to teach the detector an internal tool it could not know about.
add_filter( 'aicl_ai_systems', function ( $systems ) {
$systems[] = [
'id' => 'acme-copilot',
'name' => 'Acme Copilot',
'category' => 'chatbot',
'article' => '50(1)',
'makes_media' => false,
'signals' => [ 'plugins' => [ 'acme-copilot/acme.php' ] ],
];
return $systems;
} );
category is one of chatbot, content, image, video, audio or translation, and drives which obligation the finding is filed under. signals takes any of plugins, scripts, globals and cookies. makes_media is what raises the Article 50(4) observation.
aicl_report_formats 1.1.0
Which formats the compliance export offers. ['csv'] in the free plugin; Pro adds its own through this filter. Adding a key here only advertises the format. You still have to render it, with the filter below.
add_filter( 'aicl_report_formats', function ( $formats ) {
$formats[] = 'json';
return $formats;
} );
aicl_report_stream 1.1.0
Render a format yourself. Return true to say you handled it and the plugin stops; return false and it falls through to CSV. You are streaming to the HTTP response, so send your own headers and exit.
add_filter( 'aicl_report_stream', function ( $handled, $format, $headers, $rows, $lit, $date, $record ) {
if ( 'json' !== $format ) {
return $handled;
}
header( 'Content-Type: application/json' );
echo wp_json_encode( $record );
return true;
}, 10, 7 );
$record is the whole canonical report, added in 1.4.0: provenance pairs, column labels, section headings, the image rows, the post rows and the Article 4 record. Prefer it over the four positional arguments before it, which exist for handlers written against the original signature.
aicl_directory_endpoint 1.0.0 aicl_waitlist_endpoint 1.0.0
Where the opt-in directory registration and the Pro waitlist POST to. Both point at directory.aimtransparency.com. Repoint them if you are running your own directory, or to a null endpoint if you want to be certain nothing leaves the site.
add_filter( 'aicl_directory_endpoint', function () {
return 'https://directory.example.com/api/register';
} );
Neither fires unless the site owner opts in. The plugin makes no outbound request on a default install.
aicl_waitlist_endpoint 1.0.0
Where the dashboard posts a Pro waitlist sign-up. Default https://directory.aimtransparency.com/api/waitlist. The request is non-blocking and carries only what the visitor typed; the email-tool key lives on the backend, never in the plugin.
add_filter( 'aicl_waitlist_endpoint', function () {
return 'https://example.com/collect';
} );
Shortcodes
All three render server-side, so they work anywhere shortcodes run: posts, pages, widgets, block templates, page builders.
[aicl_text_disclosure] 2.1.0
The Article 50(4) line for a post's text, placed by hand. It follows the wording and appearance set on the Posts tab.
[aicl_text_disclosure]
[aicl_text_disclosure id="123"]
Two behaviours worth knowing. Using it switches off the automatic line on that post, so the two can never both appear. And it obeys the same rules as the automatic one: it prints nothing on a post you have not marked, and nothing on a post the Article 50(4) exemption covers. A shortcode that rendered regardless would let a page claim its text was AI-written when the record says otherwise.
[aicl_disclosure] 1.2.0
A standalone disclosure you write yourself, for content the plugin cannot see. Every attribute is optional.
[aicl_disclosure type="ai-generated" style="inline" mark="eu"]
[aicl_disclosure text="This section was drafted with AI." style="banner"]
[aicl_disclosure bg="#102a43" color="#f0f4f8" logo="https://example.com/logo.png"]
type | Source type. Default ai-generated. |
style | inline, badge or banner. Default inline. |
text | Your own wording. Default is the sentence for the type. |
mark | aim, eu or none. Default aim. |
bg, color | Hex pair. Dropped below 4.5:1 and the default pill drawn instead. |
logo | Image URL, placed beside the mark rather than replacing it. |
[aicl_ai_notice] 1.1.0
The Article 50(1) chatbot notice, for a chat interface you built yourself. It takes an optional text attribute and otherwise uses the sentence set in Settings.
[aicl_ai_notice]
For a third-party widget, the automatic notice in Settings covers it. This is the guaranteed route when the automatic one cannot find your widget.
Post meta
Seven keys. The first three are attachment-scoped and describe a file. The last four are post-scoped and describe a post's text. That difference matters more than it looks: changing an image flag changes it everywhere that image is used, on every post.
| Key | On | Holds | Public over REST |
_aicl_source_type | attachment | an IPTC source-type token, or absent | yes |
_aicl_embed_status | attachment | last file-embedding result | no |
_aicl_reviewed | attachment | the detector's "already looked at this" marker | no |
_aicl_text_type | post, page | one of the three AI tokens | only when the post discloses |
_aicl_text_reviewed | post, page | whether a person reviewed the text | never |
_aicl_text_editor | post, page | who holds editorial responsibility | never |
_aicl_text_reviewed_on | post, page | Y-m-d, stamped automatically | never |
The REST rule is that the API says what the page says. show_in_rest is not a read permission, so the plugin filters these out for anyone who cannot edit the post. The review flag, the named person and the date are never exposed; the source type is exposed only where the post actually carries a disclosure, since in that case the page states it anyway. Editors see all four, which is what keeps the block editor on one save path.
The seven source-type tokens are the IPTC DigitalSourceType vocabulary. Three assert AI involvement and four do not:
trainedAlgorithmicMedia AI Generated AI
compositeWithTrainedAlgorithmicMedia AI Modified AI
basicAI AI AI (embeds trainedAlgorithmicMedia)
algorithmicMedia Algorithmic not AI
digitalCapture Camera Photo not AI
digitalCreation Human Created not AI
composite Composite not AI
Only the three AI tokens are valid for post text. The other four describe how a file came to exist and mean nothing for a paragraph.
Constants and options
Defined by the free plugin. None of them is a setting: change behaviour through the filters above, not by redefining these.
| Name | Kind | What it holds |
AICL_VERSION | constant | the running plugin version |
AICL_FILE, AICL_DIR, AICL_URL, AICL_BASENAME | constants | the usual path helpers |
aicl_settings | option | every setting, as one array. Never write it directly, see below. |
aicl_readiness | option | the Article 4 literacy checklist |
aicl_directory | option | the opt-in directory record, including consent |
aicl_setup | option | the first-run flow's answers and its undo journal |
aicl_license | option | the licence record. Written by Pro, read by the free plugin. |
aicl_waitlist | option | the address given for the Pro waitlist, if one was |
aicl_flagged_count | transient | cached count of flagged images |
Uninstall removes every option above and the four _aicl_text_* keys, but deliberately keeps _aicl_source_type and anything already written into your image files. That is your record of how your own media was made, and it outlives the plugin.
Reading provenance in your own code
These static methods are the supported way in. Read the meta directly if you prefer, but these resolve translation copies, cache correctly and keep the vocabulary in one place.
// Images
AICL_Plugin::get_type( $attachment_id ); // 'trainedAlgorithmicMedia' | ''
AICL_Plugin::is_flagged( $attachment_id ); // bool
AICL_Plugin::label_for( $token ); // 'AI Generated', translated
AICL_Plugin::uri_for( $token ); // the full IPTC vocabulary URI
AICL_Plugin::should_badge( $token ); // does this type show a badge
AICL_Plugin::ai_types(); // the three AI tokens
AICL_Plugin::prime( $ids ); // warm the meta cache for a list
// Post text
AICL_Text::status( $post_id ); // none|recorded|discloses|incomplete|exempt
AICL_Text::requires_disclosure( $post_id ); // is one owed under Article 50(4)
AICL_Text::discloses_on_page( $post_id ); // will a line actually print
AICL_Text::record( $post_id ); // type, reviewed, editor, reviewed_on
get_type() resolves a translated attachment back to its source language, so a WPML or Polylang copy returns the flag set on the original. That is why reading the meta by hand can differ from calling this.
requires_disclosure() and discloses_on_page() are not the same question. The first is legal: does Article 50(4) oblige a disclosure. The second is practical: will a line be printed. They differ where an owner discloses voluntarily, on a site the text duty does not reach. Use the first for compliance logic and the second for layout.
// Example: add your own note beside the plugin's disclosure
add_filter( 'the_content', function ( $content ) {
if ( ! is_singular() || ! AICL_Text::discloses_on_page( get_the_ID() ) ) {
return $content;
}
return $content . '<p class="house-note">Edited by the newsroom.</p>';
}, 999 );
AICL_Delivery 2.2.0
Some CDN features and image optimisers re-compress an image after the mark is written, which strips it on the way to the reader. From the server the file still looks marked, so a report built only from disk is wrong. This class fetches one of your own images over HTTP the way a visitor would and reads what actually arrives.
It is a PHP API, not a REST route. check() performs a fetch and stores the result; last() reads the stored record without making a request. Prefer last() anywhere you are rendering, so a page view never blocks on a network call.
| State | Means |
verified | The image came back with the mark intact. |
stripped | The image came back without it. Something in delivery removed it. |
unreachable | The fetch failed, returned a non-200, or came back empty. Not evidence either way. |
not_applicable | There was nothing to check. |
never | No check has run yet. |
The record carries state, url, size, detail and checked_at. detail names the culprit where one is recognisable, such as Cloudflare Polish, Jetpack Photon or BunnyCDN, and is deliberately empty rather than a guess when the response gives no usable signal.
$last = AICL_Delivery::last();
if ( 'stripped' === $last['state'] ) {
// $last['detail'] names the culprit when it can be identified.
}
// Force a fresh fetch. Makes an outbound HTTP request; do not call it on page render.
$now = AICL_Delivery::check();
Treat unreachable as unknown, not as a failure. A site behind HTTP auth, on a private network, or briefly down produces it, and none of those mean the mark is gone.
REST API
Namespace aim-transparency/v1. These exist for the dashboard and are documented so you know what is there, not as a public integration surface: they can change with the dashboard.
| Route | Method | Requires |
/settings | GET, POST | manage_options |
/media | GET | manage_options |
/media/flag | POST | upload_files, plus edit_post on the attachment |
/report | GET | manage_options |
/readiness | POST | manage_options |
/detect | GET | manage_options |
/setup | GET | manage_options |
/setup/step | POST | manage_options |
/setup/undo | POST | manage_options |
/directory | POST | manage_options |
/directory-token | GET | public, by design |
/directory-token is deliberately unauthenticated: the directory calls it to prove you control the site you registered. It returns a token and nothing else.
Post text is written through core's post endpoint, not one of these. The four text meta keys are registered with show_in_rest, so the block editor saves them with the post like any other field.
The Pro add-on
Pro is a separate plugin with prefix AIMPRO_. It adds five filters of its own and nothing else public: no shortcodes, no actions, and no new front-end markup. Everything a visitor sees still comes from the free plugin.
aimpro_generator_signatures 1.0.0
The lowercase substrings matched against a file's head when Pro decides whether an upload was AI-made. 27 ship with it, drawn from what generators write into EXIF and XMP. Add your own if you use a tool that stamps something recognisable.
add_filter( 'aimpro_generator_signatures', function ( $signatures ) {
$signatures[] = 'acme diffusion';
return $signatures;
} );
Matched case-insensitively against the file head, so keep entries lowercase and distinctive. A short common word will flag photographs.
aimpro_auto_flag 1.0.0
Whether Pro flags an upload automatically. Return false to keep automatic detection off while leaving the rest of Pro working, which is what you want if you would rather review every image by hand.
add_filter( 'aimpro_auto_flag', '__return_false' );
aimpro_update_api 1.0.0
Where Pro checks for its own updates, since it is not distributed through wordpress.org. Defaults to the AIMPRO_UPDATE_API constant. Repoint it if you mirror releases internally.
add_filter( 'aimpro_update_api', function () {
return 'https://updates.example.com/aim-pro';
} );
aimpro_dodo_api_base aimpro_dodo_brand_id 1.0.0
The licensing endpoint and the brand an activation must belong to. The brand check is what stops a key from any other store unlocking Pro, so changing it is a licensing decision, not a configuration one. They exist for testing against a sandbox, not for day-to-day use.
Pro meta, constants and routes
| Name | Kind | What it is |
_aicl_suggested | attachment meta | Pro's suggestion, before a human confirms it. Never a disclosure on its own. |
_aimpro_scanned | attachment meta | marks an image the library scanner has already looked at |
AIMPRO_VERSION | constant | defined only when Pro is active, which is how the free plugin detects it |
AIMPRO_UPDATE_API | constant | update endpoint base, overridable by the filter above |
AIMPRO_DODO_BRAND_ID | constant | the brand an activation must match |
AIMPRO_FILE, AIMPRO_DIR, AIMPRO_URL, AIMPRO_BASENAME | constants | the usual path helpers |
/license | REST, GET | current licence state |
/license/activate | REST, POST | activate a key on this site |
/license/deactivate | REST, POST | release the seat |
/scan | REST, POST | run the library scanner. manage_options. |
Pro's routes sit in the same aim-transparency/v1 namespace as the free plugin's.
Checking for Pro from your own code
Ask the free plugin, not the add-on. AICL_Plugin::is_pro() requires Pro to be loaded and entitled; pro_installed() only tells you the plugin is active.
if ( AICL_Plugin::is_pro() ) {
// a licensed, entitled Pro install
}
Entitlement is decided inside Pro by AIMPRO_License::entitled(), against a signed record tied to the site it was activated on. There is no filter or constant that grants it, and the aicl_is_pro filter that once did was removed for exactly that reason.
aimpro_dodo_brand_id 1.0.0
The Dodo brand an activation must belong to, so a key issued by any other store cannot unlock Pro. Defaults to the AIMPRO_DODO_BRAND_ID constant. It fails closed: return an empty value and no activation matches.
CSS hooks
The stylesheet loads only on pages where something actually renders. Override in your theme rather than editing the plugin.
/* The image badge */
.aicl-badge /* the pill itself */
.aicl-badge.aicl-eu /* official EU icon style */
.aicl-badge.aicl-eu-svg /* official EU icon, as the supplied PNG */
.aicl-badge.aicl-shape-* /* custom style only: pill, square, tag, ribbon, … */
.aicl-badge.aicl-has-logo /* set when your own logo is in the badge (2.1.6) */
.aicl-badge-logo /* that logo; height/width carry !important */
.aicl-wrap /* fallback wrapper, only when the theme gives no anchor */
.aicl-bg /* a background-image container hosting a badge */
/* The Article 50(4) text disclosure */
.aicl-text-disclosure /* the line */
.aicl-text-disclosure--subtle /* boxed, borrows theme colours */
.aicl-text-disclosure--plain /* no box */
.aicl-text-disclosure--custom /* own colours, via CSS variables */
.aicl-text-disclosure__mark /* the small chip before the sentence */
/* Click to disclose */
.aicl-modal, .aicl-modal-overlay, .aicl-modal-title, .aicl-modal-body
Two of those are worth a sentence. .aicl-shape-* exists only on the custom badge style; the AIM and EU styles emit .aicl-eu or .aicl-eu-svg instead, so a selector written against a shape will not match them. And .aicl-has-logo is added from 2.1.6 whenever a logo is set, which is how the plugin hides the small dot without needing :has(). Bring the dot back with .aicl-badge.aicl-has-logo::before { display: block; }.
The custom styles read CSS variables set on the element, so you can override them without fighting specificity:
.aicl-text-disclosure {
--aicl-td-bg: #102a43;
--aicl-td-fg: #f0f4f8;
--aicl-td-radius: 10px;
--aicl-td-mark-radius: 999px;
}
Things that will bite you
Never write the settings option directly
The sanitizer rebuilds its output from scratch and only writes the keys it enumerates, so handing it a partial array silently resets every setting it does not mention. Always merge over the current values, which is exactly what the supported helper does.
// Right
AICL_Plugin::apply_settings( [ 'badge_enabled' => 0 ] );
// Wrong: wipes every other setting
update_option( 'aicl_settings', [ 'badge_enabled' => 0 ] );
The plugin has no upgrade routine
Verified against 2.1.6: no stored schema version, no migration, and no upgrader_process_complete hook. Activation seeds the defaults once and only when the option is absent, so it never runs again on an existing site. New settings survive an update because unknown keys fall back to defaults through wp_parse_args. That is fine for additive changes and only for additive changes: if you fork it, renaming or removing a settings key strands data with nothing left to read it.
Any user-facing string is a 21-locale job
The plugin bundles 21 translation sets of its own, covering 22 official EU languages with English, rather than relying on a language pack. Add a string and you have added it in one language out of 22, and a German admin will see one English line among translated ones. The same applies to a string you change: the old translation is orphaned, not adapted.
Image flags are global, text flags are not
Setting an image's source type from inside a post writes to the attachment and re-embeds the file, so it changes that image on every post, page and product that uses it, immediately, before you press Update. Post text meta saves with the post and stays there. Two controls that sit next to each other in the editor behave completely differently, and this is why.
Non-AI tokens still write metadata
Marking an image as Camera Photo or Human Created writes its IPTC value into the file and emits JSON-LD, but shows no badge. That is deliberate: a positive statement that something is not AI is worth recording. Do not treat "no badge" as "not flagged".
What is not an API
Everything else. Class names, protected methods, the option array's shape, the dashboard bundle, the detector's internal signal format and the shape of the dashboard REST responses are all internal and change without notice. If you need something that is not on this page, ask rather than reaching into it, and it may become a filter.
Removed on purpose
The aicl_is_pro filter was removed in 1.2.x. It let any code on the site turn Pro features on, which made licensing a formality. Pro entitlement is now decided inside the Pro add-on, against a signed record tied to the site it was activated on, and the free plugin only asks. There is no constant or filter that grants it.
Not modelled, and deliberately
Article 50(4) carries a second exemption for uses "authorised by law to detect, prevent, investigate or prosecute criminal offences". The plugin does not implement it, because a WordPress setting is not the right vehicle for a criminal-investigation carve-out. Nothing in the interface claims the exemption it does model is the only one.
Found a gap, or want a hook that is not here? Tell us. The extension points that exist were mostly added because somebody asked.