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:
manifest.json: Metadata about the extension.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"]
}
]
}
| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier (reverse domain notation recommended) |
name | string | Yes | Display name for the extension package |
description | string | No | Brief description of the extension |
icon | string | No | SF Symbol name or local image file (e.g. “icon.png”) for the extension package |
actions | array | Yes | List of actions provided by this extension |
settings | array | No | List of configuration options for the extension |
Action Object
| Property | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name shown in the UI |
description | string | No | Brief description shown as subtitle (use %s for argument placeholder) |
triggers | array | Yes | Keyword triggers that activate this action |
type | string | No | Action type: “inlineArg”, “noArg” (instantly executes), or “args” (default) |
function | string | No | Function name called when type is “noArg”. If absent, uses name |
icon | string | No | SF Symbol name or local image file (e.g., “star”, “gear”, “add.png”) |
longRunning | boolean | No | If true, bypasses the 30s timeout for this action. Used for actions that show a panel and block indefinitely. |
Note: For backward compatibility, if
actionsis missing, the top-levelname,description,trigger/triggers, andiconwill 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:
| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique key used to access the value in code |
title | string | Yes | Label shown in the settings UI |
description | string | No | Smaller subtitle text explaining the setting |
type | string | Yes | Input type: “string”, “boolean”, or “number” |
defaultValue | any | Yes | Initial 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
| Property | Type | Default | Description |
|---|---|---|---|
backgroundMaterial | String | ”fullScreenUI” | NSVisualEffectView material name |
tintColorHex | String? | nil | Background tint overlay hex color |
tintOpacity | Double | 0.15 | Background tint opacity |
cornerRadius | Double | 28.0 | Panel corner radius |
borderColorHex | String | ”#FFFFFF” | Panel border hex color |
borderOpacity | Double | 0.18 | Panel border opacity |
borderWidth | Double | 1.0 | Panel border width |
innerGlowEnabled | Bool | false | Whether inner glow is active |
innerGlowColorHex | String | ”#FFFFFF” | Inner glow hex color |
innerGlowOpacity | Double | 0.06 | Inner glow opacity |
fontName | String? | nil | Custom font name (nil = system font) |
foregroundColorHex | String? | nil | Primary text hex color |
selectionBackgroundColorHex | String? | nil | Selection highlight hex color |
selectionForegroundColorHex | String? | nil | Selected text hex color |
hintColorHex | String? | nil | Secondary/hint text hex color |
Layout Properties
| Property | Type | Default | Description |
|---|---|---|---|
mainPanelWidth | Double | 700.0 | Width of the main search panel |
mainPanelHeight | Double | 500.0 | Height of the main search panel (expanded) |
searchFieldHeight | Double | 32.0 | Height of the search input field |
searchFieldFontSize | Double | 25.0 | Search field font size |
searchFieldTopMargin | Double | 12.0 | Top margin above search field |
searchFieldBottomMargin | Double | 12.0 | Bottom margin below search field |
horizontalMargin | Double | 20.0 | Horizontal margins for content |
iconSize | Double | 26.0 | Main icon size |
resultRowHeight | Double | 50.0 | Height of each result row |
resultCellCornerRadius | Double | 14.0 | Corner radius for result cells |
resultTitleFontSize | Double | 14.0 | Result title font size |
resultSubtitleFontSize | Double | 11.0 | Result subtitle font size |
separatorHeight | Double | 1.0 | Collapsed separator height |
separatorExpandedHeight | Double | 14.0 | Expanded separator height |
splitPaneItemFontSize | Double | 15.0 | Split pane item font size |
Positioning Properties
| Property | Type | Description |
|---|---|---|
mainPanelOriginX | Double | X origin of the main panel on screen |
mainPanelOriginY | Double | Y origin of the main panel on screen |
mainPanelFrameWidth | Double | Current frame width of the main panel |
mainPanelFrameHeight | Double | Current frame height of the main panel |
screenVisibleX | Double | Screen visible area X origin |
screenVisibleY | Double | Screen visible area Y origin |
screenVisibleWidth | Double | Screen visible area width |
screenVisibleHeight | Double | Screen 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 / Property | Description |
|---|---|
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. |
contentArea | The raw NSView you can add subviews to directly. |
bodyFont(size:) | Returns the themed font at the given size. |
foregroundColor | Primary text color from theme. |
secondaryColor | Hint/secondary text color from theme. |
Note:
NerwPanel.show()starts anNSApplicationrun loop internally and blocks until the panel is dismissed. This is the recommended pattern forperform(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 ofNerwResultitems 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:
| Property | Type | Description |
|---|---|---|
query | String | The 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:
| Property | Type | Description |
|---|---|---|
function | String | The 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:
| Parameter | Type | Description |
|---|---|---|
title | String | The 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:
| Parameter | Type | Description |
|---|---|---|
action | String | Either 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:
| Parameter | Type | Description |
|---|---|---|
value | String | The 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:
| Parameter | Type | Description |
|---|---|---|
key | ModifierKey | The key to trigger the action: .command, .shift, .control, or .option |
action | String | URL or function name to execute |
title | String? | Optional: Alternate title shown in UI when the key is held |
subtitle | String? | 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:
| Parameter | Type | Description |
|---|---|---|
names | [String] | Placeholder names for each argument step (default: [“Query”]) |
action | String | Function 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:
| Parameter | Type | Description |
|---|---|---|
action | String | Function 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:
| Parameter | Type | Description |
|---|---|---|
action | String | Primary action (URL or function name) executed on Enter |
quickAction | NerwResult | Secondary 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:
| Parameter | Type | Description |
|---|---|---|
fields | [NerwField] | Array of form field definitions |
submitLabel | String? | Custom text for the submit button |
action | String | Function 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):
| Parameter | Type | Description |
|---|---|---|
title | String | Preview pane title |
text | String | Content text |
icon | NerwIcon? | Optional icon. Use .none to hide completely. |
primaryAction | String? | Optional function called by primary button |
secondaryAction | String? | Optional function called by secondary button |
titleFontSize | CGFloat? | Optional custom font size for the title. Set to 0 to completely hide the title. |
textFontSize | CGFloat? | Optional custom font size for the text. Set to 0 to completely hide the text. |
courtesyText | String? | Optional small attribution text placed in the bottom right corner. |
courtesyIcon | NerwIcon? | 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:
| Case | Description |
|---|---|
.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:
| Property | Type | Description |
|---|---|---|
id | String | Unique identifier for accessing the field value |
title | String | Label displayed above the field |
subtext | String? | Small descriptive text displayed below the title |
placeholder | String? | Placeholder text shown when empty |
isSecure | Bool | Whether the field is a password field |
isMultiline | Bool | Whether the field supports multiple lines of text |
defaultValue | String? | 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:
| Property | Type | Description |
|---|---|---|
title | String | Preview pane title |
text | String | Main content text |
icon | NerwIcon? | Optional icon. Use .none to hide completely. |
primaryActionName | String? | Function called when primary button clicked |
secondaryActionName | String? | Function called when secondary button clicked |
titleFontSize | CGFloat? | Custom font size for the title. Set to 0 to completely hide the title. |
textFontSize | CGFloat? | Custom font size for the text. Set to 0 to completely hide the text. |
courtesyText | String? | Small attribution text in bottom right |
courtesyIcon | NerwIcon? | 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:
| Parameter | Type | Default | Description |
|---|---|---|---|
content | String | - | Message text |
level | String | ”info” | Severity: “info”, “warn”, or “error” |
progressive | Bool | false | Shows spinner; prevents auto-dismiss |
id | String? | nil | Identifier 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:
- Reads JSON input from stdin
- Parses the
typefield (“query” or “action”) - For “query”: calls
ext.query(input:)and prints serialized results - For “action”: calls
ext.perform(action:)and prints pending commands - 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:
- Compiles your
main.swiftinto a temporary binary. - Runs the binary with a mock
queryinput. - 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())
Full Featured Extension
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:
| Property | Type | Required | Description |
|---|---|---|---|
enabled | boolean | Yes | Must be true to declare daemon capability |
description | string | Yes | Shown to the user in the approval dialog |
memoryLimit | number | No | RSS 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
}
| Method | Called When | Notes |
|---|---|---|
onStart(context:) | Daemon first connects to host | Set up indexes, file watchers, timers |
onQuery(input:) | User query matches this extension’s trigger | Return results from your in-memory index |
onAction(input:) | User executes an action | Use Nerw.open(), Nerw.copy(), etc. as normal |
onStop() | Graceful shutdown or host quit | Persist 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,
onQueryis called instead ofNerwExtension.query. When it is not running (e.g., not yet approved), Nerw falls back to spawning a one-shot process and callingNerwExtension.query.
Resource Limits
| Constraint | Default | Maximum |
|---|---|---|
| Memory (RSS) | 128 MB | 256 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’squery()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>.nerwin the current folder. - Includes:
manifest.json,main.swift, and any other assets (like.pngicons) 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)