Extending AgentDE
Two ways to add to AgentDE:
| Kind | What it is | Can do | Needs a build |
|---|---|---|---|
| Native extension | A Swift package with a class conforming to AgentDEExtension |
Every hook below: panels, widgets, tab environment, menus, settings, tickets, links, themes | Yes: listed in extensions.json, compiled into the app |
| JSON plugin | A folder with a plugin.json in ~/.config/agentde/plugins/ |
Widgets (an HTML page or a command’s output), viewer links, themes, palettes, agents | No: read at launch |
Contents: Native extensions · Hooks · The host · Saving data · Example · Adding it to a build · Widgets · Ticket providers · Link rules · Themes and palettes · JSON plugins
Native extensions
The API is the AgentDEKit library (Sources/AgentDEKit/):
| File | Holds |
|---|---|
Extension.swift |
AgentDEExtension (the hooks), AgentDEHost (what the app offers), TabInfo, GroupInfo, ExtensionAction, LinkRule, ThemeDefinition, PaletteDefinition, TicketInfo, TicketProvider |
Theme.swift |
Theme (every color the app paints) and the \.theme environment value |
TicketRow.swift |
TicketRow: one ticket on a line, for lists of tickets |
Components.swift, Hint.swift, FileIcons.swift |
Shared views: .hint(_:) hover labels, AskRing, file icons and others |
DebugLog.swift |
DebugLog.log(_:_:): writes to the app’s debug log |
AppBrand.swift |
The build’s name (AppBrand.name) and folder (AppBrand.configDirectory) |
Worktrees.swift, SafeID.swift, Tabs.swift, PluginManifest.swift |
Git worktrees, id validation for shell commands, TabKind and AgentState, the JSON plugin format |
An extension is a class with an id (stable: it namespaces the extension’s saved data) and a name (shown in Settings). Every hook has a default that does nothing, so an extension implements only what it adds. The app creates extensions at launch, in extensions.json order, calls start(host:) after saved state is loaded and stop() at quit. Hooks are called on the main thread.
Hooks
| Hook | Called or read |
|---|---|
start(host:) |
Once at launch, after saved state is loaded; keep the host |
stop() |
At quit; save anything that’s behind |
launchEnvironment(for:) |
When a tab’s shell starts: environment variables to add |
agentSessionsChanged() |
After the app re-reads which agent session each tab runs (Claude started, /clear, a resume) |
liveSessionsChanged(_:) |
After each registry poll (once a second): the agent sessions running anywhere on the Mac |
agentWentIdle(_:) |
A tab’s agent finished a turn or is waiting |
tabClosing(_:keepAgent:) |
A tab is closing; with keepAgent its background agent keeps running |
backgroundSessionOpened(_:session:forkParent:) |
The app opened a tab for a background session (/fork, claude --bg); forkParent is the session it was forked from, when known |
tabForked(_:from:) |
The user forked a tab’s Claude session into a new tab (right-click → Fork Session). The new tab has no agentSession until Claude gives the fork its own id |
wantsAttention(_:) |
Whether a tab gets a soft attention ring without its agent being stopped |
sidePanelTabs(for:) |
Tabs in the side panel beside a terminal tab (SidePanelTab: id, title, SF Symbol, view). They come before the core’s Files and Changes, in extension order |
sidebarFooter() |
The area at the bottom of the sidebar; the first extension that returns one owns it |
windowOverlay() |
A view drawn over the whole window that passes clicks through |
viewerAccessory(for:item:) |
Controls in the viewer’s header for the file path or link it shows |
title(forLink:in:) |
A better title for a link shown in the viewer |
newMenuItems(for:) |
Items for a group’s right-click New menu (ExtensionAction: title, SF Symbol, action) |
settingsView() |
The extension’s section in Settings → Extensions |
linkRules |
Links that open in the viewer rather than the browser; read once at launch |
themes |
Themes added to Settings → Look; read once at launch |
palettes |
Group color palettes added to Settings → Look; read once at launch |
ticketProviders |
Trackers whose tickets this extension can look up |
agents |
Terminal agents (AgentDefinition: id, name, command) added to the New menus beside Claude and Codex. Their tabs run the command; their status dot comes from their own hooks (see Agent status) |
TabInfo is a tab as extensions see it: id, groupID, kind (claude, codex, shell, command, files), folder, groupFolder, title, agentSession (Claude’s session id or Codex’s thread id), agentPresent, state (none, working, needsYou, doneUnseen), viewerItem, and isAgent.
The host
AgentDEHost is the app’s own observable state, so a SwiftUI view that reads it updates when it changes. Main thread only.
| Member | Gives or does |
|---|---|
allTabs, allGroups, tabInfo(_:), activeTabID |
Tabs and groups, in sidebar order |
liveSessions |
Agent sessions running anywhere on the Mac |
rateLimitsUsed |
Claude’s 5-hour and 7-day limits used, 0–100, when known |
animations |
Whether the user wants motion (Settings → Fun, and Reduce Motion) |
extensions |
The loaded extensions, for working with each other: host.extensions.compactMap { $0 as? SomeProtocol } |
selectTab(_:focus:) |
Selects a tab |
type(_:into:) |
Types text into a tab’s terminal, as if pasted; "\r" presses Return |
openInViewer(_:tab:select:) |
Shows a file path or link in a tab’s viewer |
openTab(kind:in:folder:title:) |
Opens a new tab and returns its id |
value, setValue |
Per-tab values (below) |
settingsData, setSettingsData, settings(_:for:fallback:), saveSettings(_:for:) |
The extension’s settings (below) |
showProblem(_:_:) |
A modal alert with one OK button |
configDirectory |
The app’s folder under ~/.config (~/.config/agentde, or a brand’s) |
Per-tab values and settings
| Data | API | Saved as |
|---|---|---|
| Small strings kept with a tab | host.value(key, tab:, for: self), host.setValue(value, key, tab:, for: self); nil removes |
extensionData["<extension id>.<key>"] on the tab in state.json |
| The extension’s settings | host.settings(T.self, for: self, fallback:) to read, host.saveSettings(value, for: self) to write, for any Codable type |
JSON under settings.extensionSettings["<extension id>"] in state.json |
Add new settings fields with defaults, so older saved settings still decode.
An example extension
A package agentde-hello with one library target, depending on AgentDEKit:
// Sources/AgentDEHello/HelloExtension.swift
import AgentDEKit
import SwiftUI
struct HelloSettings: Codable { var greeting = "Hello" }
public final class HelloExtension: AgentDEExtension {
public let id = "hello"
public let name = "Hello"
private var host: AgentDEHost?
public init() {}
public func start(host: AgentDEHost) { self.host = host }
// Every tab's shell gets HELLO_TAB.
public func launchEnvironment(for tab: TabInfo) -> [String: String] { ["HELLO_TAB": tab.id.uuidString] }
// Group → New → Say hello: opens a shell tab and greets.
public func newMenuItems(for group: GroupInfo) -> [ExtensionAction] {
[ExtensionAction("Say hello", symbol: "hand.wave") { [weak self] in
guard let self, let host, let tab = host.openTab(kind: .shell, in: group.id, folder: nil, title: "hello") else { return }
let greeting = host.settings(HelloSettings.self, for: self, fallback: HelloSettings()).greeting
host.type("echo \(greeting)\r", into: tab)
}]
}
// Wiki pages open in the viewer.
public var linkRules: [LinkRule] { [LinkRule(pattern: #"https://wiki\.example\.com/page/\w+"#, marker: "wiki.example.com/page/")] }
}
// Package.swift
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "agentde-hello",
platforms: [.macOS(.v14)],
products: [.library(name: "AgentDEHello", targets: ["AgentDEHello"])],
dependencies: [.package(path: "../agentde")],
targets: [.target(name: "AgentDEHello", dependencies: [.product(name: "AgentDEKit", package: "agentde")])]
)
Adding it to a build
Put it beside agentde/ (or give its repo, and the build clones it there) and add an entry to agentde/extensions.json (or a copy passed with make EXTENSIONS=…):
{
"extensions": [
{ "package": "../agentde-widgets", "product": "AgentDEWidgets", "create": "WidgetsExtension()" },
{ "package": "../agentde-hello", "repo": "https://github.com/you/agentde-hello", "product": "AgentDEHello", "create": "HelloExtension()" }
]
}
| Field | What |
|---|---|
package |
The package’s folder, relative to the extensions file |
repo |
Optional: a git URL to clone the package from when the folder isn’t there. Sibling packages it depends on (.package(path: "../x")) are cloned from the same owner |
product |
Its library product; also the module the generated main.swift imports |
create |
A Swift expression that creates the extension: an initializer call |
helpers |
Optional: [{"package": …, "repo": …, "product": …}], executables built and copied into Contents/MacOS |
scripts/assemble.py turns the file into a small package (.build/app-package) whose main.swift calls AgentDEMain.run(extensions: [...]), and scripts/build-app.sh builds and bundles it. A workspace that builds its own app instead calls AgentDEMain.run(extensions:) from its own executable and sets APP_PACKAGE, APP_PRODUCT and APP_HELPERS for build-app.sh.
Widgets
The sidebar widget spot belongs to the Widgets extension (agentde-widgets). Another extension adds widgets by depending on AgentDEWidgets and conforming to WidgetProvider:
import AgentDEKit
import AgentDEWidgets
import SwiftUI
extension HelloExtension: WidgetProvider {
public var widgets: [WidgetDefinition] {
[WidgetDefinition(id: "hello.clock", name: "Clock") {
AnyView(WidgetCard { Text(Date.now, style: .time).font(.title) })
}]
}
}
| Piece | Use |
|---|---|
WidgetDefinition(id:name:view:) |
id is stable: it remembers which widget is shown; prefix it with your extension’s id |
WidgetCard |
The plain frame text widgets share; fills the height the user dragged the widget to |
.widgetListHeight(_:) |
A scrolling list: up to the given height by itself, or the dragged height |
WidgetMessage(text:button:action:) |
An empty or setup state: a line of text and an optional button |
\.widgetHeight |
The dragged height, or nil for the natural height |
The menu lists widgets from every WidgetProvider in load order.
Ticket providers
A workstream’s tickets are tracker-neutral: a key (ENG-123), owner/repo#12, or a link to Linear, Jira, GitHub, GitLab or any https page. A TicketProvider looks them up so the panel can show state, priority and title.
| Member | What |
|---|---|
tracker |
Matches the tracker a ticket’s link names: linear, jira, github, gitlab |
name |
Shown to the user |
isReady |
Signed in and able to look things up |
claims(identifier:) |
Whether to look up a ticket linked by key alone, with no tracker known |
lookUp(_:completion:) |
Looks tickets up; call back on the main thread with the TicketInfos found |
myTickets(completion:) |
The user’s own open tickets, for picking one to link |
TicketInfo carries identifier, title, url, and optionally state, stateType, colorHex and priority (1 urgent to 4 low, 0 none). TicketRow draws one. Return providers from ticketProviders; agentde-linear is a complete example.
Link rules
A LinkRule makes matching links open in the tab’s viewer instead of the browser, and (with the Workstreams extension) count as artifacts in the panel.
| Field | What |
|---|---|
pattern |
A regular expression matching the whole link |
marker |
Text every such link contains, for skipping text quickly ("example.com/pages/") |
canonical |
Maps a link to one form per page (say, a raw link to its view page); nil when it names nothing. Default: the link itself |
openURL |
The address to load for a canonical link. Default: the link itself |
title |
A title from the link itself. Default: none |
Claude artifact links are built in.
Themes and palettes
| Type | Fields |
|---|---|
ThemeDefinition |
id, name, alwaysDark (ignores the light/dark setting), optional logo (an image shown in place of the app icon while the theme is on), and resolve: (dark) -> Theme |
PaletteDefinition |
id, name, colors: eight group colors as 0xRRGGBB |
Theme has window, sidebar, text, secondary, muted, border, hover, selected (SwiftUI colors), terminalBackground, terminalForeground, terminalSelection (NSColors), and an optional outline that outlines the active tab and group. Views read the current theme from @Environment(\.theme).
JSON plugins
A plugin is a folder in ~/.config/agentde/plugins/ (a renamed build uses its own data folder) holding a plugin.json. Plugins are read once at launch, in folder-name order; one whose plugin.json doesn’t parse is skipped and logged in the debug log. Every field but name is optional.
| Field | What |
|---|---|
name |
The plugin’s name |
widgets |
Sidebar widgets (needs the Widgets extension), each {id, name, page or command, every?} |
widgets[].page |
An HTML file inside the plugin’s folder, shown in a web view; it can read only its own folder |
widgets[].command |
A shell command, run with zsh -lc in the plugin’s folder; its output is shown (at most 4 KB; stopped after 10 seconds) |
widgets[].every |
Seconds between runs of command, or reloads of page; at least 5. Without it, a command runs once each time the widget is shown |
links |
Viewer links, each {pattern, marker} (see Link rules) |
themes |
Each {id, name, alwaysDark?, dark?, light?}. dark and light map Theme field names to "#RRGGBB" or "#RRGGBBAA"; a missing field takes the Claude theme’s color, and a missing light uses dark (and the other way round) |
palettes |
Each {id, name, colors}: colors as "#RRGGBB" |
agents |
Each {id, name, command}: a terminal agent in the New menus (tab strip +, a group’s New, the palette’s list). Its tabs run command through the login shell in the tab’s folder. Give it hooks that write $AGENTDE_STATUS_FILE for a status dot (Agent status) |
A plugin widget’s id in the menu is plugin.<id>. The example in examples/hello-plugin adds a page widget, a command widget, a link rule, a theme, a palette and an agent:
{
"name": "Hello",
"widgets": [
{ "id": "hello.page", "name": "Hello", "page": "widget.html" },
{ "id": "hello.disk", "name": "Disk", "command": "df -h / | tail -1 | awk '{print $4 \" free of \" $2}'", "every": 300 }
],
"links": [
{ "pattern": "https://gist\\.github\\.com/[\\w-]+/[0-9a-f]{20,}", "marker": "gist.github.com/" }
],
"themes": [
{ "id": "hello.dusk", "name": "Dusk (always dark)", "alwaysDark": true,
"dark": { "window": "#1B1B2F", "sidebar": "#16162A", "text": "#ECEAF6", "...": "..." } }
],
"palettes": [
{ "id": "hello.sea", "name": "Sea", "colors": ["#0B7285", "#1098AD", "#15AABF", "#3BC9DB", "#66D9E8", "#99E9F2", "#C5F6FA", "#495057"] }
],
"agents": [
{ "id": "hello.gemini", "name": "Gemini", "command": "gemini" }
]
}
To try it: cp -R examples/hello-plugin ~/.config/agentde/plugins/hello, restart AgentDE, and pick Hello in the sidebar widget’s menu.