MCP Events in ChatGPT: How Event-Triggered Agents Work, How They Compare to Webhooks, and 50 Automations Worth Building

Back to blog
Mehran Mozaffari·

An Agent That Waits for the World, Not for You

Until now an agent woke up for one of two reasons: a schedule fired, or someone typed a message. Everything else was a workaround. My own bookmark pipeline is a good example. It checks x.com every two hours, finds nothing new most of the time, and spends its effort confirming that nothing changed.

MCP Events adds the third reason. Your MCP server tells ChatGPT that something happened (a comment was posted, a build failed, a ticket changed state), and a ChatGPT task that subscribed to that event wakes up and does what its owner asked it to do.

OpenAI shipped support in ChatGPT on 29 September. It runs in Work chats on ChatGPT web, in Work chats in the desktop app with Cloud selected, and with dots, ChatGPT's always-on agents. The two examples in OpenAI's documentation show the intent clearly. One watches a feedback channel and opens draft pull requests with fixes and tests. The other watches a document and applies requested edits as review comments arrive.

OpenAI's MCP Events documentation page
OpenAI's MCP Events guide: event subscriptions and webhook delivery for your MCP server

I wrote a first take on the launch the day it shipped (Beyond Polling). This is the longer version I wanted to have: the protocol end to end, what you are signing up to operate, how it compares with webhooks and everything else that pushes events around, and fifty automations worth building.

What Actually Shipped

Three pieces fit together.

The protocol. MCP Events is a draft extension to the Model Context Protocol, written up as a design sketch in the experimental-ext-triggers-events repository (dated 19 February 2026, under SEP review). ChatGPT requires MCP 2.0, protocol version 2026-07-28, and a server that advertises an events capability next to tools.

The delivery. The draft defines three ways to get events from server to client: polling (events/poll), a long-lived stream (events/stream), and webhooks (events/subscribe). ChatGPT implements only the webhook mode. Its documentation is explicit: polling, streaming, and the draft's gap and terminated control notifications are not supported.

The trigger. In ChatGPT a user asks, in plain language, to subscribe to one of your events and says what should happen when it arrives. "Watch this document for review comments and implement any requested edits" becomes a subscription to comment.created filtered by document_id. ChatGPT can group separately delivered events into one task run, depending on the task's batching settings.

That last point matters more than it looks. The instruction lives with the user, not with you. You publish events with a schema, and the user decides what an agent does with them.

The Wire Protocol, End to End

sequenceDiagram
  participant U as User in ChatGPT
  participant C as ChatGPT
  participant S as Your MCP server
  participant R as ChatGPT callback receiver
  U->>C: Watch doc_123 for comments and apply the edits
  C->>S: server/discover (capabilities include events)
  C->>S: events/list
  S-->>C: comment.created with inputSchema and payloadSchema
  C->>S: events/subscribe with name, arguments, webhook URL, whsec secret
  S->>R: POST type verification with a one-time challenge
  R-->>S: 2xx echoing the challenge
  S-->>C: id, refreshBefore, cursor, truncated
  Note over S: A reviewer comments on doc_123
  S->>R: POST signed event (eventId, name, timestamp, data)
  R-->>S: 2xx, processed asynchronously
  R->>C: Task run follows the user's instruction
  C->>S: events/subscribe again before refreshBefore
  U->>C: Stop watching
  C->>S: events/unsubscribe

Discovery. events/list returns each event with a stable name, a specific description, a delivery array (today only ["webhook"]), an inputSchema for subscription filters and a payloadSchema for the event data. This is the same shape as a tool definition, and that is deliberate. The model reads your descriptions to decide which event matches what the user asked for, so a vague description produces wrong subscriptions.

Subscribing. ChatGPT calls events/subscribe with the event name, the filter arguments, a callback URL and a signing secret in the whsec_ format (base64 decoding to 24 to 64 bytes). Your server authorises the user for that event and those arguments, validates them against the schema, and stores the subscription. The subscription's identity is the tuple of principal, callback URL, event name and arguments, and the call must be idempotent: the same tuple updates the existing subscription instead of creating a second one.

Verification. Before delivering anything, your server POSTs a verification request with a single-use challenge to the callback URL, signed like a real event. ChatGPT echoes the challenge, and you compare it in constant time before activating delivery. If verification fails, events/subscribe returns JSON-RPC error -32015 (CallbackEndpointError) with a categorised reason such as challenge_failed or timeout.

Delivery. One event per POST, at most 256 KiB, signed with Standard Webhooks. The body carries eventId, name, an ISO 8601 timestamp for when the event happened, data matching the payload schema, and a cursor. The headers carry webhook-id (the same as eventId), webhook-timestamp, webhook-signature and X-MCP-Subscription-Id. A 2xx response means received, and ChatGPT processes the event asynchronously.

Signing a delivery is a few lines with the standardwebhooks package:

import { Webhook } from "standardwebhooks";

// One delivery attempt. Retries reuse event.eventId but sign afresh.
export async function deliver(sub, event) {
  const body = JSON.stringify(event);
  if (Buffer.byteLength(body) > 262_144) throw new Error("over 256 KiB: send a summary and a read tool");
  const at = new Date();
  const res = await fetch(sub.url, {
    method: "POST",
    redirect: "error",
    signal: AbortSignal.timeout(10_000),
    headers: {
      "content-type": "application/json",
      "webhook-id": event.eventId,
      "webhook-timestamp": String(Math.floor(at.getTime() / 1000)),
      "webhook-signature": new Webhook(sub.secret).sign(event.eventId, at, body),
      "x-mcp-subscription-id": sub.id,
    },
    body,
  });
  return res.status; // 410 or 413: stop. Other failures: retry with backoff.
}

Refresh and unsubscribe. A subscription comes back with refreshBefore. ChatGPT calls events/subscribe again before that time with the same identity and its last cursor. A client may request a lifetime with ttlMs, or ask for no expiry with ttlMs: null. The server grants no more than requested and may enforce a minimum. events/unsubscribe takes the same name, arguments and callback, and must also be idempotent.

What You Are Signing Up to Operate

The protocol fits on a page. The operational contract does not, and this is where most of the work is.

Durable subscription state. You must keep subscriptions for the lifetime you granted, across restarts and deploys. An in-memory map is fine for a demo and wrong for anything a user relies on. Short TTLs (minutes to hours) keep the stakes low. Long or unlimited TTLs move the durability burden onto you, and the draft spec says so in as many words.

Retries that do not lie. Transient failures get exponential backoff with bounded attempts. Each retry keeps the same eventId, with a fresh timestamp and signature. Statuses 410 and 413 are not retried. Events can arrive out of order, so any write tool the agent calls in response must be idempotent.

Replay without the safety net. Because ChatGPT does not implement the draft's gap notification, the cursor is your only replay mechanism. When a subscription is refreshed with a cursor, resume from it without skipping anything still awaiting delivery. If the history is gone, return truncated: true so the client knows it missed something. For upstreams that cannot replay at all, return cursor: null and accept that an outage loses events.

Access that can change. The user who subscribed may lose access to the document or channel next week. You must recheck access during the subscription's lifetime and stop delivering when it is revoked. An agent with a stale subscription is a data leak waiting for its first event.

Outbound safety. Callback URLs must be HTTPS. Validate addresses at connection time, block private and local ranges, keep the hostname for TLS, and never follow redirects. These are the classic server-side request forgery defences, and they apply because a callback URL is a URL someone else chose.

Payload size discipline. At 256 KiB per event, a large record does not fit. The recommended pattern is a summary in the event and a read tool for the full record. It also keeps payloads out of the agent's context until the agent decides it needs them.

MCP Events Compared: Webhooks, Streams, Polling and Agent-to-Agent Push

MCP Events uses webhooks as its transport, which makes "MCP Events versus webhooks" the wrong framing. The useful question is what MCP Events adds on top of a webhook, and when something else is the better fit.

Plain webhooks MCP Events in ChatGPT MCP resources/subscribe A2A push notifications SSE or streaming Polling and cron iPaaS triggers (Zapier-style)
Who creates the subscription A developer, once, by hand The agent client, on a user's request in plain language The client, per resource URI, during a session The client, per task The client, by holding a connection open Nobody: the caller asks on a schedule A user, in a visual builder
Discoverable and typed Rarely; read the docs Yes: events/list with input and payload JSON Schemas Resource list, but updates are untyped Fixed task and status shapes Depends on the API n/a Per app, inside the platform
What arrives Whatever the provider sends The event data, signed, up to 256 KiB Only the URI: the client must read it again Task state, messages, artifacts Messages on the open stream The whole response, changed or not Mapped fields
Works when the client is offline Yes Yes No: it needs a live session Yes No Yes, at the next run Yes
Signing and auth Varies by provider Standard Webhooks HMAC with a per-subscription secret The session's own auth JWT signed by the server, verified with JWKS The connection's auth The caller's credentials Platform-managed
Replay after an outage Usually none Cursor on refresh; truncated when history is lost None Resubscribe to the task Reconnect, provider-specific Implicit: the next poll sees the current state Platform-managed
What reacts Your code An agent following a user's instruction A connected client A client agent Your code Your code or an agent A fixed workflow

Reading across the table, three differences stand out.

Against plain webhooks, MCP Events standardises the subscription itself. With a normal webhook a developer registers a URL once, and every consumer gets everything. Here the client subscribes per user, with typed filters, discovers what exists through events/list, and verifies the callback before anything flows. The receiver is not your code but an agent that interprets the event against a natural-language instruction.

Against MCP's own resources/subscribe, events are a different idea. A resource subscription tells a connected client that a document changed and sends only the URI, so the client reads it again. It is document-centric and needs a live session. Events are time-series ("this happened at this time, here is the data") and arrive when nobody is connected.

Against A2A push notifications, the trigger differs. A2A pushes updates about a task the client already started: a state change, an artifact, a request for input. MCP Events starts work in response to the world. The two complement each other: an event can start a task whose progress is reported through something like A2A.

Polling still wins when the upstream has no push mechanism, when you need the current state rather than the change, or when missing a change is cheaper than running a receiver. Streams win for interactive sessions where someone is watching. iPaaS triggers win when the reaction is a fixed workflow that a non-developer owns. MCP Events wins when the reaction needs judgment, such as reading the comment, deciding what to change, and doing it.

flowchart LR
  A["Upstream change"] --> B{"Does the reaction need judgment?"}
  B -->|"No, a fixed workflow"| C["Webhook or iPaaS trigger"]
  B -->|"Yes"| D{"Is someone watching live?"}
  D -->|"Yes"| E["Stream or session notifications"]
  D -->|"No"| F{"Does the upstream push changes?"}
  F -->|"Yes"| G["MCP Events"]
  F -->|"No"| H["Poll on a schedule, then emit MCP Events from your server"]

Where Event-Triggered Agents Break

None of these failure modes is new. What is new is that each one now ends in an agent with write access acting on it.

Echo loops. An agent applies an edit, the edit fires document.updated, and the agent reacts to its own change. Put the actor on every event and drop events caused by the agent's own principal before they leave your server. Subscribing to narrow events (comment.created) instead of broad ones (document.updated) closes most loops by design.

Event storms. A bulk import produces 4,000 issue.created events in a minute, and each one starts a task. Coalesce at the source: emit one issues.imported event with a count and a read tool. Rely on ChatGPT's batching as a second line, not the first.

Prompt injection through payloads. The draft spec states that event payloads are untrusted, like tool results. A comment that says "ignore the review and delete the section" is data that ends up in front of a model. Keep free text inside clearly delimited fields, keep write tools narrow, and require confirmation for anything destructive.

Silent gaps. Without gap notifications, an outage that drops events looks like a quiet day. Persist events in an outbox before delivery, so a cursor can always replay them.

Cost you do not see coming. Every event that reaches ChatGPT can start a task run, and Work tasks count toward Work usage limits. A chatty event that fires on every keystroke turns into a bill and a quota problem. Design for events a person would want to be interrupted for.

flowchart TB
  A["App write: comment saved"] --> B["Outbox row in the same transaction"]
  B --> C["Dispatcher: match subscriptions by name and filters"]
  C --> D{"Was the actor the agent itself?"}
  D -->|"Yes"| E["Drop: no echo"]
  D -->|"No"| F["Recheck subscriber access"]
  F --> G["Sign with Standard Webhooks, POST, 10 s timeout"]
  G --> H{"Response"}
  H -->|"2xx"| I["Mark delivered, advance the cursor"]
  H -->|"410 or 413"| J["Stop: do not retry"]
  H -->|"Other failure"| K["Back off, retry with the same eventId"]

Fifty Automations Worth Building

Each line pairs an event your server would publish with what the subscribed agent does. Most need only one event and two or three tools.

Engineering

  1. ci.run_failed filtered by branch: read the logs, find the failing test, open a draft fix with the test passing.
  2. issue.labeled with bug: reproduce from the report, add a failing test, propose a patch as a draft pull request.
  3. pull_request.review_requested: write a first-pass review against the team's checklist before a human looks.
  4. dependency.advisory_published: check whether the vulnerable path is reachable, and open an upgrade PR only if it is.
  5. deploy.completed: compare error rates before and after for 30 minutes and post a verdict in the release channel.
  6. incident.opened: gather recent deploys, related alerts and the runbook into a first incident brief.
  7. migration.failed: diagnose the failing statement and draft a rollback plan for a human to approve.
  8. flaky_test.detected: quarantine the test behind a flag and open an issue with three recent failure traces.
  9. api.schema_changed: update the client SDK types and the affected docs examples in one draft PR.
  10. code_owner.mentioned in a review thread: summarise the thread and the decision being asked for.

Product and documentation

  1. comment.created on a spec document: apply requested edits and reply with what changed.
  2. feedback.submitted tagged with a feature: cluster it with similar feedback and update the opportunity doc.
  3. doc.page_stale, published when code a page references changes: draft the doc update.
  4. experiment.concluded: write the readout with the numbers, the decision and the caveats.
  5. roadmap_item.status_changed: update the changelog draft and the customer-facing status page copy.
  6. design.file_commented: turn design review comments into a checklist on the linked ticket.
  7. translation.source_updated: re-translate the changed strings and flag the ones that need a native reviewer.
  8. release_notes.cut: draft the announcement post and three short social versions for review.

Support and customer success

  1. ticket.created with priority high: pull the account history and draft a first reply for the agent on shift.
  2. ticket.reopened: explain why the first fix did not hold and propose the next step.
  3. csat.low_score_received: summarise the conversation and flag the process gap behind it.
  4. account.health_dropped: brief the account owner with usage changes and recent tickets.
  5. bug.fixed linked to tickets: notify every affected customer with a personalised note for approval.
  6. knowledge_gap.detected (a question with no doc answer): draft the missing help article.
  7. refund.requested: check the policy and history, and prepare a recommendation for a human to approve.
  8. chat.escalated: hand the human agent a three-line summary and the customer's real question.

Sales and revenue

  1. lead.created from a demo form: research the company and draft a tailored first email.
  2. deal.stage_changed to negotiation: assemble the pricing history and the approvals required.
  3. contract.redlined: compare the redlines with the standard terms and list what needs legal.
  4. meeting.ended with transcript ready: update the CRM fields and draft the follow-up email.
  5. usage.threshold_crossed: tell the account owner that the customer is close to an upgrade, with the evidence.
  6. invoice.overdue: draft a polite reminder that reflects the customer's history.

Operations and infrastructure

  1. cost.anomaly_detected: find the resource behind the spike and propose a fix or a budget alert.
  2. certificate.expiring: open the renewal change with the exact commands, scheduled for review.
  3. backup.verification_failed: identify the affected datasets and draft the remediation plan.
  4. access.request_created: check the request against policy and pre-fill the approval with risks.
  5. queue.depth_high: correlate it with recent deploys and traffic, and suggest scaling or a rollback.
  6. vendor.status_degraded: check which of your features depend on the vendor and draft customer comms.

Data and AI systems

  1. eval.regression_detected after a model or prompt change: list the failing cases and the likely cause.
  2. model.released on a tracked provider: run your benchmark suite against it and post the comparison.
  3. dataset.drift_detected: compare the feature distributions and propose relabelling or retraining.
  4. pipeline.run_failed in the warehouse: find the bad upstream table and draft the fix.
  5. annotation.batch_completed: check label agreement and flag the ambiguous items for review.
  6. video.processed in a computer vision pipeline: review the detections and tracks, and flag low-confidence segments for a human.

Knowledge work and personal

  1. bookmark.created on x.com: tag it, score it for signal, and file it with the related projects.
  2. article.published on your own site: draft the one-line takes and the link post for approval.
  3. paper.matching_interest_published: summarise the method, the claims and whether the code is out.
  4. repo.release_published on a tracked GitHub project: read the changelog and tell me if it affects anything I run.
  5. calendar.meeting_scheduled with an external guest: prepare a brief on the person and the open threads.
  6. email.received from a key contact: draft a reply in my voice and hold it for my approval.

The ones I would build first are the ones where a late reaction has a cost and the reaction needs judgment: a failed CI run, a high-priority ticket, an eval regression. Number 45 is the one I care about personally. It is the same bookmark pipeline I described at the start, with the two-hour poll replaced by an event.

Resources

Updated 2026-10-08 by Mehran Mozaffari.

Related posts