Skip to main content

Events

An event is a published contract, not a local callback. You declare it, the host registers it under your mod as owner, and anything else in the system can discover it and subscribe.

import { function command<TId extends string>(commandValue: CommandInput<TId>): Command<TId>
Declare an action the user runs from the command palette or a keybinding. Reach for this when a person triggers the behavior. The `title` is what they read in the palette, `category` groups the entry, and `when` decides whether it shows up at all. The handler takes no argument, only a context. Two neighbours are easy to confuse. `slashCommand` is also user-triggered, but it is typed inside a chat session and receives free text plus the live AI session. `tool` is not user-triggered at all, the model decides to call it mid-turn. The result goes in the mod's top-level `commands` array, alongside any slash commands. The `id` is the address a keybinding or a `command:` hook targets, so keep it stable.
@categoryAuthoring
command
, function defineMod<const TMod extends ModDefinition>(definition: TMod & ValidateModHooks<TMod>): Mod
The entry point of the SDK. A mod's module default-exports one `defineMod` call and nothing else. The returned value is data. It registers nothing, starts nothing, and touches no host state. The host folds it twice. At build time it runs the module in a throwaway sandbox and reads the value to derive the serializable manifest. At runtime it folds the same value again to wire your handlers into their lanes: ext-host for commands, tools, effects, and hooks; the sandboxed renderer for views and widgets. That double fold is why the module must be pure. It is re-executed to produce the manifest, so anything at the top level, a file read, a network call, a timer, a `console` line, runs during a build with no host attached. Put behavior inside handlers: `run`, `activate`, `handle`. The `ValidateModHooks` constraint makes the call fail to typecheck when a `hook.beforeAction` targets an action that is neither a known host action nor `command:` plus one of this mod's own command ids.
@example```ts export default defineMod({ metadata: { displayName: 'Review', description: 'Review helpers for the current change.', }, commands: [ command({ id: 'review.open', title: 'Review: Open', run: openPanel }), slashCommand({ id: 'review', description: 'Review this change', run: review }), ], ai: { tools: [reviewTool], }, }); ```@categoryAuthoring
defineMod
, function event<TPayload>(input: EventInput<TPayload>): Event<TPayload>
Declare an event your mod can publish. The payload type is read off `TPayload` by the authoring compiler, so the host validates every emit and every subscriber sees the shape it expects. Use this when your mod is the thing that happened. To listen to something the host already publishes, subscribe with `ctx.on('editor.selectionChanged', listener)` or, for a host event source, `system.on(...)`.
@categoryState
event
} from '@modular/sdk';
const
const greeted: Event<{
    readonly name: string;
}>
greeted
=
event<{
    readonly name: string;
}>(input: EventInput<{
    readonly name: string;
}>): Event<{
    readonly name: string;
}>
Declare an event your mod can publish. The payload type is read off `TPayload` by the authoring compiler, so the host validates every emit and every subscriber sees the shape it expects. Use this when your mod is the thing that happened. To listen to something the host already publishes, subscribe with `ctx.on('editor.selectionChanged', listener)` or, for a host event source, `system.on(...)`.
@categoryState
event
<{ readonly name: stringname: string }>({
EventInput<{ readonly name: string; }>.id: string
Identifier, unique within the mod.
id
: 'hello.greeted',
EventInput<{ readonly name: string; }>.title?: string | undefined
Label shown where the host lists mod events.
title
: 'Greeted',
}); export default
defineMod<{
    readonly metadata: {
        readonly displayName: "Hello";
        readonly description: "A first mod";
    };
    readonly events: readonly [Event<{
        readonly name: string;
    }>];
    readonly commands: readonly [Command<string>];
}>(definition: {
    readonly metadata: {
        readonly displayName: "Hello";
        readonly description: "A first mod";
    };
    readonly events: readonly [Event<{
        readonly name: string;
    }>];
    readonly commands: readonly [Command<string>];
}): Mod
The entry point of the SDK. A mod's module default-exports one `defineMod` call and nothing else. The returned value is data. It registers nothing, starts nothing, and touches no host state. The host folds it twice. At build time it runs the module in a throwaway sandbox and reads the value to derive the serializable manifest. At runtime it folds the same value again to wire your handlers into their lanes: ext-host for commands, tools, effects, and hooks; the sandboxed renderer for views and widgets. That double fold is why the module must be pure. It is re-executed to produce the manifest, so anything at the top level, a file read, a network call, a timer, a `console` line, runs during a build with no host attached. Put behavior inside handlers: `run`, `activate`, `handle`. The `ValidateModHooks` constraint makes the call fail to typecheck when a `hook.beforeAction` targets an action that is neither a known host action nor `command:` plus one of this mod's own command ids.
@example```ts export default defineMod({ metadata: { displayName: 'Review', description: 'Review helpers for the current change.', }, commands: [ command({ id: 'review.open', title: 'Review: Open', run: openPanel }), slashCommand({ id: 'review', description: 'Review this change', run: review }), ], ai: { tools: [reviewTool], }, }); ```@categoryAuthoring
defineMod
({
metadata: {
    readonly displayName: "Hello";
    readonly description: "A first mod";
}
metadata
: { displayName: "Hello"displayName: 'Hello', description: "A first mod"description: 'A first mod' },
events: readonly [Event<{
    readonly name: string;
}>]
events
: [
const greeted: Event<{
    readonly name: string;
}>
greeted
],
commands: readonly [Command<string>]commands: [ command<string>(commandValue: CommandInput<string>): Command<string>
Declare an action the user runs from the command palette or a keybinding. Reach for this when a person triggers the behavior. The `title` is what they read in the palette, `category` groups the entry, and `when` decides whether it shows up at all. The handler takes no argument, only a context. Two neighbours are easy to confuse. `slashCommand` is also user-triggered, but it is typed inside a chat session and receives free text plus the live AI session. `tool` is not user-triggered at all, the model decides to call it mid-turn. The result goes in the mod's top-level `commands` array, alongside any slash commands. The `id` is the address a keybinding or a `command:` hook targets, so keep it stable.
@categoryAuthoring
command
({
id: stringid: 'hello.greet', title: stringtitle: 'Greet', run: (ctx: CommandContext) => Awaitable<unknown>run: async ctx: CommandContextctx => { await ctx: CommandContextctx.
ContextBase.emit<{
    readonly name: string;
}>(descriptor: Event<{
    readonly name: string;
}>, payload: {
    readonly name: string;
}, options?: HostActionClientRunOptions): Promise<EventEmitResult>
Publish one of your mod's own events to whoever is listening.
emit
(
const greeted: Event<{
    readonly name: string;
}>
greeted
, { name: stringname: 'world' });
}, }), ], });

The payload type is the contract. event<T> is where you declare it, and the authoring compiler generates the validator the host enforces on every emit.

Members

Eventid, title, kind, sensitivity, stability. EventInput is what you pass in.

Three of those exist only because someone else is reading:

MemberWhy it exists
stabilitysubscribers need to know whether they can depend on it
sensitivitythe host needs to know what it may log or forward
owner (on the descriptor)the registry needs to know whose event this is

Emitting and subscribing

import { function defineMod<const TMod extends ModDefinition>(definition: TMod & ValidateModHooks<TMod>): Mod
The entry point of the SDK. A mod's module default-exports one `defineMod` call and nothing else. The returned value is data. It registers nothing, starts nothing, and touches no host state. The host folds it twice. At build time it runs the module in a throwaway sandbox and reads the value to derive the serializable manifest. At runtime it folds the same value again to wire your handlers into their lanes: ext-host for commands, tools, effects, and hooks; the sandboxed renderer for views and widgets. That double fold is why the module must be pure. It is re-executed to produce the manifest, so anything at the top level, a file read, a network call, a timer, a `console` line, runs during a build with no host attached. Put behavior inside handlers: `run`, `activate`, `handle`. The `ValidateModHooks` constraint makes the call fail to typecheck when a `hook.beforeAction` targets an action that is neither a known host action nor `command:` plus one of this mod's own command ids.
@example```ts export default defineMod({ metadata: { displayName: 'Review', description: 'Review helpers for the current change.', }, commands: [ command({ id: 'review.open', title: 'Review: Open', run: openPanel }), slashCommand({ id: 'review', description: 'Review this change', run: review }), ], ai: { tools: [reviewTool], }, }); ```@categoryAuthoring
defineMod
, function effect(metadata: EffectMetadata, body: EffectBody): Effect
Declare background behavior that starts when the mod activates. The body runs once at activation, then again whenever any state or signal it read has changed. Dependencies are tracked automatically, so there is nothing to subscribe to and nothing to unsubscribe from. Every listener, interceptor, and subscription the body registers through `ctx` belongs to that one run and is torn down before the next one. Reach for an effect when behavior should stay live for as long as the mod is loaded, instead of firing when someone triggers it. A `command` runs when the user picks it. A `system.on` listener answers one host event source. An effect is the standing one, and it is also the only place `ctx.hooks.beforeAction` interceptors can be registered. The body must be synchronous. Returning a promise throws. Put asynchronous work inside an event handler or an action call. Return a cleanup function or a `Disposable` for anything the SDK does not already own.
@exampleLog the editor selection whenever it changes ```ts effect({ id: 'my-mod.selection' }, ctx => { const selection = ctx.state('editor.selection'); ctx.logger.info('selection changed', selection.value); const timer = setInterval(() => ctx.logger.info('still watching'), 60_000); return () => clearInterval(timer); }); ```@categoryAuthoring
effect
} from '@modular/sdk';
export default
defineMod<{
    readonly metadata: {
        readonly displayName: "Hello";
        readonly description: "A first mod";
    };
    readonly effects: readonly [Effect];
}>(definition: {
    readonly metadata: {
        readonly displayName: "Hello";
        readonly description: "A first mod";
    };
    readonly effects: readonly [Effect];
}): Mod
The entry point of the SDK. A mod's module default-exports one `defineMod` call and nothing else. The returned value is data. It registers nothing, starts nothing, and touches no host state. The host folds it twice. At build time it runs the module in a throwaway sandbox and reads the value to derive the serializable manifest. At runtime it folds the same value again to wire your handlers into their lanes: ext-host for commands, tools, effects, and hooks; the sandboxed renderer for views and widgets. That double fold is why the module must be pure. It is re-executed to produce the manifest, so anything at the top level, a file read, a network call, a timer, a `console` line, runs during a build with no host attached. Put behavior inside handlers: `run`, `activate`, `handle`. The `ValidateModHooks` constraint makes the call fail to typecheck when a `hook.beforeAction` targets an action that is neither a known host action nor `command:` plus one of this mod's own command ids.
@example```ts export default defineMod({ metadata: { displayName: 'Review', description: 'Review helpers for the current change.', }, commands: [ command({ id: 'review.open', title: 'Review: Open', run: openPanel }), slashCommand({ id: 'review', description: 'Review this change', run: review }), ], ai: { tools: [reviewTool], }, }); ```@categoryAuthoring
defineMod
({
metadata: {
    readonly displayName: "Hello";
    readonly description: "A first mod";
}
metadata
: { displayName: "Hello"displayName: 'Hello', description: "A first mod"description: 'A first mod' },
effects: readonly [Effect]effects: [ function effect(metadata: EffectMetadata, body: EffectBody): Effect
Declare background behavior that starts when the mod activates. The body runs once at activation, then again whenever any state or signal it read has changed. Dependencies are tracked automatically, so there is nothing to subscribe to and nothing to unsubscribe from. Every listener, interceptor, and subscription the body registers through `ctx` belongs to that one run and is torn down before the next one. Reach for an effect when behavior should stay live for as long as the mod is loaded, instead of firing when someone triggers it. A `command` runs when the user picks it. A `system.on` listener answers one host event source. An effect is the standing one, and it is also the only place `ctx.hooks.beforeAction` interceptors can be registered. The body must be synchronous. Returning a promise throws. Put asynchronous work inside an event handler or an action call. Return a cleanup function or a `Disposable` for anything the SDK does not already own.
@exampleLog the editor selection whenever it changes ```ts effect({ id: 'my-mod.selection' }, ctx => { const selection = ctx.state('editor.selection'); ctx.logger.info('selection changed', selection.value); const timer = setInterval(() => ctx.logger.info('still watching'), 60_000); return () => clearInterval(timer); }); ```@categoryAuthoring
effect
({ EffectMetadata.id: stringid: 'hello.watch', EffectMetadata.title?: string | undefinedtitle: 'Watch the selection' }, ctx: EffectContextctx => {
ctx: EffectContextctx.ContextBase.on<"editor.selectionChanged">(id: "editor.selectionChanged", listener: (event: EventOccurrence<EditorSelectionChangedEvent>) => void | Promise<void>): Disposable (+1 overload)
Subscribe to an event, either your own declared one or a host event named by id. Dispose the result to stop listening.
on
('editor.selectionChanged', occurrence: EventOccurrence<EditorSelectionChangedEvent>occurrence => {
ctx: EffectContextctx.ContextBase.logger: Logger
Your mod's log channel.
logger
.Logger.info(message: string, ...args: readonly unknown[]): voidinfo(
var String: StringConstructor
(value?: any) => string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String
(occurrence: EventOccurrence<EditorSelectionChangedEvent>occurrence.EventOccurrence<EditorSelectionChangedEvent>.value: EditorSelectionChangedEvent
The event payload.
value
.EditorSelectionChangedEvent.current: EditorSelection | nullcurrent?.charCount: number | undefinedcharCount ?? 0));
}); }), ], });

ctx.emit publishes one of your own declared events; ctx.on subscribes to a host event by its ModularEventMap key, and the payload type follows from that key.

Discovery

The registry view of an event is ModularEventDescriptorDtoid, owner, schema, sensitivity, stability. Reach it with ctx.listEvents() and ctx.describeEvent().

Those two methods are the clearest evidence that events are a connection and not an internal detail: discovery is pointless within a single mod. You already know your own events. It is there so one mod can find another's.

The trusted local workspace context CLI uses the same catalog. It can list event descriptors and stream occurrences as newline-delimited JSON:

modular context events subscribe editor.selectionChanged