AI-Generated Content Marking Specification
How AI-generated text and audio are marked, and how software can detect and check the mark.
Last updated: October 26, 2026
This document describes how NovixAgent AI marks content that an AI model produced, so that software can identify that content as artificially generated without a person in the loop (EU AI Act, Article 50(2)). It is written for those building a detector: a platform, a browser extension, an archive, or a market-surveillance authority.
It describes the marking format and where the mark travels. It does not describe how the content itself is generated. Section 2 lists the deliveries that carry a mark and states which text is hashed for each; section 3 lists the deliveries that do not, and why.
1. The assertion {#assertion}
The mark has one JSON shape, whichever surface or file format carries it. It is published in the API contract as the schema ContentProvenanceResponse. The names in the Field column are the object's keys as they appear in the JSON; the contract names the same property with the schema in front, so the key aiGenerated is ContentProvenanceResponse.aiGenerated there, producedAt is ContentProvenanceResponse.producedAt and contentSha256 is ContentProvenanceResponse.contentSha256.
| Field | Type | Meaning |
|---|---|---|
spec |
string | The string novixagent/content-provenance, naming this specification. A reader that does not recognise the string must ignore the object rather than guess at its fields. |
v |
integer | Shape version, currently 1. Changed only by a breaking change to the shape; a reader that does not know the version must ignore the assertion. |
aiGenerated |
boolean | The value true. An assertion is built only for content a model produced, and the field is present so a detector matches on a value rather than on the mere presence of a key. |
modality |
string | text or audio. |
generator |
string | The product name of the generating system. A deployment operated under its own brand is named by that brand. On text built from stored values when it is delivered (the held-draft read in section 2.3, the email in section 2.5 and the deliveries in section 2.6) it is the brand in force at the time of that delivery; on the greeting and the translated instructions (sections 2.2 and 2.7) it is the brand in force when the text was generated. |
model |
string | The model identifier. On the widget's greeting, on drafted follow-up messages and on the English translation of custom instructions it is the identifier the model vendor named in the response that carried this output, and when the vendor's response names no model, no assertion is built and the content carries no mark. On chat answers, on synthesised speech and on chat answers shown again later it is the identifier of the model this system called, including after a switch to a fallback model, rather than one the vendor reported back. Wherever the identifier is stored for a later delivery (the greeting, the translated instructions, follow-ups, answers shown again), a blank identifier or one longer than 128 characters yields no mark rather than a shortened or invented name. |
producedAt |
string | The instant of generation, as an RFC 3339 timestamp in UTC. On text delivered again from storage (the held-draft read in section 2.3, the email in section 2.5 and the deliveries in section 2.6) it is the instant the stored row was written, so a repeated delivery states the same time. |
contentSha256 |
string | Lowercase hexadecimal SHA-256 (64 characters) of the marked content, taken before the mark was attached. Section 2 states, for each carrier, which bytes are hashed, and section 1.2 states the canonical form that applies where a carrier names it. |
The assertion is deliberately data-minimal. It contains no visitor, session, account or website identifier, no prompt and no copy of the content.
1.1 Checking an assertion {#checking}
- Parse the JSON. If
specis notnovixagent/content-provenance, orvis a version you do not know, stop: the object is not one you can interpret. - Read
aiGenerated, which this system sets totrue. - Recompute the SHA-256 of the content in front of you, over the bytes section 2 names for its carrier — applying the canonical form of section 1.2 first where the carrier says so — and compare it with
contentSha256. - If the hashes match, the assertion describes this content. If they do not, the content in front of you is not the content that was marked — it was edited, truncated or replaced after generation — and the assertion must not be taken as a statement about it.
1.2 The canonical form {#canonical-form}
Text that was stored and is delivered again is handled on the way to you: a spreadsheet rewrites an embedded line break as CRLF, a downloaded file puts a tab at the start of a cell that begins with =, +, - or @, and a mail system adds a trailing line break. For those carriers the hash is taken over the canonical form of the text, so a copy that went through such handling still reproduces it. To compute it, in this order:
- Replace each CRLF pair (CR followed by LF) with a single LF, in one pass from the start of the text. A CR that is not followed by LF stays as it is.
- Remove, from the start and from the end of the result, each run of the four characters space (U+0020), tab (U+0009), CR (U+000D) and LF (U+000A). No other character is removed: a form feed, a no-break space (U+00A0) and the ideographic space (U+3000) stay, and the text between the two ends is left as it is.
- Encode the result as UTF-8 and hash it with SHA-256, written as lowercase hexadecimal.
A text that is empty after step 2 receives no mark.
The canonical form applies to the follow-up email (section 2.5), to the deliveries of stored text in section 2.6, and to the follow-up draft when it is read again after it was stored (section 2.3). It does not apply to the chat stream, the greeting, the draft in the response that generated it, the translated instructions or the audio: for those the hash is taken over the bytes section 2 names.
2. Where the mark travels {#carriers}
| What a person receives | Section | Where the mark is | Text hashed |
|---|---|---|---|
| Chat answer in the widget | 2.1 | A provenance frame in the answer stream |
The streamed delta values before the frame, as sent |
| The widget's greeting, in each language | 2.2 | A provenance field beside the greeting in the widget configuration |
The greeting string, as served |
| A drafted follow-up message, shown to the account owner | 2.3 | A provenance field beside body |
body, as returned by the generating call; canonical form when the stored draft is read again |
| Synthesised speech | 2.4 | Metadata inside the MP3 or WAV file | The audio before the metadata was attached |
| A follow-up email | 2.5 | An email header | The text/plain part, in canonical form |
| A stored answer or follow-up shown again later | 2.6 | A provenance field beside the text, or a column of the downloaded file |
The text the route delivers, in canonical form |
| The generated greeting and the English translation of custom instructions, shown back to the account owner | 2.7 | welcome_provenance, custom_prompt_provenance, or a provenance entry inside the greeting document |
The string as served |
| The assistant's answer handed to software: a tool result of the AI-app door or of the owner door, or the body of the REST API chat | 2.8 | A provenance field on each element of parts |
The text of that element, as delivered |
2.1 Chat answers in the widget {#chat-stream}
The widget's chat answer (POST /v1/ai-provider/chat) is a server-sent event stream. Each frame is a data: line holding one JSON object with a type and a protocol version v. The answer text arrives in frames of type chunk, each carrying a delta string.
The model's text is followed by a frame of type provenance whose assertion property is the object described in section 1. A frame describes the chunk deltas received since the previous provenance frame, or since the start of the stream, and it is sent after the last delta the model wrote. On a turn the model completes, the last provenance frame is the last frame before the frame of type done; an ordinary turn carries one. When a different model is about to write the next delta, as after a switch to a fallback model between the pass that called a tool and the pass that answers, a frame over the text already sent, naming the model that wrote it, is sent first, so such a turn carries one frame over each model's text. When the service adds a sentence of its own after the model's text — because the model returned no answer after a tool call, or because the stream failed part-way through — the frame is sent before that sentence, and the sentence is not part of what it describes. A failed stream then ends with a frame of type error instead of done. A stream in which the model sent no text, or only whitespace, carries no provenance frame.
Hashed bytes: the UTF-8 encoding of the chunk frame delta values received since the previous provenance frame, or since the start of the stream, concatenated in the order received. This is the text the visitor was sent, and it is not put through the canonical form. model names the model that wrote those deltas.
No provenance frame describes text a person wrote rather than the model: a hand-written message substituted because the model produced no text, hand-written copy a safeguard put in place of the model's answer, or the hand-written sentence that follows the model's text on the turns described above. Where such a message is the only text of the turn, the turn carries no frame. The mark would otherwise state that a person's words were machine-written.
2.2 The widget's greeting {#welcome-message}
The widget configuration (GET /v1/api/config/{websiteId} and GET /v1/api/config/{websiteId}/full) carries the greeting for the language it resolved in its welcome_message block. The block's welcome_message string is the greeting, and its provenance field, beside it, holds the assertion or null.
Each language of the greeting set is marked on its own account. The greeting in the language the site is written in and each translation of it carry the identifier of the model call that wrote that text, and each assertion is built over the greeting as it is stored, after markup has been stripped from it, so the string served is the string hashed. producedAt is the instant the set was generated.
Hashed bytes: the UTF-8 encoding of the welcome_message string, as served. The starter questions served beside it are not part of the hash and carry no mark.
provenance is null whenever the greeting is not being presented as machine-written: the hand-written fallback stored when generation produced no usable set, such as a site with no content to generate from; a translation whose model call failed or named no model; a greeting stored before marking existed; and a greeting that has been changed since. The service serves a mark only while its hash equals the greeting being served, so an edit by the site owner removes the mark instead of leaving a hash that no longer reproduces.
2.3 Drafted follow-up messages {#outbound-draft}
When an account owner asks NovixAgent AI to draft a follow-up message to a lead (POST /v1/outbound/{sourceType}/{sourceId}/generate), the response carries the drafted body with a provenance field beside it.
Hashed bytes: the UTF-8 encoding of body, as the response returns it. The subject is not part of the hash. producedAt is the instant of generation.
When the content check holds an automatic follow-up for the owner's review, the held draft is read with GET /v1/outbound/{sourceType}/{sourceId}/held, whose held object carries body and a provenance field in the same way. That assertion is built from the stored draft, so it is hashed over body in the canonical form of section 1.2, and producedAt is the instant the draft was stored. The data export (section 2.6) carries the same field for each follow-up it lists.
provenance is null when the draft is the hand-written fallback used after a model failure, when the model vendor named no model, or, for a stored draft, when no model was recorded for it. A consumer shows an AI-generated indication on a non-null value and not on the mere presence of the key: a null is the absence of a mark, not a statement that a person wrote the text (section 3).
A follow-up sent by email carries its own mark in a header (section 2.5). A follow-up on WhatsApp is an approved message template filled in from facts the service holds; the drafted body is not sent on that channel. On a voice call the drafted body is handed to a third party's voice agent as talking points (section 3).
2.4 Synthesised speech {#audio}
Speech returned by the widget's voice control (POST /v1/voice/tts) is MP3 or WAV, and both carry the assertion as metadata inside the file, under the key ai-content-provenance. The value is the assertion serialised as one line of JSON with no whitespace.
MP3. An ID3v2.4 tag is placed at the very start of the file, holding one TXXX frame with text encoding 0x03 (UTF-8), the description ai-content-provenance, a NUL byte, and the JSON value.
WAV. A RIFF LIST chunk of type INFO is appended after the audio data, holding one ICMT sub-chunk whose value is ai-content-provenance= followed by the JSON value, NUL-terminated and padded to an even length. The size field of the RIFF header is increased by the length of the appended chunk. When the audio data ended on an odd offset, one NUL pad byte is written before the LIST chunk, and the size field counts it.
Hashed bytes: the audio as it was before the metadata was attached.
- For MP3, remove the ID3 tag at the start of the file — its 10-byte header plus the tag size that header declares — and hash the remainder.
- For WAV, remove the appended
LISTchunk and the pad byte before it, if one was written, subtract their length from the RIFF size field, and hash the result.
No other audio format is offered by this route.
2.5 Follow-up emails {#email}
A follow-up email written by a model carries the assertion in an email header named ai-content-provenance, with no X- prefix. The header name is the same string as the key used in the audio files of section 2.4.
Header value. The assertion serialised as one line of JSON with no whitespace, then encoded as base64url (RFC 4648, section 5) without padding. The alphabet holds only letters, digits, - and _, so the value contains no line break and no byte a mail system may re-encode. To read it, base64url-decode the value, decode the bytes as UTF-8 and parse the JSON as in section 1.1. A retried send writes the identical value.
Hashed bytes: the text/plain part of the message, in the canonical form of section 1.2. That part is the drafted body, a blank line, and the visible sentence stating that the message was written by an AI assistant. The subject is not part of the hash. The HTML part of the message carries no mark: a rendering is not reproducibly re-hashed, and the assertion describes the plain-text part alone.
The header is written when the email is sent, from stored facts only: the model the vendor named when the draft was written and, as producedAt, the instant the draft was stored.
The email goes out without the header, and the service records the gap and raises it with its operators, when the stored facts support no mark (the draft was written before the model was recorded, the vendor named no model, or the name was longer than 128 characters), when building the mark fails, or when the mail provider rejects a message that carries the header and accepts it again without. An email whose body a person wrote carries no header, because no model wrote it.
2.6 Answers shown again later {#replayed}
Chat answers, the replies sent on messaging platforms and follow-up messages are stored. The provenance frame of section 2.1 is a stream event and is not stored, so each delivery of a stored text builds its own assertion from three stored values — who wrote it, the model recorded for it, and the instant its row was written — and hashes the text that delivery hands over, in the canonical form of section 1.2.
| Delivery | Route | Field | Text hashed |
|---|---|---|---|
| The account owner's transcript of a conversation | GET /v1/client/conversations/{websiteId}/{sessionId} |
provenance beside text in each entry of messages |
text, as the owner receives it |
| The same transcript as a downloaded file | GET /v1/client/conversations/{websiteId}/{sessionId}/export |
The provenance column of the message table, one assertion per turn as compact JSON, or an empty cell |
The text cell, in canonical form, which discards the tab and the CRLF a spreadsheet adds |
| The panel an operator sees when a conversation is handed over | GET /v1/live-chat/{websiteId}/conversations/{conversationId}/ai-context |
provenance beside text in each entry of aiContext.messages |
text, as the operator receives it (not masked) |
| The support staff's view of a conversation | GET /v1/support/conversations/{sessionId} |
provenance beside assistantResponse in each entry of messages |
assistantResponse, after personal details were masked |
| The same transcript read by software that holds an API key | GET /v1/public-api/websites/{websiteId}/conversations/{sessionId} |
provenance beside text in each entry of messages |
text, as the key holder receives it |
| The same transcript as a downloaded file, through the API | GET /v1/public-api/websites/{websiteId}/conversations/{sessionId}/export |
The provenance column, as in the dashboard's file |
The text cell, in canonical form |
| The same transcript read by an AI app the account owner connected | The get_conversation tool of the owner door |
provenance beside text in each entry of messages in the tool result |
text, as the app receives it |
| The data export of a site | GET /v1/client/deletion/websites/{id}/export |
provenance beside assistant_response in each entry of data.messages |
assistant_response, as exported |
| The same export, replies sent on WhatsApp, Messenger and Instagram | as above | provenance beside content in each entry of data.channelMessages |
content, as exported |
| The same export, follow-up messages | as above | provenance beside body in each entry of data.outboundMessages |
body, as exported, while it is stored |
An assertion is built only for an answer recorded as written by the model. It is null, and the cell is empty, on a visitor's turn; on a turn whose text was pre-written platform copy put in the model's place; on a row older than the record of who wrote the answer; and whenever no text is delivered because it was removed under the site's retention setting. Three further rules follow from hashing the text the route delivers:
- Staff reading another account's site receive masked text. In the support view the mark is built over the masked text, and a turn that was withheld in full because it repeated a form answer carries no mark. When staff open the owner's transcript route for another account's site, a turn whose text was rewritten for them carries no mark, because a digest of the words they were not given would disclose those words.
- A reply sent on a messaging platform is marked from the model its live turn reported. The
contentin the export is the text as the service stored it: the model's Markdown, with the visible sentence stating that the reader is talking to an AI assistant on the first reply of a conversation, and product cards appended when they were sent. The platform was sent the same text converted to its own markup, so reproduce the hash from the exportedcontent, not from what the platform displayed. Replies that are not the model's — such as the fixed reply to an unsupported message type — carry no mark. A reply in which the model's text is followed by a sentence the service added (section 2.1) is stored as one string, and the export marks that string as stored, the added sentence included, as it includes the visible sentence and the product cards; a reply that consists of the added sentence alone carries no mark. One model is stored per reply: when two models wrote a reply, the export names the model of the last frame the live turn sent. - Because each delivery builds the assertion again, an assertion on stored text need not be equal to the one the live stream carried, and
generatorfollows the brand in force at the time of delivery.
2.7 Texts shown back to the account owner {#owner-texts}
Two generated texts are shown to the account owner in the dashboard, beside what the owner wrote. Each is marked only while it is unchanged, because the owner may edit either by hand and marking an edited text as machine-written would be a false statement.
The generated greeting. Wherever the dashboard receives the stored greeting document — the surface-context read (GET /v1/client/websites/{id}/surface-context?surface=..., on each of its surfaces) and the echoes of the saves made from it, the websites list and card (GET /v1/client/websites and GET /v1/client/websites/{id}), and the responses to creating and saving a website — a language entry keeps its embedded provenance entry only while its hash equals the greeting beside it. A stale or foreign one is removed from the copy served, so the owner's edit removes it. The surface-context read also states the surviving assertions as welcome_provenance, an object keyed by language code. Hashed bytes: the UTF-8 encoding of that language's welcome_message string, as in section 2.2. The starter and spare questions carry no mark.
The English translation of the owner's custom instructions. When the owner saves custom instructions, a model translates them into the English text the assistant reads. On the ai_templates surface of the surface-context read, custom_prompt_provenance sits beside custom_prompt_template, the English text, and the support staff's diagnostic of the site carries the same assertion beside it. Hashed bytes: the UTF-8 encoding of custom_prompt_template, as served. It is present only while the instructions are in the machine-translated state and the hash equals the English text served; a later edit by the owner, a reset to a template, applying a template, and a failed translation each leave no mark. The assertion is written together with the translation it describes and no request can write it.
2.8 Answers handed to software {#software-answers}
Three routes hand the assistant's answer to a program instead of to a person's screen, and each builds the body that carries it:
- the
asktool of the AI-app door, which a customer's AI app calls to put a question to a business's assistant; - the
ask_assistanttool of the owner door, which an AI app or assistant the account owner connected calls to put a question to the owner's own assistant; and - the REST API chat,
POST /v1/public-api/websites/{websiteId}/chat, called with an API key and published in the API contract as the schemaPublicChatResponse.
The program relays the text to a person, and a stream frame would be dropped on the way, so the mark is a field of the body, beside the text it describes. The body has a parts list in the order the text was written. Each element has text and provenance. provenance is the assertion of section 1 for that text, or null for text no model wrote, such as a sentence the service adds after the model's text. answer is the text values joined in order. A turn answered by one model is one part with a mark; when a different model wrote the next stretch, as described in section 2.1, there is one marked part for each model; and a sentence the service adds is a part of its own with provenance set to null, outside the marked parts.
The REST API body and the result of ask_assistant also carry ai_generated, which is true when at least one part carries an assertion and false when no part does, as when the service put a sentence of its own in place of an answer the model did not give. The result of ask has no ai_generated; a reader looks at the parts. The results of ask and ask_assistant also carry authorship, a plain sentence for the calling model to pass on, saying that the business's or the site's assistant wrote the answer; it is not an assertion and is not hashed. A tool result holds the body twice, as the structured content of the result and as the JSON text of the result, and the two are the same object.
Hashed bytes: the UTF-8 encoding of the text of the element, as delivered. The canonical form of section 1.2 does not apply. contentSha256 is the SHA-256 of that text, so a program re-hashes the part it holds. With stream: true the REST API chat sends the chat stream of section 2.1, with its provenance frames, instead of a body.
The mark is for the program. Whether the person at the other end is told that they are dealing with an AI system (Article 50(1)) is for whoever shows the answer to that person. An integrator that shows REST API answers to its own end users has that duty itself; ai_generated and the marks in parts are what the service hands it to meet the duty and to keep the Article 50(2) marking intact when it passes the text on. The service does not see that person and does not tell them.
3. What is not marked, and why {#not-marked}
Article 50(2) requires marking insofar as it is technically feasible. These are the places generated content reaches a person without the machine-readable mark today.
- Replies inside messaging platforms (WhatsApp, Messenger, Instagram). The reply is handed to the platform's messaging API as a plain string, and that API offers no recipient-visible metadata field to carry a mark. The first reply in a conversation carries a visible disclosure that the reader is talking to an AI assistant instead. The stored copy of the reply, as exported, is marked (section 2.6).
- Calls placed by a third-party voice agent. The words a listener hears are produced by that provider's system, not by ours. The script handed to that provider is instructed to open by saying that an AI assistant is calling on behalf of the business; the words spoken are the provider's, and the opening is an instruction to the model, not a guaranteed property of the call.
- The HTML part of a follow-up email, and an email that went out without the header. The mark of section 2.5 covers the plain-text part and rides in a header. It is lost when a mail client saves or forwards the message without its headers; the visible sentence in the body is what survives. An email sent unmarked for one of the reasons listed in section 2.5 is delivered, not held back.
- Turns in which a person's words stand in the model's place. A chat turn whose text was wholly replaced by hand-written copy carries no mark. Where hand-written copy follows the model's text, the stream's frame describes the model's text alone and the copy after it is unmarked (section 2.1); the exported copy of such a reply on a messaging platform is marked as one string (section 2.6). Stored answers older than the record of who wrote them carry no mark either (section 2.6).
- Text whose model is not on record. Where the vendor's response names no model, or the name is blank or longer than 128 characters, the service builds no mark rather than name a model that did not answer. This reaches the greeting, the translated instructions and follow-up messages. A follow-up email still carries its visible sentence (section 2.5).
- Starter and spare questions. The model also writes these beside the greeting, but a hash binds one string, and a reader holding the object does not know how several would be joined, so the mark covers the greeting alone.
- What a program does with an answer after it receives it. The service marks the body it returns (section 2.8). A program that shows only
text, or a person who copies it, loses the mark (section 4), and the visible notice to the person is the program's to give.
4. Limits of the mark {#limits}
- No digital signature. The assertion is bound to its content by the hash, not signed. A party able to change both the content and the assertion can make them agree; the hash detects a change to the content alone.
- Text a site owner edits. The greeting document is stored as one object that the site owner saves in one piece. The routes that save it discard the marks a request carries and keep a mark the service wrote earlier only for a language whose greeting it still describes, so a mark the owner sends is not stored, and an edit removes the mark. The service also serves a mark only while its hash equals the greeting being served. The fields that identify the mark (
spec,vandaiGenerated) are restated by the system from its own values rather than copied from stored data, so an object foreign to this specification is not published under its name. The English translation of custom instructions and the author, model and instant behind stored answers are written by the system alone. - Marking does not hold a delivery back. A follow-up email or a piece of speech whose mark could not be written is delivered without it, and the gap is reported to the service's operators; refusing to send a message or to answer a visitor because a tag could not be written would turn a transparency gap into an outage.
- Metadata can be stripped. Copying the text out of the widget, forwarding or re-saving an email without its headers, deleting the
provenancecolumn of a downloaded file, or re-encoding the audio, removes the mark. Metadata marking does not survive that, and the specification does not claim it does.