Events
Emit and subscribe to real-time events with the @agentapps/sdk.
Events
Events are real-time push notifications from your app to subscribers — the Station, other apps, or any client connected to the session. They let the agent and UI react instantly to changes without polling.
How events work
Your app ──ctx.emit('event.created', payload)──► Station ──► Subscribers
│
├── Agent (wakes up, reacts)
├── UI (refreshes a panel)
└── Other apps (via inbound subscriptions)- Your app calls
ctx.emit('event_name', payload)inside a tool handler, webhook handler, or session lifecycle hook. - The Station delivers the event to all active subscribers for that event type.
- Subscribers react — the agent can process the event, a UI panel can refresh, or another app can take action.
Events are fire-and-forget — emit() returns void. There is no acknowledgement or retry. If no subscribers are listening, the event is silently dropped.
Enable events in your app
Set capabilities.events: true in your app config so the Station knows your app emits events:
const app = createApp({
id: 'app.example.calendar',
version: '1.0.0',
name: 'Calendar',
description: 'A calendar app that emits events',
author: { name: 'Example', email: 'dev@example.com' },
category: 'productivity',
runtime: { transport: 'stdio', command: 'node', args: ['./dist/index.js'] },
capabilities: {
events: true, // ← declare that this app emits events
},
});Register event types
Register the events your app can emit with app.event(). This makes them discoverable — the Station validates subscription requests against the registered list and can display them in UI.
// Register events your app will emit
app.event('event.created', {
description: 'Emitted when a new calendar event is created',
});
app.event('event.updated', {
description: 'Emitted when an existing calendar event is modified',
});
app.event('event.deleted', {
description: 'Emitted when a calendar event is deleted',
});Subscribers can only subscribe to event types you have registered (or '*' for all events).
Emit events
Call ctx.emit() from any tool handler, webhook handler, or session lifecycle hook:
app.tool('create_event', {
description: 'Create a new calendar event',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
start: { type: 'string', description: 'ISO 8601 datetime' },
end: { type: 'string', description: 'ISO 8601 datetime' },
},
required: ['title', 'start'],
},
handler: async (args, ctx) => {
const { title, start, end } = args as {
title: string;
start: string;
end?: string;
};
// ... create the event in your backend ...
const eventData = { id: 'evt_123', title, start, end };
// Emit the event — all subscribers for 'event.created' receive it
ctx.emit('event.created', eventData);
return { created: true, event: eventData };
},
});Emitting from a webhook handler
Webhook handlers also receive a ToolContext with emit:
app.webhook('/github/webhook', async (payload, ctx) => {
// GitHub sent a webhook — emit an event for subscribers
ctx.emit('repo.pushed', {
repo: payload.repository?.full_name,
branch: payload.ref,
commits: payload.commits?.length ?? 0,
});
return { received: true };
});Emitting from session lifecycle hooks
Session lifecycle hooks (onSessionOpen, onSessionClose) receive a SessionContext that includes emit:
const app = createApp({
// ... other config ...
capabilities: { events: true },
onSessionOpen: async (ctx) => {
// Emit a snapshot of current state when a session starts
if (ctx.hasSubscribers('state.snapshot')) {
const data = await fetchCurrentState();
ctx.emit('state.snapshot', data);
}
},
});Check for subscribers
Before doing expensive work (polling an API, opening a WebSocket, running a background process), check if anyone is actually listening:
app.tool('start_polling', {
description: 'Start polling an external API for changes',
parameters: { type: 'object', properties: {} },
handler: async (_args, ctx) => {
// Don't waste resources if nobody is listening
if (!ctx.hasSubscribers('data.changed')) {
return { skipped: true, reason: 'No subscribers for data.changed' };
}
// Start polling and emit events when data changes
const interval = setInterval(async () => {
const latest = await fetchLatest();
ctx.emit('data.changed', latest);
}, 5000);
// Clean up when the tool call ends
ctx.signal.addEventListener('abort', () => clearInterval(interval));
return { started: true };
},
});List active subscriptions
Use ctx.activeSubscriptions() to see all event types that currently have subscribers (including '*' if someone subscribed to all events):
const active = ctx.activeSubscriptions();
// → ['event.created', 'event.updated', '*']The onSubscribe lifecycle hook
When a subscriber attaches, the Station calls your app's onSubscribe hook. Use it to start background work only when needed:
const app = createApp({
id: 'app.example.email',
version: '1.0.0',
name: 'Email',
description: 'An email app that notifies on new messages',
author: { name: 'Example', email: 'dev@example.com' },
category: 'productivity',
runtime: { transport: 'stdio', command: 'node', args: ['./dist/index.js'] },
capabilities: { events: true },
// Called when a session subscribes to one or more event types
onSubscribe: async (ctx, eventTypes) => {
ctx.log('info', `Subscriber attached for: ${eventTypes.join(', ')}`);
// If someone subscribed to 'message.received', start polling
if (eventTypes.includes('message.received') || eventTypes.includes('*')) {
// Start checking for new emails and emit events
const latest = await checkInbox(ctx);
if (latest.length > 0) {
for (const msg of latest) {
ctx.emit('message.received', msg);
}
}
}
},
});onSubscribe receives a ToolContext bound to the subscribing session. You can use ctx.emit(), ctx.hasSubscribers(), ctx.secrets, and all other context methods inside it.
Subscribe to inbound events
Your app can also receive events from other apps (via bindings) or from the host platform. Use ctx.subscribeInbound() to listen:
Binding events (from other apps)
If your app declares a requires dependency on another app, you can subscribe to that app's events:
app.tool('watch_calendar', {
description: 'Watch for new calendar events from a bound calendar app',
parameters: { type: 'object', properties: {} },
handler: async (_args, ctx) => {
// Subscribe to events from a bound app (declared in `requires`)
const subId = await ctx.subscribeInbound(
{ kind: 'binding', binding: 'calendar' },
['event.created', 'event.updated'],
);
// Register a handler for inbound events
const unsubscribe = ctx.onInboundEvent((event) => {
ctx.log('info', `Received ${event.eventType} from ${event.source}`);
if (event.eventType === 'event.created') {
// React to the calendar event
ctx.emit('schedule.conflict', { conflict: true });
}
});
// Clean up on cancel
ctx.signal.addEventListener('abort', async () => {
unsubscribe();
await ctx.unsubscribeInbound(subId);
});
return { watching: true, subscriptionId: subId };
},
});Platform events (from the host)
Subscribe to events emitted by the host platform (e.g., board task movements, scheduler events):
const subId = await ctx.subscribeInbound(
{ kind: 'platform', namespace: 'boards' },
['task.created', 'task.moved'],
);Event payload conventions
Event payloads are plain JSON objects (Record<string, unknown>). Follow these conventions for consistency:
| Convention | Example | Why |
|---|---|---|
| Use dot-separated event names | message.received, file.uploaded | Namespaced, readable, avoids collisions |
Include an id for the entity | { id: 'msg_123', ... } | Subscribers can deduplicate and reference |
| Include timestamps | { createdAt: '2026-09-07T...' } | Subscribers know when it happened |
| Keep payloads small | { id, title, status } | Events are pushed in real time — avoid large blobs |
Real-world examples
Calendar app
app.event('event.created', {
description: 'A new calendar event was created',
});
app.event('event.reminder', {
description: 'A reminder fired for an upcoming event',
});
app.tool('create_event', {
description: 'Create a calendar event',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
start: { type: 'string' },
},
required: ['title', 'start'],
},
handler: async (args, ctx) => {
const event = { id: 'evt_001', ...args };
// ... save to backend ...
ctx.emit('event.created', event);
return { created: true };
},
});Email app
app.event('message.received', {
description: 'A new email message was received',
});
app.event('message.sent', {
description: 'An email was sent successfully',
});
// Polling loop started by onSubscribe
onSubscribe: async (ctx, eventTypes) => {
if (eventTypes.includes('message.received') || eventTypes.includes('*')) {
const messages = await checkInbox(ctx);
for (const msg of messages) {
ctx.emit('message.received', {
id: msg.id,
from: msg.from,
subject: msg.subject,
receivedAt: new Date().toISOString(),
});
}
}
},File storage app
app.event('file.uploaded', {
description: 'A file was uploaded to the volume',
});
app.event('file.deleted', {
description: 'A file was deleted from the volume',
});
app.tool('upload_file', {
description: 'Upload a file to the volume',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
content: { type: 'string', description: 'Base64-encoded file content' },
},
required: ['path', 'content'],
},
handler: async (args, ctx) => {
const { path, content } = args as { path: string; content: string };
const bytes = new Uint8Array(Buffer.from(content, 'base64'));
await ctx.volume.write(path, bytes);
const stat = await ctx.volume.stat(path);
ctx.emit('file.uploaded', {
path,
size: stat?.size,
uploadedAt: new Date().toISOString(),
});
return { uploaded: true, path, size: stat?.size };
},
});Events vs polling
| Events (push) | Polling | |
|---|---|---|
| Latency | Instant — subscribers are notified immediately | Depends on poll interval |
| Efficiency | Zero overhead when no subscribers | Constant work regardless of changes |
| Implementation | ctx.emit() + onSubscribe hook | setInterval + manual checks |
| Best for | Real-time notifications, state changes, new data | Periodic health checks, batch syncs |
Use events when:
- An external webhook arrives and you want to notify the agent
- A background process detects a change
- New data is created, updated, or deleted
- A long-running task completes
Use polling when:
- You need to check if an external service is healthy
- You're syncing data on a schedule (and real-time isn't required)
- The external system doesn't support webhooks or push notifications
You can combine both: start a polling loop in onSubscribe and emit events when the poll detects changes. Use ctx.hasSubscribers() to stop polling when nobody is listening.
API reference
ctx.emit(event, payload)
Emits an event to all matching subscribers in the current session.
emit(event: string, payload: Record<string, unknown>): void- event — event type name (must be registered via
app.event(), unless the subscriber uses'*') - payload — JSON-serializable object delivered to subscribers
ctx.hasSubscribers(eventType)
Returns true if at least one subscriber is listening for the given event type (or via '*').
hasSubscribers(eventType: string): booleanctx.activeSubscriptions()
Returns all event types that currently have active subscribers, including '*' if any wildcard subscription exists.
activeSubscriptions(): string[]ctx.subscribeInbound(source, eventTypes)
Subscribes to events from a bound app or the host platform. Returns a subscription ID.
subscribeInbound(
source: { kind: 'binding'; binding: string } | { kind: 'platform'; namespace: string },
eventTypes: string[],
): Promise<string>ctx.unsubscribeInbound(subscriptionId)
Removes a previously created inbound subscription.
unsubscribeInbound(subscriptionId: string): Promise<void>app.event(name, config)
Registers an event type that your app can emit.
app.event(name: string, config: { description: string }): voidAppConfig.onSubscribe
Lifecycle hook called when a session subscribes to one or more of your app's events.
onSubscribe?: (ctx: ToolContext, eventTypes: string[]) => void | Promise<void>AppConfig.capabilities.events
Set to true to declare that your app emits events. Default: false.
capabilities: { events: true }Next steps
- Implement tools — register tools and use the full ToolContext
- Storage — volume files, sharing, and key-value storage
- Advanced — OAuth flows and cross-app intents