Extension Development

Nerw extensions are written in Swift and run as compiled executables. By importing NerwExtensionKit, you get a clean, type-safe builder API to create search results and handle actions.

You have full access to all macOS frameworks (EventKit, Contacts, URLSession, AppleScript, etc.).

Extension Structure

An extension is a .nerw package (a renamed .zip file) containing:

  1. manifest.json: Metadata about the extension.
  2. main.swift: Your Swift source code.

When a user opens a .nerw file, the app automatically extracts it to ~/.nerw/extensions/ and compiles the main.swift file against the NerwExtensionKit SDK. No app restart required.

Development Workflow: During development, you can use the nerw CLI to initialize, test, and bundle your extensions without manual copying.

manifest.json

{
  "id": "com.example.search",
  "name": "Example Search",
  "description": "Searches an example API",
  "icon": "magnifyingglass",
  "actions": [
    {
      "name": "Action One",
      "description": "First action",
      "triggers": ["one", "first"],
      "icon": "1.circle"
    },
    {
      "name": "Action Two",
      "description": "Second action",
      "triggers": ["two", "second"]
    }
  ]
}
PropertyTypeRequiredDescription
idstringYesUnique identifier (reverse domain notation recommended)
namestringYesDisplay name for the extension package
descriptionstringNoBrief description of the extension
iconstringNoSF Symbol name or local image file (e.g. “icon.png”) for the extension package
actionsarrayYesList of actions provided by this extension
settingsarrayNoList of configuration options for the extension

Action Object

PropertyTypeRequiredDescription
namestringYesDisplay name shown in the UI
descriptionstringNoBrief description shown as subtitle (use %s for argument placeholder)
triggersarrayYesKeyword triggers that activate this action
typestringNoAction type: “inlineArg”, “noArg” (instantly executes), or “args” (default)
functionstringNoFunction name called when type is “noArg”. If absent, uses name
iconstringNoSF Symbol name or local image file (e.g., “star”, “gear”, “add.png”)
longRunningbooleanNoIf true, bypasses the 30s timeout for this action. Used for actions that show a panel and block indefinitely.

Note: For backward compatibility, if actions is missing, the top-level name, description, trigger/triggers, and icon will be used to create a single action.


NerwExtension Protocol

Extension Settings & Theme

Extensions can define persistent configuration options that users can modify in the Nerw settings UI. These values are automatically passed to your extension during query() and perform().

Additionally, the host app’s UI configuration is automatically provided via the _theme key, exposing the current NerwThemeConfig.

Defining Settings in manifest.json

Add a settings array to your manifest.json. Each setting object supports the following properties:

PropertyTypeRequiredDescription
idstringYesUnique key used to access the value in code
titlestringYesLabel shown in the settings UI
descriptionstringNoSmaller subtitle text explaining the setting
typestringYesInput type: “string”, “boolean”, or “number”
defaultValueanyYesInitial value if the user hasn’t changed it

Example Manifest:

{
  "id": "com.example.weather",
  "name": "Weather",
  "settings": [
    {
      "id": "apiKey",
      "title": "API Key",
      "description": "Enter your OpenWeatherMap key",
      "type": "string",
      "defaultValue": ""
    },
    {
      "id": "isMetric",
      "title": "Use Metric Units",
      "type": "boolean",
      "defaultValue": true
    }
  ]
}

Accessing Settings in Code

Settings are provided in the input.settings dictionary of QueryInput and ActionInput.

func query(input: QueryInput) -> [NerwResult] {
    let apiKey = input.settings["apiKey"] as? String ?? ""
    let isMetric = input.settings["isMetric"] as? Bool ?? true
    
    // Use the settings to fetch data...
}

Accessing Theme Configuration

The Nerw host automatically injects its complete UI configuration via NerwThemeConfig. This includes visual styling, layout dimensions, and screen positioning — everything you need to build native-looking panels that match the host.

let theme = NerwThemeConfig(from: input.settings)

Appearance Properties

PropertyTypeDefaultDescription
backgroundMaterialString”fullScreenUI”NSVisualEffectView material name
tintColorHexString?nilBackground tint overlay hex color
tintOpacityDouble0.15Background tint opacity
cornerRadiusDouble28.0Panel corner radius
borderColorHexString”#FFFFFF”Panel border hex color
borderOpacityDouble0.18Panel border opacity
borderWidthDouble1.0Panel border width
innerGlowEnabledBoolfalseWhether inner glow is active
innerGlowColorHexString”#FFFFFF”Inner glow hex color
innerGlowOpacityDouble0.06Inner glow opacity
fontNameString?nilCustom font name (nil = system font)
foregroundColorHexString?nilPrimary text hex color
selectionBackgroundColorHexString?nilSelection highlight hex color
selectionForegroundColorHexString?nilSelected text hex color
hintColorHexString?nilSecondary/hint text hex color

Layout Properties

PropertyTypeDefaultDescription
mainPanelWidthDouble700.0Width of the main search panel
mainPanelHeightDouble500.0Height of the main search panel (expanded)
searchFieldHeightDouble32.0Height of the search input field
searchFieldFontSizeDouble25.0Search field font size
searchFieldTopMarginDouble12.0Top margin above search field
searchFieldBottomMarginDouble12.0Bottom margin below search field
horizontalMarginDouble20.0Horizontal margins for content
iconSizeDouble26.0Main icon size
resultRowHeightDouble50.0Height of each result row
resultCellCornerRadiusDouble14.0Corner radius for result cells
resultTitleFontSizeDouble14.0Result title font size
resultSubtitleFontSizeDouble11.0Result subtitle font size
separatorHeightDouble1.0Collapsed separator height
separatorExpandedHeightDouble14.0Expanded separator height
splitPaneItemFontSizeDouble15.0Split pane item font size

Positioning Properties

PropertyTypeDescription
mainPanelOriginXDoubleX origin of the main panel on screen
mainPanelOriginYDoubleY origin of the main panel on screen
mainPanelFrameWidthDoubleCurrent frame width of the main panel
mainPanelFrameHeightDoubleCurrent frame height of the main panel
screenVisibleXDoubleScreen visible area X origin
screenVisibleYDoubleScreen visible area Y origin
screenVisibleWidthDoubleScreen visible area width
screenVisibleHeightDoubleScreen visible area height

Building Custom Panels with NerwPanel

Extensions can create their own native NSPanel windows that match the host’s glassmorphic aesthetic using the NerwPanel helper. The panel runs entirely in your extension’s process — no host IPC needed.

import NerwExtensionKit

func perform(action: ActionInput) {
    let theme = NerwThemeConfig(from: action.settings)
    
    // Create a panel matching the host's dimensions and style
    let panel = NerwPanel(theme: theme)
    
    // Or with custom dimensions:
    // let panel = NerwPanel(theme: theme, width: 500, height: 300)
    
    // Build your content
    let label = NSTextField(labelWithString: "Hello from my extension!")
    label.font = panel.bodyFont()
    label.textColor = panel.foregroundColor
    // ... add more views ...
    
    // Set the content and show
    panel.setContent(myContentView)
    panel.show()  // Blocks until dismissed (Esc or click-outside)
}

NerwPanel API:

Method / PropertyDescription
init(theme:width:height:)Create a themed panel. Width/height default to main panel dimensions.
setContent(_ view:)Replace the panel’s content area with your custom view.
show()Show the panel and block until dismissed.
dismiss()Programmatically dismiss the panel.
contentAreaThe raw NSView you can add subviews to directly.
bodyFont(size:)Returns the themed font at the given size.
foregroundColorPrimary text color from theme.
secondaryColorHint/secondary text color from theme.

Note: NerwPanel.show() starts an NSApplication run loop internally and blocks until the panel is dismissed. This is the recommended pattern for perform(action:) handlers that need to display UI.


NerwExtension Protocol

What it does: The main protocol your extension must conform to. It defines two lifecycle methods that Nerw calls when handling user queries and actions.

Signature:

public protocol NerwExtension {
    func query(input: QueryInput) -> [NerwResult]
    func perform(action: ActionInput)
}

Implementation details:

  • query(input:): Called when the user types a query with your extension’s trigger keyword. Return an array of NerwResult items to display.
  • perform(action:): Called when the user triggers a function-based action (from .instant(action:), .arg(), .form(), or .hybrid()). The default implementation is empty, so you only need to implement it if you use function-based actions.

Example:

struct MyExtension: NerwExtension {
    func query(input: QueryInput) -> [NerwResult] {
        return [
            NerwResult("Search Google")
                .subtitle("Search the web")
                .icon(.system("magnifyingglass"))
                .instant(action: "https://google.com")
        ]
    }

    func perform(action: ActionInput) {
        // Handle function-based actions here
    }
}

QueryInput Struct

What it does: Provides the input data when your extension’s query(input:) method is called.

Signature:

public struct QueryInput {
    public let query: String
    public let triggers: [String]

    public init(query: String, triggers: [String] = [])
}

Properties:

PropertyTypeDescription
queryStringThe full text the user typed after the trigger
triggers[String]The trigger keyword(s) that activated this extension. Usually contains one element: the trigger actually typed.

Implementation:

public init(query: String, triggers: [String] = []) {
    self.query = query
    self.triggers = triggers
}

Example:

func query(input: QueryInput) -> [NerwResult] {
    // If user types "one hello world"
    // input.triggers = ["one"]
    // input.query = "hello world"
    
    return searchDatabase(query: input.query)
}

ActionInput Struct

What it does: Provides the input data when your extension’s perform(action:) method is called. Contains the action identifier and any data collected from the user.

Signature:

public struct ActionInput {
    public let function: String
    public let args: [String]
    public let formValues: [String: String]

    public init(
        function: String,
        args: [String] = [],
        formValues: [String: String] = [:]
    )
}

Properties:

PropertyTypeDescription
functionStringThe function name specified in the builder (e.g., “handleSearch”)
args[String]Array of arguments collected via .arg() action
formValues[String: String]Dictionary mapping field IDs to submitted values from .form() action

Implementation:

public init(function: String, args: [String] = [], formValues: [String: String] = [:]) {
    self.function = function
    self.args = args
    self.formValues = formValues
}

Example:

func perform(action: ActionInput) {
    switch action.function {
    case "handleSearch":
        // For .arg() actions: get user input
        let searchTerm = action.args.first ?? ""
        Nerw.open("https://google.com/search?q=\(searchTerm)")
        
    case "handleLogin":
        // For .form() actions: get form values by field ID
        let username = action.formValues["username"] ?? ""
        let password = action.formValues["password"] ?? ""
        authenticate(username: username, password: password)
    }
}

NerwResult Struct

What it does: Represents a single search result item. Uses a fluent builder pattern to configure its appearance and behavior.

Signature:

public struct NerwResult {
    public var title: String
    public var subtitleText: String
    public var resultIcon: NerwIcon

    public init(_ title: String)

    // Builder methods
    public func subtitle(_ text: String) -> NerwResult
    public func icon(_ icon: NerwIcon) -> NerwResult
    public func instant(action: String) -> NerwResult
    public func arg(names: [String], action: String) -> NerwResult
    public func inlineArg(action: String) -> NerwResult
    public func hybrid(action: String, quickAction: NerwResult) -> NerwResult
    public func form(fields: [NerwField], submitLabel: String?, action: String) -> NerwResult
    public func modifier(_ key: ModifierKey, action: String, title: String?, subtitle: String?) -> NerwResult
    public func peek(_ peek: NerwPeek) -> NerwResult
    public func peek(title: String, text: String, icon: NerwIcon?, primaryAction: String?, secondaryAction: String?) -> NerwResult
}

Constructor:

ParameterTypeDescription
titleStringThe main text displayed for this result

Builder Methods:

.subtitle(_:)

What it does: Sets the secondary text line displayed below the title.

public func subtitle(_ text: String) -> NerwResult

Implementation:

public func subtitle(_ text: String) -> NerwResult {
    var copy = self
    copy.subtitleText = text
    return copy
}

Example:

NerwResult("Google")
    .subtitle("Search the web")

.icon(_:)

What it does: Sets the icon displayed next to the result.

public func icon(_ icon: NerwIcon) -> NerwResult

Implementation:

public func icon(_ icon: NerwIcon) -> NerwResult {
    var copy = self
    copy.resultIcon = icon
    return copy
}

Example:

NerwResult("Settings")
    .icon(.system("gear"))           // SF Symbol
    .icon(.file("/path/to/icon.png")) // Custom image file

.instant(action:)

What it does: Configures the result for immediate execution. When the user presses Enter, the action is triggered immediately.

public func instant(action: String) -> NerwResult

Parameters:

ParameterTypeDescription
actionStringEither a URL (opened in browser) or a function name (calls your perform(action:))

Implementation:

public func instant(action: String) -> NerwResult {
    var copy = self
    copy.actionType = "instant"
    copy.actionValue = action
    return copy
}

.option(value:)

What it does: Configures the result as a programmatic option for a query action (an action with type: "args"). When the user presses Enter on this result, the value is sent to your extension’s perform(action:) handler as the query (via action.args.first).

public func option(value: String) -> NerwResult

Parameters:

ParameterTypeDescription
valueStringThe string to submit as the query when this option is selected.

Implementation:

public func option(value: String) -> NerwResult {
    var copy = self
    copy.actionType = "option"
    copy.actionValue = value
    return copy
}

Example:

// URL action - opens in browser
NerwResult("Open GitHub")
    .instant(action: "https://github.com")

// Function action - calls perform(action:)
NerwResult("Custom Action")
    .instant(action: "handleCustom")

.modifier(_:action:title:subtitle:)

What it does: Adds an alternate action when a modifier key (Cmd, Shift, Ctrl, or Option) is held while pressing Enter.

public func modifier(_ key: ModifierKey, action: String, title: String? = nil, subtitle: String? = nil) -> NerwResult

Parameters:

ParameterTypeDescription
keyModifierKeyThe key to trigger the action: .command, .shift, .control, or .option
actionStringURL or function name to execute
titleString?Optional: Alternate title shown in UI when the key is held
subtitleString?Optional: Alternate subtitle shown in UI when the key is held

Example:

NerwResult("Open File")
    .subtitle("Open in default app")
    .instant(action: "openFile")
    .modifier(.command, action: "revealInFinder", title: "Reveal in Finder", subtitle: "Show file location")
    .modifier(.shift, action: "copyPath", title: "Copy Path", subtitle: "Copy absolute file path")

.arg(names:action:)

What it does: Configures a multi-step argument action. The user types additional input after pressing Enter, and the collected arguments are passed to your perform(action:) method.

public func arg(names: [String] = ["Query"], action: String) -> NerwResult

Parameters:

ParameterTypeDescription
names[String]Placeholder names for each argument step (default: [“Query”])
actionStringFunction name called with collected args

Implementation:

public func arg(names: [String], action: String) -> NerwResult {
    var copy = self
    copy.actionType = "arg"
    copy.actionValue = action
    copy.argNamesList = names
    return copy
}

Example:

// Single argument
NerwResult("Search")
    .arg(action: "handleSearch")

// Multiple arguments
NerwResult("Calculate")
    .arg(names: ["Number 1", "Operator", "Number 2"], action: "calculate")

.inlineArg(action:)

What it does: Configures inline argument action. The action remains in the main search field, and the user’s typed query (after the trigger and a space) is passed as the argument to your perform(action:) method.

public func inlineArg(action: String) -> NerwResult

Parameters:

ParameterTypeDescription
actionStringFunction name called with the argument

Example:

NerwResult("Map")
    .subtitle("Search for '%s' on Apple Maps")
    .icon(.system("map.fill"))
    .inlineArg(action: "openMap")

.hybrid(action:quickAction:)

What it does: Provides two parallel actions: Enter executes the primary action, Tab shows and executes a quick action.

public func hybrid(action: String, quickAction: NerwResult) -> NerwResult

Parameters:

ParameterTypeDescription
actionStringPrimary action (URL or function name) executed on Enter
quickActionNerwResultSecondary action shown and executed on Tab

Implementation:

public func hybrid(action: String, quickAction: NerwResult) -> NerwResult {
    var copy = self
    copy.actionType = "hybrid"
    copy.actionValue = action
    copy.quickActionResult = NerwResultBox(quickAction)
    return copy
}

Example:

NerwResult("Search")
    .hybrid(
        action: "https://google.com",  // Enter: open Google
        quickAction: NerwResult("Quick Search")
            .subtitle("Tab to quick search")
            .icon(.system("bolt.fill"))
            .instant(action: "handleQuick")  // Tab: custom handler
    )

.form(fields:submitLabel:action:)

What it does: Shows a multi-field input form. When the user submits, the form values are passed to your perform(action:) method.

public func form(
    fields: [NerwField],
    submitLabel: String? = nil,
    action: String
) -> NerwResult

Parameters:

ParameterTypeDescription
fields[NerwField]Array of form field definitions
submitLabelString?Custom text for the submit button
actionStringFunction name called with form values

Implementation:

public func form(
    fields: [NerwField],
    submitLabel: String?,
    action: String
) -> NerwResult {
    var copy = self
    copy.actionType = "form"
    copy.actionValue = action
    copy.formFieldsList = fields
    copy.formSubmitLabelText = submitLabel
    return copy
}

Example:

NerwResult("Login")
    .form(
        fields: [
            NerwField("username", title: "Username"),
            NerwField("password", title: "Password", secure: true),
        ],
        submitLabel: "Sign In",
        action: "handleLogin"
    )

.peek(_:)

What it does: Adds an expanded inline preview pane shown to the right of the result.

public func peek(_ peek: NerwPeek) -> NerwResult
public func peek(
    title: String,
    text: String,
    icon: NerwIcon? = nil,
    primaryAction: String? = nil,
    secondaryAction: String? = nil,
    titleFontSize: CGFloat? = nil,
    textFontSize: CGFloat? = nil,
    courtesyText: String? = nil,
    courtesyIcon: NerwIcon? = nil
) -> NerwResult

Parameters (convenience form):

ParameterTypeDescription
titleStringPreview pane title
textStringContent text
iconNerwIcon?Optional icon. Use .none to hide completely.
primaryActionString?Optional function called by primary button
secondaryActionString?Optional function called by secondary button
titleFontSizeCGFloat?Optional custom font size for the title. Set to 0 to completely hide the title.
textFontSizeCGFloat?Optional custom font size for the text. Set to 0 to completely hide the text.
courtesyTextString?Optional small attribution text placed in the bottom right corner.
courtesyIconNerwIcon?Optional icon placed next to the courtesy text.

Example:

NerwResult("Movie: Inception")
    .peek(
        title: "Inception (2010)",
        text: "A thief who steals corporate secrets through dream-sharing technology...",
        icon: .system("film"),
        primaryAction: "playMovie"
    )

NerwIcon Enum

What it does: Represents an icon for a search result. Supports SF Symbols and custom file paths.

Signature:

public enum NerwIcon {
    case system(String)
    case file(String)

    func serialize() -> String
}

Cases:

CaseDescription
.system(String)SF Symbol name (e.g., “star.fill”, “magnifyingglass”)
.file(String)Path to an image file. Can be absolute or relative to extension package.

Implementation:

public enum NerwIcon {
    case system(String)
    case file(String)

    func serialize() -> String {
        switch self {
        case .system(let name): return name
        case .file(let path): return path
        }
    }
}

Example:

NerwResult("Settings")
    .icon(.system("gear"))

NerwResult("Custom")
    .icon(.file("/Users/me/icons/custom.png"))

NerwField Struct

What it does: Defines a single input field in a form action.

Signature:

public struct NerwField {
    public let id: String
    public let title: String
    public let subtext: String?
    public let placeholder: String?
    public let isSecure: Bool
    public let isMultiline: Bool
    public let defaultValue: String?

    public init(
        _ id: String,
        title: String,
        subtext: String? = nil,
        placeholder: String? = nil,
        secure: Bool = false,
        multiline: Bool = false,
        defaultValue: String? = nil
    )
}

Properties:

PropertyTypeDescription
idStringUnique identifier for accessing the field value
titleStringLabel displayed above the field
subtextString?Small descriptive text displayed below the title
placeholderString?Placeholder text shown when empty
isSecureBoolWhether the field is a password field
isMultilineBoolWhether the field supports multiple lines of text
defaultValueString?Initial value populated in the field

Implementation:

public init(
    _ id: String,
    title: String,
    subtext: String? = nil,
    placeholder: String? = nil,
    secure: Bool = false,
    multiline: Bool = false,
    defaultValue: String? = nil
) {
    self.id = id
    self.title = title
    self.subtext = subtext
    self.placeholder = placeholder
    self.isSecure = secure
    self.isMultiline = multiline
    self.defaultValue = defaultValue
}

Example:

NerwField("email", title: "Email Address", placeholder: "user@example.com")
NerwField("password", title: "Password", secure: true)

NerwPeek Struct

What it does: Configuration for an expanded inline preview (Peek) shown beside a result.

Signature:

public struct NerwPeek {
    public let title: String
    public let text: String
    public let icon: NerwIcon?
    public let primaryActionName: String?
    public let secondaryActionName: String?
    public let titleFontSize: CGFloat?
    public let textFontSize: CGFloat?
    public let courtesyText: String?
    public let courtesyIcon: NerwIcon?

    public init(
        title: String,
        text: String,
        icon: NerwIcon? = nil,
        primaryAction: String? = nil,
        secondaryAction: String? = nil,
        titleFontSize: CGFloat? = nil,
        textFontSize: CGFloat? = nil,
        courtesyText: String? = nil,
        courtesyIcon: NerwIcon? = nil
    )
}

Properties:

PropertyTypeDescription
titleStringPreview pane title
textStringMain content text
iconNerwIcon?Optional icon. Use .none to hide completely.
primaryActionNameString?Function called when primary button clicked
secondaryActionNameString?Function called when secondary button clicked
titleFontSizeCGFloat?Custom font size for the title. Set to 0 to completely hide the title.
textFontSizeCGFloat?Custom font size for the text. Set to 0 to completely hide the text.
courtesyTextString?Small attribution text in bottom right
courtesyIconNerwIcon?Optional icon next to the courtesy text

Implementation:

public init(
    title: String,
    text: String,
    icon: NerwIcon? = nil,
    primaryAction: String? = nil,
    secondaryAction: String? = nil,
    titleFontSize: CGFloat? = nil,
    textFontSize: CGFloat? = nil,
    courtesyText: String? = nil,
    courtesyIcon: NerwIcon? = nil
) {
    self.title = title
    self.text = text
    self.icon = icon
    self.primaryActionName = primaryAction
    self.secondaryActionName = secondaryAction
    self.titleFontSize = titleFontSize
    self.textFontSize = textFontSize
    self.courtesyText = courtesyText
    self.courtesyIcon = courtesyIcon
}

Example:

let peek = NerwPeek(
    title: "Weather Today",
    text: "Sunny, 72°F",
    icon: .system("sun.max.fill"),
    primaryAction: "viewWeek"
)

NerwResult("Weather")
    .peek(peek)

Nerw API (Host Commands)

What it does: Static API for instructing the Nerw host application to perform actions. Use inside your perform(action:) method.

Signature:

public enum Nerw {
    public static func open(_ url: String)
    public static func copy(_ text: String)
    public static func log(_ message: String)
    public static func notify(_ content: String, level: String, progressive: Bool, id: String?)
    public static func dismissNotify(id: String)
    public static func run(_ ext: NerwExtension)
}

Nerw.open(_:)

What it does: Opens a URL in the default browser or a file path in Finder.

public static func open(_ url: String)

Implementation:

public static func open(_ url: String) {
    pendingCommands.append(["type": "open", "value": url])
}

Example:

Nerw.open("https://github.com")
Nerw.open("/System/Applications/Calculator.app")
Nerw.open("file:///Users/me/Documents")

Nerw.copy(_:)

What it does: Copies text to the system clipboard.

public static func copy(_ text: String)

Implementation:

public static func copy(_ text: String) {
    pendingCommands.append(["type": "copy", "value": text])
}

Example:

Nerw.copy("Secret Token: 12345")
Nerw.copy(action.formValues["username"] ?? "")

Nerw.log(_:)

What it does: Logs a debug message to stderr (visible in terminal if running Nerw manually).

public static func log(_ message: String)

Implementation:

public static func log(_ message: String) {
    FileHandle.standardError.write(
        "[Extension] \(message)\n".data(using: .utf8) ?? Data())
}

Example:

Nerw.log("User clicked the button!")
Nerw.log("Search results: \(results.count)")

Nerw.notify(_:level:progressive:id:)

What it does: Displays a system notification panel.

public static func notify(
    _ content: String,
    level: String = "info",
    progressive: Bool = false,
    id: String? = nil
)

Parameters:

ParameterTypeDefaultDescription
contentString-Message text
levelString”info”Severity: “info”, “warn”, or “error”
progressiveBoolfalseShows spinner; prevents auto-dismiss
idString?nilIdentifier for later dismissal

Implementation:

public static func notify(
    _ content: String,
    level: String = "info",
    progressive: Bool = false,
    id: String? = nil
) {
    var cmd: [String: Any] = [
        "type": "notify",
        "value": content,
        "level": level,
        "progressive": progressive,
    ]
    if let id = id { cmd["id"] = id }
    pendingCommands.append(cmd)
}

Example:

Nerw.notify("Download started", level: "info")
Nerw.notify("File not found", level: "error")
Nerw.notify("Processing...", level: "warn", progressive: true, id: "loader-1")

Nerw.dismissNotify(id:)

What it does: Dismisses a previously shown progressive notification.

public static func dismissNotify(id: String)

Implementation:

public static func dismissNotify(id: String) {
    pendingCommands.append([
        "type": "dismiss_notify",
        "id": id,
    ])
}

Example:

Nerw.notify("Downloading...", progressive: true, id: "download-1")
// ... later when done ...
Nerw.dismissNotify(id: "download-1")

Nerw.run(_:)

What it does: Bootstraps and runs the extension. Call this exactly once at the end of your main.swift. Handles reading input from stdin, dispatching to query() or perform(), and writing results to stdout.

public static func run(_ ext: NerwExtension)

Implementation flow:

  1. Reads JSON input from stdin
  2. Parses the type field (“query” or “action”)
  3. For “query”: calls ext.query(input:) and prints serialized results
  4. For “action”: calls ext.perform(action:) and prints pending commands
  5. If invalid input, logs error and exits with code 1

Example:

import NerwExtensionKit

struct MyExtension: NerwExtension {
    func query(input: QueryInput) -> [NerwResult] { ... }
    func perform(action: ActionInput) { ... }
}

Nerw.run(MyExtension())

Development Tools

The nerw CLI provides tools to streamline extension development.

Initializing a New Extension

To create a new extension template:

nerw extension init

This will prompt you for the extension name, ID, and trigger, then create a directory with manifest.json and a boilerplate main.swift.

Testing Extensions (Smoke Test)

You can test your extension without installing it into the main app:

cd your-extension-dir
nerw extension smoke-test "your test query"

The smoke-test command:

  1. Compiles your main.swift into a temporary binary.
  2. Runs the binary with a mock query input.
  3. Prints the JSON output (results) to the terminal.

This is the fastest way to debug your extension’s logic and ensure it returns the expected results.


Example Extensions

Basic Extension (Single Trigger)

import Foundation
import NerwExtensionKit

struct GoogleSearch: NerwExtension {
    func query(input: QueryInput) -> [NerwResult] {
        let query = input.query
        let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
        
        return [
            NerwResult("Search Google for '\(query)'")
                .subtitle("Open in browser")
                .icon(.system("magnifyingglass"))
                .instant(action: "https://www.google.com/search?q=\(encoded)")
        ]
    }

    func perform(action: ActionInput) {
        // URL actions execute automatically without needing this
    }
}

Nerw.run(GoogleSearch())

Dual Trigger Extension

import NerwExtensionKit

struct DualExtension: NerwExtension {
    func query(input: QueryInput) -> [NerwResult] {
        if input.triggers.contains("gh") {
            return [
                NerwResult("GitHub")
                    .subtitle("Open GitHub")
                    .icon(.system("chevron.left.forwardslash.chevron.right"))
                    .instant(action: "https://github.com")
            ]
        } else if input.triggers.contains("gl") {
            return [
                NerwResult("GitLab")
                    .subtitle("Open GitLab")
                    .icon(.system("t.square"))
                    .instant(action: "https://gitlab.com")
            ]
        }
        return []
    }

    func perform(action: ActionInput) {}
}

Nerw.run(DualExtension())
import NerwExtensionKit

struct FullFeaturedExtension: NerwExtension {
    func query(input: QueryInput) -> [NerwResult] {
        return [
            // Instant action (URL)
            NerwResult("Open Documentation")
                .subtitle("Apple Developer Docs")
                .icon(.system("book"))
                .instant(action: "https://developer.apple.com/documentation"),

            // Instant action (function)
            NerwResult("Copy API Key")
                .subtitle("Copy to clipboard")
                .icon(.system("key"))
                .instant(action: "copyApiKey"),

            // Argument action
            NerwResult("Search NPM")
                .subtitle("Search npm registry")
                .icon(.system("cube.box"))
                .arg(names: ["Package Name"], action: "searchNpm"),

            // Hybrid action
            NerwResult("Quick Actions Demo")
                .subtitle("Enter for URL, Tab for function")
                .icon(.system("bolt"))
                .hybrid(
                    action: "https://example.com",
                    quickAction: NerwResult("Run Script")
                        .subtitle("Execute shell command")
                        .icon(.system("terminal"))
                        .instant(action: "runScript")
                ),

            // Form action
            NerwResult("SSH Connection")
                .subtitle("Connect to server")
                .icon(.system("network"))
                .form(
                    fields: [
                        NerwField("host", title: "Host", placeholder: "server.com"),
                        NerwField("user", title: "Username"),
                        NerwField("pass", title: "Password", secure: true),
                    ],
                    submitLabel: "Connect",
                    action: "sshConnect"
                ),

            // With peek preview
            NerwResult("Weather: San Francisco")
                .subtitle("72°F, Partly Cloudy")
                .icon(.system("cloud.sun"))
                .peek(
                    title: "San Francisco, CA",
                    text: "Currently: 72°F, Partly Cloudy\nHigh: 78°F, Low: 62°F\nHumidity: 65%",
                    icon: .system("cloud.sun.fill")
                ),
        ]
    }

    func perform(action: ActionInput) {
        switch action.function {
        case "copyApiKey":
            Nerw.copy("sk-api-key-1234567890")
            Nerw.notify("API key copied!", level: "info")

        case "searchNpm":
            let package = action.args.first ?? ""
            let encoded = package.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? package
            Nerw.open("https://www.npmjs.com/search?q=\(encoded)")

        case "runScript":
            Nerw.log("Running script...")
            Nerw.notify("Script completed", level: "info")

        case "sshConnect":
            let host = action.formValues["host"] ?? ""
            let user = action.formValues["user"] ?? ""
            Nerw.open("ssh://\(user)@\(host)")
            Nerw.notify("Connecting to \(host)...", level: "info")

        default:
            break
        }
    }
}

Nerw.run(FullFeaturedExtension())

Daemon Mode

Extensions can register as long-lived background daemons that stay running while Nerw is open. This enables:

  • Instant queries: Results served from in-memory indexes (file watcher, RSS feed, DB, etc.)
  • Persistent connections: Keep API sessions or sockets alive across queries
  • Push notifications: Daemons can proactively send commands (notify, copy, etc.) to the host

[!IMPORTANT] Daemon mode requires explicit user approval every time a new extension is installed. By default, no extension can run as a daemon. Permission must be granted through the approval dialog or via nerw daemon approve <id>.

Declaring Daemon Mode in manifest.json

{
  "id": "com.example.watcher",
  "name": "File Watcher",
  "description": "Watches files in real-time",
  "icon": "eye.fill",
  "daemon": {
    "enabled": true,
    "description": "Runs a file-system watcher to provide instant search results without re-indexing.",
    "memoryLimit": 64
  },
  "actions": [
    {
      "name": "Watch Results",
      "triggers": ["watch"],
      "description": "Search watched files"
    }
  ]
}

Daemon Config Properties:

PropertyTypeRequiredDescription
enabledbooleanYesMust be true to declare daemon capability
descriptionstringYesShown to the user in the approval dialog
memoryLimitnumberNoRSS memory limit in MB (default: 128, max: 256)

NerwDaemon Protocol

Implement NerwDaemon alongside your NerwExtension to add background processing.

public protocol NerwDaemon: AnyObject {
    func onStart(context: DaemonContext)
    func onQuery(input: QueryInput) -> [NerwResult]   // optional
    func onAction(input: ActionInput)                  // optional
    func onStop()                                      // optional
}
MethodCalled WhenNotes
onStart(context:)Daemon first connects to hostSet up indexes, file watchers, timers
onQuery(input:)User query matches this extension’s triggerReturn results from your in-memory index
onAction(input:)User executes an actionUse Nerw.open(), Nerw.copy(), etc. as normal
onStop()Graceful shutdown or host quitPersist in-memory state to context.dataDirectory

DaemonContext

Provides startup information to your daemon via onStart.

public struct DaemonContext {
    /// Absolute path to persistent storage directory for this extension.
    /// Content survives daemon restarts; cleaned on extension uninstall.
    public let dataDirectory: String

    /// Extension settings as configured by the user in Nerw Settings.
    public let settings: [String: Any]
}

Registering the Daemon

Pass your daemon to Nerw.run:

import NerwExtensionKit
import Foundation

// MARK: - Extension (handles one-shot queries when daemon is unavailable)

struct FileWatcherExtension: NerwExtension {
    func query(input: QueryInput) -> [NerwResult] {
        // Fallback: will only be called if daemon is not running
        return [NerwResult("File Watcher").subtitle("Daemon not running")]
    }
}

// MARK: - Daemon (long-lived background process)

class FileWatcherDaemon: NerwDaemon {
    private var index: [String] = []
    private var watcher: DispatchSourceFileSystemObject?
    private var dataDir: String = ""

    func onStart(context: DaemonContext) {
        dataDir = context.dataDirectory
        loadIndex()          // restore previous state
        startWatching()      // set up FSEvents or DispatchSource
    }

    func onQuery(input: QueryInput) -> [NerwResult] {
        // Serve from in-memory index — instant response
        return index
            .filter { $0.localizedCaseInsensitiveContains(input.query) }
            .prefix(10)
            .map { NerwResult($0).icon(.system("doc")) }
    }

    func onAction(input: ActionInput) {
        switch input.function {
        case "openFile":
            if let path = input.args.first {
                Nerw.open(path)
            }
        default:
            break
        }
    }

    func onStop() {
        watcher?.cancel()
        saveIndex()          // persist to dataDirectory
    }

    private func loadIndex() {
        let file = URL(fileURLWithPath: dataDir).appendingPathComponent("index.json")
        if let data = try? Data(contentsOf: file),
           let arr = try? JSONDecoder().decode([String].self, from: data) {
            index = arr
        }
    }

    private func saveIndex() {
        let file = URL(fileURLWithPath: dataDir).appendingPathComponent("index.json")
        if let data = try? JSONEncoder().encode(index) {
            try? data.write(to: file)
        }
    }

    private func startWatching() { /* FSEvents setup */ }
}

// MARK: - Entry Point

Nerw.run(extension: FileWatcherExtension(), daemon: FileWatcherDaemon())

[!NOTE] When the daemon is running, onQuery is called instead of NerwExtension.query. When it is not running (e.g., not yet approved), Nerw falls back to spawning a one-shot process and calling NerwExtension.query.


Resource Limits

ConstraintDefaultMaximum
Memory (RSS)128 MB256 MB
CPU (sustained)25% for 30s

If limits are exceeded, the daemon receives SIGTERM followed by SIGKILL after 5 seconds.

After 5 crashes within 10 minutes, the daemon is automatically disabled. It can be re-enabled via nerw daemon approve <id> or manually via the approval dialog.

After 60 seconds of uptime, the crash counter resets.


CLI Tools

The nerw command-line tool provides built-in utilities for extension developers.

nerw extension init

Interactively creates a new extension directory with a manifest.json and main.swift template.

  • Asks for: Name, ID, and Trigger keyword.

nerw extension smoke-test [query]

Compiles and runs the extension in the current directory against a test input.

  • [query]: The text to send to the extension’s query() method.
  • --install / -i: Symlinks the current directory to the app’s extension folder and reloads the host. This allows you to test your extension live in the Nerw UI as you save changes.
  • --clean / -c: Removes the development symlink.
  • --daemon / -d: Compiles the extension and launches it in daemon mode with an interactive REPL. Commands: q <query>, a <function>, h (health check), s (stop).

nerw extension bundle

Packages the current directory into a distribution-ready .nerw file.

  • Output: <id>.nerw in the current folder.
  • Includes: manifest.json, main.swift, and any other assets (like .png icons) present in the directory.

nerw daemon list

Lists all daemon-capable extensions with their approval and runtime status.

com.example.watcher       running      12345   uptime: 3h24m
com.example.other         unapproved   -
com.example.broken        disabled     -       (crash_limit)

nerw daemon approve <id>

Grants daemon permission for an extension and requests Nerw to start it immediately.

nerw daemon revoke <id>

Revokes permission and stops the daemon if running.

nerw daemon start <id>

Requests Nerw to start a previously approved daemon.

nerw daemon stop <id>

Requests Nerw to gracefully stop a running daemon.


(For additional examples, check the /examples folder in the repository)