The Browser Annotation API lets your website customize what people select, what context accompanies their feedback, and which controls they use to preview changes before sending an annotation to ChatGPT.
The Browser Annotation API isn’t currently available in ChatGPT Enterprise or Edu workspaces.
Browser annotations work on your site without any code changes. People can select part of a page, add a comment, and send it in Context to Codex or ChatGPT Work.
As a developer, you can use the Browser Annotation API to provide context or controls specific to your application. For example, you can attach previews for component variants in a design system preview for developers to know how to update the components on a website.
To get help understanding the Browser Annotation API or adding annotation support to your website, install the Annotations Extensibility plugin.
Install the Annotations Extensibility plugin
See the Browser Annotation API in action on this guide.
- Open this page in ChatGPT’s built-in browser.
- Try opening the suggested prompt that will appear in this card.
- Enter Annotation mode, then select the table below or a code sample to switch between predefined layouts and themes.
- You can still annotate any item on the page and see default annotation behavior.
Choose what to customize
Start with the integration that fits your website:
| Goal | Integration |
|---|---|
| Make a card or other group of elements selectable as one object | Selection targets |
| Let people select a phrase or sentence | Text selection containers |
| Include additional context with a selection | Selection metadata |
| Open an annotation from your own button with a suggested comment | Annotation requests |
| Request feedback on an exact passage from your own UI | Text-range requests |
| Show advanced controls when an annotation opens | Editor defaults |
| Preview application properties or collect choices | Custom controls |
| Select individual objects drawn inside a canvas | Annotation surfaces |
| Turn Annotation mode on or off from your site | Annotation mode controls |
This guide targets the DevDay 2026 release of the ChatGPT desktop app and later.
The JavaScript API is available through document.oai.annotation in the app’s
built-in browser, on secure, top-level pages such as HTTPS or localhost. When
enabled, the browser installs it before your page’s scripts run. Feature-detect
each method to support older or unsupported browsers, then initialize your
integration once its DOM elements exist. No readiness event or polling is needed.
API methods return synchronously, and registration handles are ready to use
immediately. The browser can finish loading the annotation editor afterward.
A surface’s hitTest callback can return a promise.
Customize selection targets
By default, Annotation mode selects elements from the page’s DOM, favoring
targets such as text, images, and controls. To make a larger object selectable,
mark its containing region with oai-annotation-container and its selectable
descendants with oai-annotatable.
This example makes a chart card selectable as one object:
<section oai-annotation-container>
<article id="chart-card" oai-annotatable="Q3 Revenue">
<h2>Q3 Revenue</h2>
<p>$120,000 this quarter</p>
<button type="button">More info</button>
</article>
</section>Pointing anywhere within the card highlights the entire card. The optional
oai-annotatable value gives the object a name shown to the user and the model.
Choose names that distinguish nearby objects, or omit the value.
The container defines where these selection rules apply. An
oai-annotatable attribute on its own doesn’t change selection behavior.
Within a container, the browser selects the nearest marked target containing
the element under the pointer. Nested containers use the nearest container.
Unmarked areas inside a container create a webpage annotation; areas outside
all containers keep the default behavior.
Enable text selection
Add oai-annotation-container-text to a region to let people drag to select text
during Annotation mode. The attribute doesn’t need a value:
<article oai-annotation-container-text>
<h2>Design guidelines</h2>
<p>Use consistent spacing between related components.</p>
<p>Leave more space between separate groups.</p>
</article>Releasing a nonempty selection opens the text annotation editor with the selected range and its context. Clicking without selecting text doesn’t create an annotation. Escape or a canceled gesture cancels the selection.
The nearest text or DOM selection container determines how a gesture begins.
Text containers don’t use oai-annotatable markers for element picking. Nest
an oai-annotation-container to restore element picking, or a text container to
restore text selection. If both attributes are on one element, text selection
takes precedence.
Text selection follows the page’s normal selection rules and can extend beyond
the starting container. A container doesn’t clip the range or enable selection
inside an iframe. Text fields remain selectable, but ordinary page clicks and
native actions on controls remain blocked during Annotation mode. Explicit
request() calls keep their existing behavior.
Text selection containers take effect only during Annotation mode when the site annotation capability and per-site setting are enabled.
Add context to a selection
Add oai-annotation-metadata to a marked target to include context that may not
be visible on the page. For example, a fictional contact row can include an
email address for a request to draft an email:
<section oai-annotation-container>
<div
id="contact-row"
oai-annotatable="Alex Morgan"
oai-annotation-metadata='{"email":"alex@example.com"}'
>
<div>Alex Morgan</div>
<div>Project lead</div>
</div>
</section>Selecting either line selects the entire row. The metadata appears with the annotation and accompanies it in the conversation. Include only context you intend to share with both the user and the model. Sending an email would still require a connected email tool.
Use a small, flat JSON object with these limits:
- Up to six properties, with string, finite number, boolean, or
nullvalues. - Keys up to 64 characters and string values up to 256 characters.
- Up to 2,048 bytes for the serialized object.
Keys must start with an ASCII letter and contain only ASCII letters, digits, spaces, underscores, or hyphens. Use single spaces between words. Nested objects and arrays aren’t supported. The browser ignores invalid metadata.
You can also provide metadata through request() or a surface’s hitTest
result.
Open an annotation from your website
Call document.oai.annotation.request(target, options) directly from a user
interaction, such as a button press. The target can be a connected HTML element
within the visible area of the current document or a
DOM Range. It doesn’t need annotation
attributes. Virtual object IDs aren’t supported.
Site-initiated requests may require user permission; users can re-enable blocked annotation features under Site tools > Annotation features.
Add this button beside the chart card from the selection example, then run the script after both elements exist:
<button id="discuss-chart" type="button" hidden>Explain more</button>const card = document.getElementById("chart-card");
const button = document.getElementById("discuss-chart");
button.hidden = typeof document.oai?.annotation?.request !== "function";
button.addEventListener("click", () => {
const annotation = document.oai?.annotation;
if (typeof annotation?.request !== "function") return;
annotation.request(card, {
initialComment: "Explain the latest trend in this graph.",
});
});Selecting Explain more asks the browser to open an annotation with the card selected and an editable comment. The person can edit, save, and send it with their message. Opening an annotation doesn’t send a message to ChatGPT; only the user can submit it.
Keep the call within the active user interaction. Awaiting a network request first can lose that interaction.
The optional second argument supports these fields:
| Option | Behavior |
|---|---|
mode |
Use "advanced" to open advanced controls for an element, or "default" to use the default editor behavior. Defaults to "default". Custom controls can still open with "default"; see Editor defaults. Text ranges support only "default". |
enterAnnotationMode |
Set to true to enter Annotation mode and remain in it after canceling or sending the annotation. |
metadata |
Valid, nonempty metadata replaces the target’s HTML metadata for this request. This option is ignored for text ranges and when the target element is inside a shadow DOM. |
initialComment |
Supplies an editable comment, up to 240 UTF-16 code units. |
request() returns an object with an accepted boolean. Check
result.accepted, not the result object, to see whether the browser received
and validated the request. It doesn’t confirm that the editor opened or that
the person saved or sent an annotation.
While the editor loads, the browser can hold one accepted request. It can decline another while a request is pending, an editor is open, or ChatGPT is controlling the browser.
Request an annotation for a text range
Pass a DOM Range to request feedback on a passage without changing the browser’s
text selection. This example selects the paragraph’s contents; it doesn’t need
an oai-annotation-container-text attribute:
<p id="draft-passage">Leave more space between separate groups.</p>
<button id="discuss-passage" type="button" hidden>Discuss this passage</button>Run this script after both elements exist:
const passage = document.getElementById("draft-passage");
const button = document.getElementById("discuss-passage");
button.hidden = typeof document.oai?.annotation?.request !== "function";
button.addEventListener("click", () => {
const annotation = document.oai?.annotation;
if (typeof annotation?.request !== "function") return;
const range = document.createRange();
range.selectNodeContents(passage);
annotation.request(range, {
initialComment: "Suggest a clearer version of this guidance.",
});
});The range must contain nonempty visible text in the current document, with at least part of the selection in the visible page area. It can contain at most 20,000 UTF-16 code units. Collapsed ranges, whitespace-only text, hidden selected text, and ranges in closed shadow roots aren’t supported.
Text-range requests use only the default text editor. A request with
mode: "advanced" is rejected, and request metadata is ignored. Keep the target
text available while an accepted request waits for the editor: the browser
checks the range again before opening it. Text selection containers separately
enable people to drag to select text during Annotation mode.
Choose the editor’s default mode
To show advanced controls immediately for annotations opened through the
browser’s selection UI, add this tag to your page’s <head>:
<meta name="oai-annotation-editor-default-mode" content="advanced" />For annotations opened from your own UI, pass { mode: "advanced" } to
request(). Use mode: "default", or omit it, for the default editor behavior.
The page’s meta setting doesn’t override this request option. Default mode
doesn’t guarantee a comment-only editor: custom controls with proposed starting
values, or controls that omit currentValue, can open the controls editor.
Manual Adjust, collapse, and Option-click controls are available only in Codex or on localhost. On hosted sites in ChatGPT, registered custom controls appear automatically without an Adjust or collapse button. Page-requested advanced mode still works there.
Add custom controls
Use registerControls() to associate annotation controls with one or more DOM
elements. Controls can preview application properties, such as a spacing token,
or collect choices to include with a request, such as an email tone.
Preview a shared spacing token
Both cards in this example use the same CSS property:
<style>
#component-preview {
--card-padding: 16px;
}
.preview-card {
padding: var(--card-padding);
border: 1px solid #d1d5db;
}
</style>
<section id="component-preview" oai-annotation-container>
<article class="preview-card" oai-annotatable="Profile card">
Profile card
</article>
<article class="preview-card" oai-annotatable="Summary card">
Summary card
</article>
</section>Run this script after creating the preview:
const preview = document.getElementById("component-preview");
const annotation = document.oai?.annotation;
function previewSpacing(event) {
const { callback, value } = event.detail;
if (callback === "setCardPadding") {
preview.style.setProperty("--card-padding", `${value}px`);
}
}
let registration;
if (typeof annotation?.registerControls === "function") {
preview.addEventListener("oaiannotationcontrolchange", previewSpacing);
registration = annotation.registerControls({
targets: preview.querySelectorAll(".preview-card"),
controlsHeading: "Card spacing",
controlsMode: "replace",
controls: [
{
type: "range",
label: "Card padding (pixels)",
callback: "setCardPadding",
reference: "--card-padding",
min: 8,
max: 32,
step: 4,
currentValue: 16,
},
],
});
}
function disposeAnnotationControls() {
preview.style.removeProperty("--card-padding");
registration?.dispose();
preview.removeEventListener("oaiannotationcontrolchange", previewSpacing);
}Annotate either card and change Card padding (pixels) from 16 to 24. On
hosted sites in ChatGPT, the controls appear automatically; in Codex or on
localhost, select Adjust if needed. Both cards update. The annotation records the label, reference,
and old and new values. Clearing the preview restores the original padding.
Call disposeAnnotationControls() when removing the component.
Collect a choice without a preview
Use the contact row from the metadata example to offer an email tone:
const contact = document.getElementById("contact-row");
const registration = document.oai?.annotation?.registerControls?.({
targets: contact,
controlsHeading: "Email options",
controlsMode: "replace",
controls: [
{
type: "select",
label: "Email tone",
callback: "emailTone",
options: [
{ label: "Professional", value: "professional" },
{ label: "Friendly", value: "friendly" },
{ label: "Direct", value: "direct" },
],
defaultValue: "professional",
},
],
});This control doesn’t need an event handler because it doesn’t preview a page
change. Omitting currentValue tells the browser to include the selected tone
even if the user keeps the initial option. Call registration?.dispose() when
removing the row.
For select controls, preview callbacks receive option.value, such as
"professional". Annotation history and ChatGPT receive the visible
option.label, such as "Professional", for both the previous and selected
options. Use labels that explain each choice; internal IDs in value aren’t
sent as the choice’s text.
Configure controls and starting values
Use controlsMode: "replace" to show only your controls for the registered
targets, or "extend" to show them alongside built-in controls. An optional
controlsHeading names the panel. The browser trims it and accepts one to 80
characters. Without a heading, the panel shows the element’s HTML tag. The
heading isn’t included in the context sent to ChatGPT.
A registration supports up to 12 controls. Each requires a type, visible
label, and callback identifier:
| Type | Value | Additional fields |
|---|---|---|
color |
Hex color, such as "#2563eb" |
None |
range |
Number | min, max, and step |
select |
String | options, an array of { label, value } objects |
toggle |
boolean | None |
callback is a string identifier, not a JavaScript function. Make it unique
within the registration, start it with an ASCII letter, and use ASCII letters,
digits, underscores, or hyphens. An optional reference identifies the property
being changed and accompanies the label and value in the annotation.
Set currentValue to the property’s valid ordinary value, including unsaved
edits. Don’t use preview state or unfinished input as the baseline. The browser
uses it for before-and-after changes and resets. When application state changes, use
registration.update({ controls }) to refresh the controls’ currentValue
values. Updates affect future annotations; existing annotations retain their
captured values.
Set defaultValue for a suggested starting value; it takes precedence over
currentValue for the initial control
state. If you omit both, the control starts with white for a color, the minimum
for a range, the first option for a select, or false for a toggle.
Keep proposed values separate from ordinary drafts. If an update throws or a
request fails or returns accepted: false, discard the attempted proposal and
restore the prior controls and proposal state. Retain proposals when a request
is accepted: the browser may queue it before capturing the controls.
Validate controls and registrations
The browser validates registrations and updates against these limits:
| Field or resource | Constraint |
|---|---|
| Controls | Up to 12 per registration, with unique callback identifiers. Use only the fields defined for the control type. |
| Labels and headings | Control labels, option labels, and controlsHeading must be nonempty after trimming and at most 80 UTF-16 code units. Control text can’t contain control characters or direction-changing characters. |
| Identifiers | callback is at most 80 UTF-16 code units after trimming, using the format described above. reference is 1–80 characters and allows ASCII letters, digits, and _ . / : @ $ # -, without spaces. |
| Select options | 1–12 options with distinct value strings of at most 512 UTF-16 code units. Supplied currentValue and defaultValue must match an option’s value. |
| Range values | min, max, currentValue, and defaultValue must be finite numbers between −10,000 and 10,000. Require min < max, a step from 0.001 to 10,000 that is no larger than max - min, and starting values within the range. Starting values don’t have to align with step. |
| Colors and toggles | Colors must use 3, 4, 6, or 8 hexadecimal digits after #. Toggle values must be true or false. |
| Targets | 1–128 target entries when registering, all elements in the current document. Updates can pass targets: [] to detach. Each document supports up to 64 controls registrations and 1,024 registration-to-target associations. |
| Serialized controls | The JSON payload containing controls, controlsHeading, and controlsMode must fit within 16,384 UTF-16 code units. Use data that can be encoded as JSON, without functions, symbols, or big integers. |
registerControls() and registration.update() can throw synchronously for
invalid definitions, targets, or exceeded limits. An update after dispose()
also throws. A rejected update preserves the previous registration, including
its controls and targets. Handle failures at the call site, keep ordinary
editing usable, and reuse or dispose of owned registrations to stay within the
limits.
Handle previews and resets
The oaiannotationcontrolchange event bubbles from the selected element. Its
detail contains callback, value, and action:
| Action | Apply the supplied value to |
|---|---|
preview |
Show the requested change. |
preview-original |
Temporarily show the original state for comparison. |
reset |
Restore the original state when the preview is cleared. |
Apply the supplied value for every action, as the spacing example does. Make handlers reversible and safe to call repeatedly. Preview events don’t request a permanent change; save through your application’s normal save flow. Keep ordinary drafts, unfinished input, and previews separate so unrelated edits don’t overwrite a preview. When someone edits the same setting, replace its preview and update the baseline for future annotations.
Keep the registration handle for updates and cleanup. update() accepts any
combination of targets, controls, controlsHeading, and controlsMode.
Omitted fields keep their previous values. For example, use
registration.update({ targets: newElement }) when replacing a component’s DOM
element. Target collections capture existing elements and don’t track future
selector matches.
Pass targets: [] to detach the registration or controls: [] to clear its
controls. Call dispose() and remove event listeners when removing the
integration. Cleanup must also restore ordinary rendering: dispose() only
removes the controls registration and doesn’t undo your preview changes. The spacing example
removes its inline override to restore the original CSS value. Both methods
return synchronously without a value. Updates affect
future selections; saved annotations retain their captured controls and target.
Control events target the element captured by the annotation. Updating a registration’s targets does not redirect existing annotations. Reset events can still fire on a removed element, so a listener on its former parent will not receive them.
Make canvas objects selectable
An annotation surface lets your application identify individual objects drawn
inside a canvas. Register the host element with registerSurface() and provide
a hitTest function that returns an object with a stable id, or null for
empty space.
The host must be a connected HTML element outside shadow DOM in a secure, top-level document. Surface registration isn’t supported inside an iframe.
This example draws a revenue bar and makes it selectable. Put the script after the canvas:
<canvas id="revenue-canvas" width="480" height="240">
Revenue this quarter: $120,000.
</canvas>const canvas = document.getElementById("revenue-canvas");
const context = canvas.getContext("2d");
const bar = { x: 40, y: 60, width: 320, height: 100 };
function drawRevenue(highlighted = false) {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#2563eb";
context.fillRect(bar.x, bar.y, bar.width, bar.height);
if (highlighted) {
context.strokeStyle = "#111827";
context.lineWidth = 3;
context.strokeRect(bar.x, bar.y, bar.width, bar.height);
}
}
drawRevenue();
const surface = document.oai?.annotation?.registerSurface?.({
element: canvas,
hitTest({ clientX, clientY }) {
const bounds = canvas.getBoundingClientRect();
const scaleX = bounds.width / canvas.width;
const scaleY = bounds.height / canvas.height;
const rect = {
x: bounds.left + bar.x * scaleX,
y: bounds.top + bar.y * scaleY,
width: bar.width * scaleX,
height: bar.height * scaleY,
};
if (
clientX < rect.x ||
clientX > rect.x + rect.width ||
clientY < rect.y ||
clientY > rect.y + rect.height
) {
return null;
}
return {
id: "revenue-this-quarter",
name: "Revenue this quarter",
role: "chart-bar",
metadata: { Metric: "Revenue", Value: 120000 },
rect,
};
},
renderSelection({ hoveredId, selectedId }) {
drawRevenue(
hoveredId === "revenue-this-quarter" ||
selectedId === "revenue-this-quarter"
);
},
});Hovering over the bar in Annotation mode highlights it. Selecting it opens an annotation with the object’s name, metadata, and a screenshot of the selection.
Keep IDs stable within a surface. The optional name is visible to the user;
role gives a short semantic description. The optional rect uses CSS pixels
relative to the visible page area, matching clientX and clientY. Convert from scene coordinates,
including scale, pan, and zoom.
hitTest can return a promise and receives an AbortSignal as signal to
cancel superseded work. The browser allows 250 milliseconds before falling
back to DOM selection. Errors and invalid results also fall back. Return
null explicitly for a successful hit test with no object.
Canceled work must still resolve or reject its promise. The browser permits
only one hitTest callback in flight and holds that slot until the promise
settles, even after cancellation or a timeout. If a worker handles picking,
settle the pending promise when its work is aborted; dropping a canceled worker
response can block subsequent canvas picking.
Use the optional renderSelection callback for application-specific feedback.
Clear feedback when both IDs are null. Call surface?.invalidate() after
moving objects or changing zoom, and surface?.dispose() when removing the
integration. Both return synchronously without a value.
Add controls to canvas objects
Register custom controls on the surface’s DOM element. Drawn objects, called virtual objects in the API, use your custom controls; built-in CSS and text controls don’t apply to them.
For multiple objects, use renderSelection to update the host’s controls when
selectedId changes, before the browser captures the annotation. Control
events include detail.virtualTarget: { surfaceId, targetId }. Route each event
using that captured identity and its callback, rather than the current
selection. targetId matches the id from hitTest; surfaceId identifies the
browser’s surface registration. Ordinary DOM events omit virtualTarget.
Preview, comparison, and reset events retain the original object’s identity after another object is selected. Reopening a saved annotation doesn’t rerun hit testing to replace its captured object or metadata.
Control Annotation mode
Use toggle() to request a mode change, isActive() to read confirmed state,
and the document’s oaiannotationmodechange event to keep your UI in sync.
Feature-detect both methods for older or unsupported browsers. Add this button,
then run the script after it exists:
<button id="toggle-annotations" type="button" hidden>
Enter annotation mode
</button>const button = document.getElementById("toggle-annotations");
const annotation = document.oai?.annotation;
function renderMode(active) {
button.textContent = active
? "Exit annotation mode"
: "Enter annotation mode";
}
const onModeChange = (event) => renderMode(event.detail.active);
const onClick = () => annotation.toggle(!annotation.isActive());
if (
typeof annotation?.toggle === "function" &&
typeof annotation?.isActive === "function"
) {
document.addEventListener("oaiannotationmodechange", onModeChange);
button.addEventListener("click", onClick);
renderMode(annotation.isActive());
button.hidden = false;
}
function cleanupAnnotationButton() {
document.removeEventListener("oaiannotationmodechange", onModeChange);
button.removeEventListener("click", onClick);
button.hidden = true;
}toggle() inverts the mode. Pass true to ensure it’s on or false to ensure
it’s off. Repeated requests with the same boolean are idempotent. Passing
true preserves an active editor or pending activation; false cancels
pending activation and uses the normal exit flow.
Call toggle() from a live user interaction. Its synchronous { accepted }
result acknowledges the request, not a confirmed mode change. Browser
eligibility checks can still prevent the change. Read state from isActive()
and the event, as the example does.
isActive() needs no user gesture. The browser updates it before dispatching
oaiannotationmodechange, whose event.detail.active is a boolean. The browser
doesn’t send an initial event or a duplicate for a forced no-op, so initialize your UI
from the getter. If access is revoked while active, a final event reports
active: false and a retained getter returns false.
Annotation mode captures page clicks. Hold Space to use page controls,
including your exit button, or exit through the browser’s annotation UI.
Holding Space alone leaves isActive() true. The API doesn’t support an
oai-annotation-ignore or other attribute that lets clicks pass through.
Exiting closes the editor and preserves saved annotations without submitting
them or moving them into the composer. Call cleanupAnnotationButton() when
removing the component. The captured API reference lets cleanup remove
listeners even if the namespace has disappeared.
Test your integration
Open your website in the desktop app’s built-in browser and test the features you added:
- Enter Annotation mode and select objects and text. Check that highlights, names, ranges, and metadata match the intended targets.
- Open an annotation from your site’s button. Check the selected element or text range, initial comment, and editor mode.
- Change a custom control, compare with the original, and clear the preview. Check that your application restores the original state.
- For canvas content, test empty space, resizing, and scene changes. Switch objects and confirm control events still update the captured target. Cancel an asynchronous hit test and confirm that later picks still work.
- Save, reopen, and edit an annotation from the composer’s attachment preview. Check that it retains its controls and target, and that removing it clears any preview.
- Send an annotation with a message. Confirm that ChatGPT receives the
selected content, metadata, and requested values, including visible select
labels and unchanged choices on controls that omit
currentValue. - Open the site in a browser without the API and verify that normal interactions still work.
To expose actions ChatGPT can take on your website, add Site tools (WebMCP). Annotations bring the person’s selection and feedback into the conversation; site tools let the agent act on that context through your application’s existing capabilities.