Agent Apps Docs

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)
  1. Your app calls ctx.emit('event_name', payload) inside a tool handler, webhook handler, or session lifecycle hook.
  2. The Station delivers the event to all active subscribers for that event type.
  3. 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:

ConventionExampleWhy
Use dot-separated event namesmessage.received, file.uploadedNamespaced, 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
LatencyInstant — subscribers are notified immediatelyDepends on poll interval
EfficiencyZero overhead when no subscribersConstant work regardless of changes
Implementationctx.emit() + onSubscribe hooksetInterval + manual checks
Best forReal-time notifications, state changes, new dataPeriodic 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): boolean

ctx.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 }): void

AppConfig.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