Skip to main content

Hooks

A hook intercepts an action before it commits. This is the sharpest edge in the SDK: your mod gets to sit in the path of something another mod or the host is doing.

hook is a value, not a factory:

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
,
const hook: Readonly<{
    beforeAction<TAction extends string>(input: BeforeActionHookInput<TAction>): BeforeActionHook<NormalizeHostActionReference<TAction>>;
}>
Declares that this mod wants to step in front of a host action. Interception is two halves. This half is the declaration: which action, under what name, gated by what condition. It is what the manifest records and what the user grants permission for. The other half is the interceptor itself, registered at runtime with `ctx.hooks.beforeAction(action, interceptor)` from inside an `effect`. Declaring here without registering there means nothing runs. A hook fires before the action does and can change its input, let it through, or stop it. That is what separates it from `system.on` and `ctx.on`, which observe after the fact and cannot alter anything.
@categoryAuthoring
hook
} from '@modular/sdk';
export default
defineMod<{
    readonly metadata: {
        readonly displayName: "Careful";
        readonly description: "Confirms before writing";
    };
    readonly commands: readonly [Command<string>];
    readonly hooks: readonly [BeforeActionHook<`command:${string}`>];
}>(definition: {
    readonly metadata: {
        readonly displayName: "Careful";
        readonly description: "Confirms before writing";
    };
    readonly commands: readonly [Command<string>];
    readonly hooks: readonly [BeforeActionHook<`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: "Careful";
    readonly description: "Confirms before writing";
}
metadata
: { displayName: "Careful"displayName: 'Careful', description: "Confirms before writing"description: 'Confirms before writing' },
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: 'careful.capitalize', title: stringtitle: 'Capitalize the selection', run: (ctx: CommandContext) => Awaitable<unknown>run: async ctx: CommandContextctx => { await ctx: CommandContextctx.ContextBase.action<"clipboard.writeText">(id: "clipboard.writeText", input: ClipboardWriteTextInput, options?: HostActionClientRunOptions): Promise<ClipboardWriteTextResult>
Dispatch an action. This is the only way a mod changes the app. Takes a host action id or a bare command id. Inside an event listener, prefer `occurrence.action(...)` so the causal link survives.
action
('clipboard.writeText', { ClipboardWriteTextInput.text: stringtext: 'CAREFUL' });
}, }), ], hooks: readonly [BeforeActionHook<`command:${string}`>]hooks: [
const hook: Readonly<{
    beforeAction<TAction extends string>(input: BeforeActionHookInput<TAction>): BeforeActionHook<NormalizeHostActionReference<TAction>>;
}>
Declares that this mod wants to step in front of a host action. Interception is two halves. This half is the declaration: which action, under what name, gated by what condition. It is what the manifest records and what the user grants permission for. The other half is the interceptor itself, registered at runtime with `ctx.hooks.beforeAction(action, interceptor)` from inside an `effect`. Declaring here without registering there means nothing runs. A hook fires before the action does and can change its input, let it through, or stop it. That is what separates it from `system.on` and `ctx.on`, which observe after the fact and cannot alter anything.
@categoryAuthoring
hook
.beforeAction<string>(input: BeforeActionHookInput<string>): BeforeActionHook<`command:${string}`>
Declare an interception of one action, before it runs. Returns the declaration to place in the mod's `hooks` array.
beforeAction
({
BeforeActionHookInput<string>.action: string
The action to intercept: a host action id such as `workspace.file.write`, or one of your own command ids. Write the command id bare, the `command:` prefix is added for you and passing it yourself is a type error.
action
: 'command:careful.capitalize',
BeforeActionHookInput<TAction extends string>.title: string
Human-readable label shown when the host asks the user about this hook.
title
: 'Confirm capitalize',
}), ], });

The command: prefix names one of your own commands. Passing a bare host action id such as clipboard.writeText works too; passing command: in front of a host action is a type error.

Its shape is Readonly<{ beforeAction }> — one member, deliberately. There is no afterAction, because reacting to something that already happened is what events and action traces are for.

Why it is beforeAction and not beforeAnything

Hooks attach to governed action ids — the entries in ModularActionMap. You cannot intercept an arbitrary function call in another mod. The set of interceptable points is the set of things the host chose to publish as actions, which is what keeps interception auditable rather than ambient.

Reach it from a handler

ctx.hooks exposes the same surface inside a running handler, alongside ctx.permissions — what your mod is allowed to do.

Use it sparingly

An intercepting mod changes behaviour for code that never asked for it. That is the point, and it is also the risk: two mods hooking the same action are two mods with an implicit ordering dependency neither one declared.