Dynamic Commands
Dynamic commands let a Tier 2 extension register commands at runtime —
in addition to the static commands declared in manifest.json. Each
dynamic command behaves identically to a manifest command at every
layer: it appears in root search, supports inline argument input on
Tab, ranks alongside manifest commands, and persists last-values for
its arguments.
The motivating use case is surfacing user-owned items from the operating
system: macOS Apple Shortcuts, SSH hosts in ~/.ssh/config, project
paths, automation flows, scripts in user directories. The set of items
is unknown when the extension is built, so they cannot live in
manifest.json — the extension discovers them at runtime and registers
them through this API.
When to use dynamic commands
Use dynamic commands when the user's environment determines the list — the extension can't know in advance how many items there will be or what they're called.
- macOS: each Apple Shortcut becomes one dynamic command
- All platforms: each entry in
~/.ssh/configbecomes one dynamic command - All platforms: each script in a user-configured directory becomes one dynamic command
- Cross-platform automation: each Power Automate flow / Raycast Script / Alfred-style template becomes one dynamic command
For any list whose items are known at build time, prefer manifest commands — they're simpler and don't need a worker to register.
For unbounded query-driven results (Linear tickets, Spotify tracks, web
search) where the user picks one and runs it, prefer the existing
Extension.search() API. Those results don't need typed argument
schemas.
Worker-only
Dynamic commands must be registered from the extension's worker
iframe, never the view. The worker is always-on and survives the
panel closing; the view is on-demand and evicted (Dormant) within
roughly two minutes of the user dismissing the launcher. Registering
from the view would silently drop commands the moment the view is
evicted, leaving the user wondering why their items disappeared.
The SDK enforces this two ways:
- The proxy method
commandsService.replaceDynamicCommands(...)assertswindow.__ASYAR_ROLE__ === "worker"before sending. Calling it fromview.tsrejects with a clear error. - The launcher's Rust IPC handler rejects calls from extensions whose
manifest does not declare
background.main. An extension with no worker cannot register dynamic commands at all.
Both gates exist on purpose. The first catches honest mistakes early; the second is the load-bearing security check.
The API
import type { DynamicCommandRegistration, ICommandService } from 'asyar-sdk/contracts';
// ...inside your worker.ts
const commandsService = workerContext.getService<ICommandService>('commands');
const regs: DynamicCommandRegistration[] = [
{
id: 'sc-lights', // stable identifier — see "Stable ids" below
name: 'Set Lights', // displayed in search results
description: 'Smart-home command',
icon: 'icon:lightbulb',
arguments: [
{ name: 'value', type: 'text', placeholder: 'e.g. 85' },
],
},
// ...up to N items; no max enforced today, but expect ~5-100 in practice
];
await commandsService.replaceDynamicCommands(regs);
replaceDynamicCommands takes the full current list. There is no
register / unregister — every call is an atomic snapshot. The
launcher computes added / removed / kept internally and:
- removes search-index entries for ids no longer in the list
- adds search-index entries for new ids
- updates display fields (
name,description,icon) for kept ids - garbage-collects argument last-values for removed ids
If any registration fails validation, the call rejects and the previous list remains intact. Validation is atomic — partial state is never written.
When to call it
The natural pattern is on every change in the underlying source:
- Apple Shortcuts: re-run
shortcuts listfrom afs.watchcallback on~/Library/Shortcuts/. - SSH hosts: re-parse
~/.ssh/configfrom afs.watchcallback on it. - Scripts in directories: re-read the directory from a
fs.watchcallback on the directory.
Plus once at activation:
async activate(): Promise<void> {
await commandsService.replaceDynamicCommands(await computeCurrentList());
}
The activation call is the source of truth on launcher restart — the launcher does not persist registry state between launches.
Argument schemas
Same rules as manifest arguments:
- Maximum 3 arguments per command.
namematches/^[a-zA-Z_][a-zA-Z0-9_]*$/, unique within the command.- Required arguments must precede optional arguments.
typeis one oftext,password,dropdown,number.dropdownrequires a non-emptydata: [{ value, title }, ...].- When
defaultis set, it must match the declared type. passwordvalues are never persisted.
See Command Arguments for the full schema.
Stable ids
The id field is the persistence key for argument last-values. If the
underlying source allows renaming (e.g., an Apple Shortcut's name is
editable, but its UUID is not), use the stable identity as id:
| Source | Recommended id |
|---|---|
| Apple Shortcut | the shortcut's UUID from shortcuts list --show-identifiers |
| SSH host | the Host line value (rarely renamed) |
| Script in directory | the file basename without extension |
| Project | absolute path hash, or workspace UUID if available |
The id format is [a-zA-Z0-9_-]+, max 128 characters. Colons and dots
are not allowed (the launcher reserves : for storage namespacing).
Receiving arguments in the handler
Dynamic commands route through your extension's existing
executeCommand handler. The dynamic id arrives as the commandId;
argument values arrive under args.arguments.<name>:
async executeCommand(commandId: string, args?: CommandExecuteArgs) {
if (commandId === 'sc-lights') {
const value = String(args?.arguments?.value ?? '');
// run the action with the user's value
}
}
There is no separate dispatch source for dynamic commands; they reuse the standard command path. This means scheduled-tick simulation, deeplink trigger flags, and notification-action routing all work identically — no parallel implementation to maintain.
Lifecycle
| Event | Effect on dynamic commands |
|---|---|
| Extension activate | Worker boots, calls replaceDynamicCommands from your activate handler |
| Extension disable | Launcher drops registrations; persistence is kept so re-enable restores last-values |
| Extension uninstall | Launcher drops registrations and wipes all persisted last-values |
| Launcher restart | In-memory registry is fresh; your worker's activate path re-registers |
Persistence
Argument last-values are stored in the launcher's SQLite
command_arg_defaults table, namespaced under
(extension_id, "dynamic:<id>") so dynamic ids can never collide with
manifest command ids.
A dynamic command sharing an id with a manifest command (within the
same extension) is supported and isolated — the persistence keys
differ by the dynamic: prefix the launcher applies internally.
Cross-platform notes
The API itself is platform-neutral. Per-platform extensions should:
- Declare
platformsinmanifest.jsonto constrain installation. - Use OS-native enumeration:
shortcuts liston macOS, parsing~/.ssh/configon all Unix-likes, registry / API calls on Windows. - Watch the underlying source with
fs.watch(file-based) or polling (API-based) and re-callreplaceDynamicCommandson each change.
The launcher core knows nothing about Apple Shortcuts, SSH, or any specific platform feature — those concerns belong in the extension.
Worked example: Apple Shortcuts
// extensions/apple-shortcuts/src/worker.ts
import { ExtensionContext as WorkerExtensionContext } from 'asyar-sdk/worker';
import type { ICommandService, IShellService, IFileSystemWatcherService } from 'asyar-sdk/contracts';
const ctx = new WorkerExtensionContext();
const commandsService = ctx.getService<ICommandService>('commands');
const shell = ctx.getService<IShellService>('shell');
const fsWatcher = ctx.getService<IFileSystemWatcherService>('fsWatcher');
async function listShortcuts() {
const out = await shell.execute({
command: '/usr/bin/shortcuts',
args: ['list', '--show-identifiers'],
});
// parse `name (uuid)` lines into an array
return out.stdout.split('\n').flatMap((line) => {
const m = line.match(/^(.+?)\s+\((.+?)\)$/);
return m ? [{ name: m[1], id: m[2] }] : [];
});
}
async function syncFromOS() {
const items = await listShortcuts();
await commandsService.replaceDynamicCommands(
items.map((item) => ({
id: item.id,
name: item.name,
arguments: [{ name: 'input', type: 'text', placeholder: 'Optional input' }],
})),
);
}
// Initial sync at activate, plus re-sync on every fs change.
await syncFromOS();
await fsWatcher.watch(['~/Library/Shortcuts/'], async () => {
await syncFromOS();
});
See also
- Command Arguments — argument schema reference.
- Script Headers — the built-in
scriptsextension is the canonical dynamic-command consumer; its registrations are driven by# @asyar.*comments inside script files instead of a worker. - SDK · file-system-watcher — the natural invalidation signal for file-system-driven sources.