Integrate a ChatGPT widget for email authentication auditing with CaptainDNS

By CaptainDNS
Published on December 7, 2025

  • #MCP
  • #ChatGPT
  • #Apps SDK
  • #DNS
  • #Email
  • #Architecture
Diagram showing ChatGPT calling the CaptainDNS MCP server and rendering an email authentication audit widget
TL;DR
  • Goal: display a visual SPF/DKIM/DMARC/BIMI audit component inside ChatGPT, powered by the CaptainDNS MCP server.
  • In practice you need to align three things: the MCP tool descriptor, the tool response (structuredContent + _meta), and an HTML text/html+skybridge resource.
  • Main hurdles: deciding where to place openai/outputTemplate, fixing the resources/read shape, and handling the timing of window.openai.toolOutput inside the widget.
  • Outcome: an email_auth_audit widget that renders the score, summary, and MX/SPF/DKIM/DMARC/BIMI checks directly in the ChatGPT conversation—without losing the assistant's reasoning.

1. Context: why a ChatGPT widget for CaptainDNS?

CaptainDNS already provides an email authentication audit API: we compute a readiness-to-send score based on MX, SPF, DKIM, DMARC, and BIMI for a given domain.

With the CaptainDNS MCP server we could already trigger this audit from MCP Inspector and then from a ChatGPT App. From a UX standpoint, something was missing:

  • the user saw a text block,
  • but no synthetic view: score, status per mechanism, summary.

The new OpenAI Apps SDK UI lets you attach an HTML template to a tool to render a component in the conversation. The idea was simple:

When ChatGPT calls email_auth_audit, display a CaptainDNS widget that summarizes the audit, then let the assistant detail the DNS fixes.

Reality was a bit subtler.

This post describes what ChatGPT actually expects, the pitfalls we ran into, and how we ended up with the following render:

(see the "Result: the widget in action" section for the screenshot, to include as an image).

2. Overall architecture: backend, MCP, Apps SDK

On the CaptainDNS side, the base architecture is:

  • Frontend: Next.js on Vercel (captaindns.com).
  • Backend API: Go on Fly.io handling the business logic (DNS audit, scoring, etc.).
  • MCP server: Go on Fly.io as well, speaking JSON-RPC with MCP Inspector / ChatGPT and delegating to the backend.

For the widget we add a new actor: the ChatGPT App consuming the CaptainDNS MCP and able to render an HTML text/html+skybridge resource.

The simplified flow for email_auth_audit:

  1. The user asks ChatGPT: "Can you audit the email setup for captaindns.com?"
  2. The model decides to call the MCP tool email_auth_audit.
  3. The MCP server calls the backend API, receives the audit JSON, and re-maps it into structuredContent + _meta.
  4. ChatGPT renders:
    • a text block based on content + structuredContent,
    • a widget by loading ui://widget/email-auth-widget.html referenced by openai/outputTemplate.

You can visualize this flow with a diagram like:

MCP + Apps SDK flow for the email_auth_audit widget

3. What ChatGPT really expects from a “widget” MCP tool

The first challenge was to understand where to declare what. On the Apps SDK side, there are three distinct layers:

  1. Tool descriptor (tools/list)
  • This is where you declare the OpenAI metadata:
    • _meta["openai/outputTemplate"] = "ui://widget/email-auth-widget.html",
    • _meta["openai/toolInvocation/invoking"] / invoked (the small messages above the widget).
  • You can keep annotations in parallel for your own needs (tags, OAuth2 scopes, etc.).
  1. HTML resource (resources/list + resources/read)
  • URI: ui://widget/email-auth-widget.html.
  • mimeType: strictly text/html+skybridge.
  • The content is a standard HTML file with a <script type="module"> reading window.openai.
  1. Tool response (tools/call)
  • structuredContent: the raw data the model will use to reason.
  • _meta: the metadata specific to the result, visible from the widget.
  • content: a textual fallback for the conversation.

For the email_auth_audit widget we adopted the following convention:

  • structuredContent = full audit JSON (domain, score, checks, breakdown, notes...).
  • _meta.emailAuthUi = the "UI-friendly" version: score, summary, rating (good/ok/bad), normalized checks.
  • _meta["openai/outputTemplate"] + openai/toolInvocation/* = present both in:
    • the tool descriptor,
    • and the tool response (for safety—some Apps SDK guides recommend both).

This separation lets:

  • the model read the data without caring about the UI,
  • the widget retrieve a payload already "ready to render."

4. Wiring the email_auth_audit tool on the MCP side

On the MCP side, the email_auth_audit handler does three things:

  1. Call the backend with the domain and retrieve an audit JSON such as:
{
  "domain": "captaindns.com",
  "score": 57,
  "max_score": 100,
  "percentage": 57,
  "authentication_summary": "Partial authentication: tighten SPF/DKIM/DMARC.",
  "checks": [ ... ],
  "score_breakdown": { ... },
  "findings": { ... },
  "notes": [ ... ],
  "ui_meta": { ... }
}
  1. Remap into structuredContent

Here we mostly reuse the JSON as-is:

"structuredContent": {
  "domain": "captaindns.com",
  "ready_to_send": true,
  "degraded": true,
  "grade": "medium",
  "score": 57,
  "max_score": 100,
  "percentage": 57,
  "authentication_score": 57,
  "authentication_summary": "Partial authentication: tighten SPF/DKIM/DMARC.",
  "checks": [ ... ],
  "score_breakdown": { ... },
  "findings": { ... },
  "notes": [ ... ]
}
  1. Build _meta + content
"_meta": {
  "emailAuthUi": {
    "score": 57,
    "summary": "Partial authentication: tighten SPF/DKIM/DMARC.",
    "rating": "bad",
    "checks": [ ... ]
  },
  "openai/outputTemplate": "ui://widget/email-auth-widget.html",
  "openai/toolInvocation/invoking": "Running the email authentication audit...",
  "openai/toolInvocation/invoked": "Email authentication audit completed."
},
"content": [
  {
    "type": "text",
    "text": "Authentication score: 57/100 — Partial authentication: tighten SPF/DKIM/DMARC."
  }
]

The "reasoning" part of ChatGPT relies on structuredContent and content. The widget leverages structuredContent and _meta.emailAuthUi.

5. Exposing the HTML template as an MCP resource

Next we had to declare the template on the MCP side.

5.1. resources/list: declare the resources

The MCP exposes three resources for this widget:

  • ui://widget/email-auth-widget.html (HTML + CSS + JS),
  • ui://widget/apps-sdk-bridge.js (optional Apps SDK bridge),
  • other assets if needed.

resources/list returns an array of descriptors with uri, name, description, mimeType.

5.2. resources/read: the invalid_union trap

The first version of resources/read returned JSON shaped like:

{
  "contents": [
    {
      "uri": { "value": "ui://widget/email-auth-widget.html" }, // wrong
      "mimeType": "text/html+skybridge",
      "text": "<!doctype html>..."
    }
  ]
}

Result in MCP Inspector: a nice invalid_union error on contents[0].uri. The MCP client expects exactly:

{
  "contents": [
    {
      "uri": "ui://widget/email-auth-widget.html",
      "mimeType": "text/html+skybridge",
      "text": "<!doctype html>..."
    }
  ]
}

Once the types were fixed (uri and text as strings), MCP Inspector could display the HTML and ChatGPT could load the template without complaining.

6. Inside the widget: understanding window.openai

Last but not least: implementing the widget itself.

6.1. What ChatGPT provides to the template

In an Apps SDK template, ChatGPT injects a global window.openai object whose fields evolve over time:

  • on load, window.openai.toolOutput can be null or empty;
  • when the tool is executed, ChatGPT emits an openai:set_globals event and updates:
    • globals.toolOutput,
    • globals.toolResponseMetadata.

Initially our script assumed that window.openai.toolOutput immediately contained an object of type structuredContent. That was false, hence a render with:

  • Unknown domain,
  • Score unavailable,
  • No checks available.

6.2. Normalizing the shape of toolOutput

We started by writing a helper that accepts several possible shapes:

function extractStructured(toolOutputSource) {
  if (!toolOutputSource) return {};
  // Case 1: direct structuredContent
  if (toolOutputSource.domain || toolOutputSource.score || toolOutputSource.checks) {
    return toolOutputSource;
  }
  // Case 2: { structuredContent: {...} }
  if (toolOutputSource.structuredContent) {
    return toolOutputSource.structuredContent;
  }
  // Case 3: { result: { structuredContent: {...} } }
  if (toolOutputSource.result?.structuredContent) {
    return toolOutputSource.result.structuredContent;
  }
  return toolOutputSource;
}

Then, in normalize, instead of doing:

const structured =
  toolOutputSource?.structuredContent ?? toolOutputSource ?? {};

we do:

const structured = extractStructured(toolOutputSource);

6.3. Handling timing with openai:set_globals

Then we wired the widget to the openai:set_globals event:

function renderFromContext() {
  const ctx = window.openai || {};
  const toolOutput = ctx.toolOutput || ctx.toolInvocationResult || {};
  const meta = ctx.toolResponseMetadata || {};
  const data = normalize(toolOutput, meta);
  render(data);
}

// Optimistic first render
renderFromContext();

// Reactive render when ChatGPT pushes tool results
window.addEventListener(
  "openai:set_globals",
  (event) => {
    const globals = event.detail?.globals || {};
    const toolOutput = globals.toolOutput ?? window.openai?.toolOutput ?? {};
    const meta = globals.toolResponseMetadata ?? window.openai?.toolResponseMetadata ?? {};
    const data = normalize(toolOutput, meta);
    render(data);
  },
  { passive: true }
);

With these two points in place, the widget started to display:

  • captaindns.com at the top left,
  • 57/100 (57%) at the top right,
  • the summary, and the MX/SPF/DKIM/DMARC/BIMI rows with their statuses.

7. Result: the widget in action

The final view looks like a CaptainDNS card rendered directly in the conversation:

  • Title: captaindns.com.
  • Score: 57/100 (57%), with a status color (bad in our case).
  • Summary: "Partial authentication: tighten SPF/DKIM/DMARC."
  • Table:
    • MX: OK – 1 smtp.google.com.
    • SPF: OK – v=spf1 include:_spf.google.com -all
    • DKIM: Info – "No record found"
    • DMARC: OK – v=DMARC1; p=reject; rua=mailto:dmarc@captaindns.com;
    • BIMI: Warning – "No record found"

Screenshot of the email_auth_audit widget in ChatGPT showing the score and MX/SPF/DKIM/DMARC/BIMI checks for captaindns.com

8. Watchpoints and checklist

To recap, here is the checklist we now use for every new Apps SDK widget connected to the CaptainDNS MCP:

  1. Tool descriptor (tools/list):
  • _meta["openai/outputTemplate"] points to a text/html+skybridge HTML resource.
  • _meta["openai/toolInvocation/invoking"] / invoked are set.
  1. HTML resource (resources/read):
  • contents[0].uri is a string (exactly the resource URI).
  • contents[0].mimeType === "text/html+skybridge".
  • contents[0].text contains valid HTML.
  1. Tool response (tools/call):
  • structuredContent holds the business data.
  • _meta contains:
    • the UI metadata (emailAuthUi here),
    • the openai/* keys if you want to duplicate the declaration.
    • content contains a readable sentence for the conversation.
  1. HTML widget:
  • reads window.openai.toolOutput / toolResponseMetadata,
  • normalizes the shape of toolOutput,
  • listens to openai:set_globals,
  • leaves no debug noise in the final render.

With these safeguards, adding new widgets (for example for authoritative DNS health or propagation) becomes much smoother.

9. What's next?

This first email_auth_audit widget serves as a reference for upcoming CaptainDNS UI components in ChatGPT:

  • a widget for authoritative DNS health (domain_dns_check),
  • a multi-resolver DNS propagation widget,
  • or even richer dashboards combining several tools.

The most important point from this REX is actually simple:

the Apps SDK render is not "magic"; it relies on a very precise contract between the tool descriptor, the tool response, and the HTML resources.

Once you master that contract, you can turn rather raw MCP tools into much more readable user experiences directly within the ChatGPT conversation, without duplicating business logic in the frontend.

Similar articles

CaptainDNS · November 27, 2025

Diagram of the CaptainDNS MCP architecture between ChatGPT, the MCP server, and the backend API

Behind the scenes of the CaptainDNS MCP

How we wired CaptainDNS to AIs through MCP: architecture, HTTP+SSE transport, JSON-RPC, 424 errors, timeouts, and what we learned along the way.

  • #MCP
  • #Architecture
  • #DNS
  • #Email
  • #AI integrations

CaptainDNS · November 21, 2025

Diagram showing an AI host talking to CaptainDNS through a standardized MCP connector

An MCP for CaptainDNS?

Before plugging CaptainDNS into AIs, you need to understand what the Model Context Protocol (MCP) is and what it really enables. A short MCP ABC, then first steps for CaptainDNS.

  • #MCP
  • #AI
  • #DNS
  • #Email
  • #Architecture

CaptainDNS · December 4, 2025

Architecture diagram showing Auth0, the CaptainDNS MCP server, the backend API and MCP clients

Auth0 + MCP CaptainDNS: our full postmortem

How we connected Auth0 to our CaptainDNS MCP server: dedicated audiences, PRM, Resource Parameter Compatibility Profile, JWT validation, identity propagation into profiles and api_requests, with optional auth today and protected tools ready for later.

  • #Auth0
  • #MCP
  • #OAuth2
  • #DNS
  • #Architecture