Channel Events
Push real-time notifications into your subscribers' AI sessions using ctx.emit(). Apps can surface events proactively -- no tool call required.
Normally, your App responds to requests: a subscriber asks the AI to do something, the AI calls your tool, your tool returns a result. Channel events flip that model.
With ctx.emit(), your App can push a notification directly into a subscriber's active AI session at any time -- from inside a tool handler, from a background job, from anywhere in your server code. The subscriber sees the event appear in Claude Code without asking for it.
Use channel events to surface things that matter: a build finishing, a deploy failing, a new comment on an issue, a metric crossing a threshold.
How it works
Your App calls ctx.emit() with a message. mctx queues the event and the subscriber's thin client polls for new events. When one arrives, Claude Code displays the notification in the session.
You do not need to understand the queuing or polling mechanics. From your App's perspective, ctx.emit() is a one-line fire-and-forget call.
ctx.emit()
ctx.emit() is available on the same ctx object that carries ctx.userId. You already receive ctx as the third parameter in every tool, resource, and prompt handler.
Basic usage
const deployApp = async ({ environment }, ask, ctx) => {
await triggerDeploy(environment);
ctx.emit("Deployment started");
return `Deploying to ${environment}`;
};
deployApp.description = "Deploy the app to an environment";
deployApp.input = {
environment: T.string({ required: true, description: "Target environment" }),
};
app.tool("deploy_app", deployApp);ctx.emit() does not block the tool response. The deploy continues and your tool returns normally regardless of whether the event was queued successfully.
With an event type
Pass an eventType to categorize the event. Subscribers see the type alongside the message:
ctx.emit("PR #42 merged", { eventType: "pull_request" });With metadata
Attach structured data to an event using meta. Metadata is available to the subscriber's AI client for richer display and filtering:
ctx.emit("Deploy failed on staging", {
eventType: "deploy",
meta: {
environment: "staging",
status: "failed",
},
});Inside a tool handler
Here is a complete example of a tool that emits events at multiple points during a long-running operation:
const runTests = async ({ suite }, ask, ctx) => {
ctx.emit(`Test suite "${suite}" started`, { eventType: "ci" });
const results = await executeTestSuite(suite);
if (results.passed) {
ctx.emit(`Build #${results.buildId} passed -- ${results.count} tests`, {
eventType: "ci",
meta: {
build_id: String(results.buildId),
test_count: String(results.count),
status: "passed",
},
});
} else {
ctx.emit(`Build #${results.buildId} failed -- ${results.failures} failures`, {
eventType: "ci",
meta: {
build_id: String(results.buildId),
failure_count: String(results.failures),
status: "failed",
},
});
}
return results;
};
runTests.description = "Run a test suite and report results";
runTests.input = {
suite: T.string({ required: true, description: "Name of the test suite to run" }),
};
app.tool("run_tests", runTests);Meta key constraints
| Constraint | Limit |
|---|---|
| Key format | [a-zA-Z0-9_]+ (alphanumeric and underscores only) |
| Maximum keys per event | 10 |
display_text max length | 500 characters |
event_type max length | 100 characters |
| Total event payload | 4 KB |
Hyphens in meta keys are silently dropped by Claude Code. If you use build-id as a key, Claude Code may receive buildid or drop it entirely. Use underscores: build_id.
Writing good event text
Events appear directly in a subscriber's AI session. Keep display text concise, specific, and actionable.
Good examples:
Build #847 passedNew comment on issue #12Alert: CPU usage above 90%Deploy to production completePayment webhook received
Avoid:
- Long paragraphs or multi-sentence descriptions
- HTML or markdown formatting
- Instruction patterns (
You should now...,Please check...,The AI should...) -- the platform filters these as a secondary defense, but responsibility for sanitizing event text belongs to your App - User-generated content passed through without sanitization
If a subscriber's users can influence what ends up in ctx.emit() -- for example, through a comment body or a commit message -- sanitize or truncate that content before passing it to ctx.emit().
Works everywhere. Enhanced in Claude Code.
Channel events are an enhancement, not a requirement.
Your App's tools, resources, and prompts work with every MCP-compatible client -- Claude Desktop, Cursor, any other client that implements the protocol. When subscribers use those clients, your App works exactly as they expect.
When a subscriber uses Claude Code with the mctx thin client installed, they also get real-time event notifications. The thin client polls for events in the background and surfaces them inside the session.
Design your App so it works fully without channel events. Emit events to add value on top -- not as a substitute for returning useful results from your tools.
Rate limits
| Limit | Value |
|---|---|
| Events per minute | 60 per App |
| Events per hour | 1,000 per App |
| Maximum live events | 500 (unexpired, per App) |
Calls to ctx.emit() that exceed these limits are silently dropped. Your tool handler continues normally.
If ctx.emit() cannot reach the events service for any reason, it silently no-ops. Your tool is never blocked by an unavailable events service.
Next steps
- Scheduled Delivery -- emit events on a schedule, not just in response to tool calls
- Installing the Thin Client -- how subscribers enable real-time event notifications in Claude Code
- Framework API Reference -- every export, type, and option in
@mctx-ai/app
See something wrong? Report it or suggest an improvement
User Context (ctx.userId)
How to identify and personalize responses for each subscriber in your App using the stable ctx.userId identifier mctx provides to every handler.
Scheduled Event Delivery
Emit events that the platform holds and delivers at a specific time. Use deliverAt to defer events, key to supersede stale ones, and ctx.cancel() to remove pending events before they arrive.