Quil supports custom pane types via TOML plugin files. This document covers every configuration option, constraint, and behavior so you can create your own plugins.
Plugins define how panes are spawned, persisted, and restored. Each plugin is a single .toml file placed in the plugins directory.
| Item | Details |
|---|---|
| File location | ~/.quil/plugins/*.toml (or $QUIL_HOME/plugins/) |
| Loading | On daemon startup; hot reload via F1 → Plugins → Reload |
| Defaults | 4 built-in TOML plugins (claude-code, opencode, ssh, stripe) are written on first run and never overwritten — your edits are preserved across upgrades |
| Built-in | The terminal plugin is defined in Go (not TOML) because it requires runtime shell detection |
Save this as ~/.quil/plugins/htop.toml:
[plugin]
name = "htop"
display_name = "System Monitor"
category = "tools"
description = "Interactive process viewer"
[command]
cmd = "htop"Then press F1 → Plugins → Reload (or restart the daemon). Open with Ctrl+N → Tools → System Monitor.
[plugin]
name = "my-tool"
display_name = "My Tool"
category = "tools"
description = "A short description of what this plugin does"
schema_version = 1| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | Yes | — | Unique plugin identifier. Should match the filename (e.g., my-tool.toml → name = "my-tool"). Used internally for pane type tracking. |
display_name |
string | No | Value of name |
Human-readable label shown in the Ctrl+N creation dialog and pane borders. |
category |
string | No | "tools" |
Groups the plugin in the creation dialog. |
description |
string | No | "" |
One-line description shown in plugin lists. |
schema_version |
int | No | 0 |
Plugin TOML schema version. Built-in plugins shipped via defaults/*.toml carry an embedded schema_version; on startup, Quil compares your on-disk file's value with the embedded default and — if yours is older — opens a side-by-side migration dialog so you can merge new defaults without losing edits. Custom plugins: set schema_version = 1 and bump it whenever you publish a breaking schema change so consumers of your plugin get a prompt. Omitting the field is treated as 0 (legacy) and the migration check is skipped. |
| Value | Label in UI | Use for |
|---|---|---|
"terminal" |
Terminal | Shell-based plugins |
"ai" |
AI Assistant | AI coding tools, chat interfaces |
"tools" |
Tools | Utilities, monitors, webhook listeners |
"remote" |
Remote | SSH, remote connections |
Categories control display order in the Ctrl+N dialog: Terminal → AI Assistant → Tools → Remote.
[command]
cmd = "ssh"
args = ["-o", "ServerAliveInterval=60"]
env = ["TERM=xterm-256color"]
detect = "ssh -V"
arg_template = ["-p", "{port}", "{user}@{host}"]| Field | Type | Required | Default | Description |
|---|---|---|---|---|
cmd |
string | Yes | — | Binary name or absolute path. Resolved via PATH at runtime (exec.LookPath). |
path |
string | No | "" |
Explicit absolute path to the binary. If set, bypasses PATH lookup. Useful for tools installed in non-standard locations or when Quil is launched from Explorer on Windows with an incomplete PATH. Detection order: path → exec.LookPath(cmd) → searchBinary fallback (which scans ~/.local/bin everywhere and the User PATH on Windows). |
args |
string[] | No | [] |
Default arguments passed every time the plugin is launched. Overridden when instance-specific args are provided. |
env |
string[] | No | [] |
Environment variables as KEY=VALUE pairs. Merged into the PTY process environment. For claude-code and opencode panes Quil also injects QUIL_PANE_ID=<paneID> (and QUIL_HOME=<dir> plus OPENCODE_CONFIG_CONTENT=… for opencode) so their respective session-id hooks can attribute writes to a specific pane; custom plugins that wrap those tools can read these from their child process and assume they are set. |
detect |
string | No | Value of cmd |
Command used to check if the tool is installed. Only the first word is used for PATH lookup (e.g., "ssh -V" checks for ssh). |
shell_integration |
bool | No | false |
Reserved for the built-in terminal plugin. Injects OSC 7 directory tracking hooks. Has no effect in user TOML plugins. |
arg_template |
string[] | No | [] |
Template arguments with {placeholder} tokens. Expanded from form field values when creating an instance. See Template Expansion. |
prompts_cwd |
bool | No | false |
If true, the pane setup dialog asks the user for a working directory when creating the pane. Pre-filled with the active pane's CWD (tracked via OSC 7). Empty input falls back to the daemon's os.Getwd(). See Pane Setup Dialog. |
raw_keys |
string[] | No | [] |
List of key strings (e.g. ["shift+tab"]) that bypass Quil's global shortcut layer for panes of this type and are forwarded directly to the PTY. Capped at 64 entries. Single-rune printable entries (e.g. ["a"]) shadow ordinary typing for the entire pane and are warned about in the daemon log. The default plugins do not opt in — Tab and Shift+Tab reach the PTY naturally because pane navigation lives on Alt+Arrow. The mechanism is available for custom plugins that need to override some other global shortcut. |
Define user-fillable fields for the instance creation dialog (Ctrl+N → select plugin → fill form).
[[command.form_fields]]
name = "host"
label = "Hostname"
required = true
[[command.form_fields]]
name = "port"
label = "Port"
default = "22"| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | Yes | — | Placeholder key used in arg_template expansion. Must be unique within the plugin. |
label |
string | No | "" |
Display label shown next to the input field. |
required |
bool | No | false |
If true, the form cannot be submitted until this field has a value. Shown with * in the UI. |
default |
string | No | "" |
Pre-filled value shown in the input field. |
If a plugin defines no form_fields, the instance creation step is skipped — the pane is created directly with the plugin's default args.
The arg_template field supports {fieldname} placeholders that are replaced with form field values at pane creation time.
Example:
arg_template = ["-p", "{port}", "{user}@{host}"]With form values port = "2222", user = "admin", host = "example.com":
Result: ["-p", "2222", "admin@example.com"]
These expanded args become the pane's InstanceArgs — they override the plugin's default args and are saved for rerun resume strategy.
Declare boolean flags shown in the pane setup dialog. Each toggle has args that are appended to the spawn command when the user enables it. Toggle state is per pane, captured at creation time, and persisted across daemon restarts via the pane's InstanceArgs.
By default toggles render as independent checkboxes. Assigning a shared group value to two or more toggles turns them into a mutually-exclusive set — rendered as radio buttons in the dialog, enabling one automatically disables the others in the group. All members of the group may still be OFF (a valid "pick none" state).
# Independent checkbox
[[command.toggles]]
name = "verbose"
label = "Verbose output"
args_when_on = ["-v"]
default = false
# Mutually-exclusive group: selecting one disables the other.
[[command.toggles]]
name = "dangerously_skip_permissions"
label = "Dangerously skip permissions (unattended mode — no confirmations)"
args_when_on = ["--dangerously-skip-permissions"]
default = false
group = "permission_mode"
[[command.toggles]]
name = "enable_auto_mode"
label = "Enable auto mode (safer alternative to skipping permissions)"
args_when_on = ["--enable-auto-mode"]
default = false
group = "permission_mode"| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | Yes | — | Stable identifier for the toggle (used for future addressability). |
label |
string | Yes | — | Text shown next to the control in the setup dialog. |
args_when_on |
string[] | No | [] |
Arguments appended to the spawn command when the user enables this toggle. Combined with any other enabled toggles' args, then appended after InstanceArgs. |
default |
bool | No | false |
Initial state in the dialog. Users who always want a toggle on can set this to true. Within a group, if multiple members declare default = true only the last one in declaration order stays on — the invariant is preserved from the first render. |
group |
string | No | "" |
Non-empty value marks this toggle as part of a mutually-exclusive radio group. Toggles that share the same group value cannot be enabled simultaneously; enabling one disables the others. Empty string = independent checkbox (default). |
If a plugin declares any toggles (or sets prompts_cwd = true), the setup dialog is shown automatically after plugin selection. The dialog is skipped entirely when neither opt-in is set — existing plugins (terminal, ssh, stripe) are unaffected.
When a plugin opts in via prompts_cwd = true or [[command.toggles]], Quil inserts a setup step between plugin selection and split-direction selection. The dialog renders:
- Working directory text input (only if
prompts_cwd = true), pre-filled with the active pane's CWD. Validated on Enter: the path is trimmed, stripped of surrounding quotes (to accept WindowsCopy as pathoutput),~is expanded, then verified viaos.Statas an existing directory. Empty input is accepted and means "use daemon default". - One checkbox per toggle, in declaration order.
- Continue button.
Navigation: Tab / Shift+Tab / ↑ / ↓ cycle fields. Space flips the focused checkbox. Enter validates and submits. Esc unwinds — back to the instance form if the plugin also has form_fields, otherwise back to the plugin picker. Ctrl+V pastes into the CWD field.
Controls how panes survive daemon restarts.
[persistence]
strategy = "rerun"
ghost_buffer = true
start_args = ["--session-id", "{session_id}"]
resume_args = ["--resume", "{session_id}"]| Field | Type | Required | Default | Description |
|---|---|---|---|---|
strategy |
string | No | "" (none) |
Resume mechanism. See Strategy Reference. |
ghost_buffer |
bool | No | true |
If true, PTY output is saved to disk and replayed on reconnect (shows previous terminal content with a dimmed "restored" label). Set to false for TUI apps that manage their own display. |
start_args |
string[] | No | [] |
Template arguments appended on fresh pane creation. {key} placeholders expanded from plugin state (e.g., generated UUIDs). Used with preassign_id strategy. |
resume_args |
string[] | No | [] |
Template arguments used when restoring a pane after daemon restart. {key} placeholders expanded from previously scraped state. If any placeholder cannot be resolved, the pane starts fresh instead. |
Extract state values from PTY output using regex patterns. Scraped values are persisted to disk and available for resume arg expansion.
[[persistence.scrape]]
name = "session_id"
pattern = 'Session ID: ([a-f0-9-]+)'| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | Yes | — | Key name for the scraped value (e.g., "session_id"). Used as {name} in start_args/resume_args templates. |
pattern |
string | Yes | — | Go regex pattern with exactly one capture group. The first submatch is stored as the value. Matched continuously against all PTY output. |
How scraping works at runtime:
- Every chunk of PTY output is tested against all scrape patterns
- When a pattern matches, the captured value is stored in the pane's plugin state
- Plugin state is persisted to
workspace.jsonon each snapshot - On daemon restart, scraped values are loaded and available for
resume_argsexpansion
Regex tips:
- Use raw TOML strings (
'...') to avoid escaping backslashes - Only the first capture group is used — keep patterns simple
- Invalid patterns are logged at startup but don't prevent the plugin from loading
- Patterns are pre-compiled for performance (not re-compiled on each match)
| Strategy | On fresh start | On restore after daemon restart | Best for |
|---|---|---|---|
"" / "none" |
Start cmd + args |
Start cmd + args (no state) |
Stateless tools, system monitors |
"cwd_only" |
Start in current directory | Restore saved CWD, start fresh shell | Terminal shells (built-in only) |
"rerun" |
Start cmd + instanceArgs |
Rerun cmd + saved instanceArgs |
SSH connections, Stripe listeners |
"preassign_id" |
Generate UUID, store in plugin state, expand start_args |
Expand resume_args using saved state |
AI tools (Claude Code) |
"session_scrape" |
Start normally | Expand resume_args from scraped output values |
Tools that emit session tokens |
Strategy details:
"none" / "" — No persistence. The pane is recreated from scratch on every daemon restart. Suitable for disposable tools like system monitors.
"cwd_only" — Reserved for the built-in terminal plugin. Saves the current working directory (tracked via OSC 7 shell integration) and restores it on respawn. The shell itself starts fresh.
"rerun" — The simplest persistence for interactive tools. On restore, the exact same command and arguments are re-executed. The user's saved instance args (from the form) are preserved. Example: an SSH connection to admin@prod-server:2222 is re-established automatically.
"preassign_id" — For tools that support session IDs. On first launch:
- Quil generates a UUID
- Stores it as
session_idin plugin state - Expands
start_args(e.g.,["--session-id", "{session_id}"]) - Appends expanded args to the command
On restore:
- Loads saved
session_idfrom disk - Expands
resume_args(e.g.,["--resume", "{session_id}"]) - Uses expanded args instead of
start_args
"session_scrape" — For tools that emit session tokens in their output. Scrape patterns continuously match PTY output and store extracted values. On restore, resume_args are expanded with the scraped values. If scraping never captured the required values, the pane starts fresh.
[display]
border_color = "blue"
dialog_width = 60| Field | Type | Required | Default | Description |
|---|---|---|---|---|
border_color |
string | No | "" |
Lipgloss terminal color for the pane border (e.g., "blue", "cyan", "#ff5733"). |
dialog_width |
int | No | 50 |
Width (in characters) of the instance creation form dialog. Increase for plugins with long field labels. |
Ship the plugin with ready-to-use configurations. Users see these as selectable options in the Ctrl+N dialog.
[[instances]]
name = "staging"
display_name = "Staging Server"
args = ["-p", "2222", "deploy@staging.example.com"]
[[instances]]
name = "production"
display_name = "Production"
args = ["admin@prod.example.com"]
env = ["SSH_AUTH_SOCK=/run/ssh-agent.sock"]| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | Yes | — | Unique identifier for this instance within the plugin. |
display_name |
string | No | "" |
Human-readable label shown in the instance selection list. |
args |
string[] | No | [] |
Instance-specific command arguments. Override the plugin's default args. |
env |
string[] | No | [] |
Instance-specific environment variables (KEY=VALUE). |
Note: Users can also create and save instances at runtime via the Ctrl+N form. These are stored in ~/.quil/instances.json and appear alongside TOML-defined instances.
Match PTY output against regex patterns to show help dialogs or log errors.
[[error_handlers]]
pattern = 'Permission denied \(publickey'
title = "SSH Authentication Failed"
message = """
SSH key not configured for {host}.
1. Generate: ssh-keygen -t ed25519
2. Copy key: ssh-copy-id {user}@{host}
3. Retry the connection"""
action = "dialog"
[[error_handlers]]
pattern = "Connection refused|No route to host"
title = "Connection Failed"
message = "Cannot reach {host}. Check that the server is running."
action = "dialog"| Field | Type | Required | Default | Description |
|---|---|---|---|---|
pattern |
string | Yes | — | Go regex matched against PTY output. Use | alternation for multiple patterns. Use raw TOML strings ('...') to avoid double-escaping. |
title |
string | No | "" |
Title shown in the error dialog. |
message |
string | No | "" |
Body text of the error dialog. Supports {host}, {user}, {port} placeholder expansion from the pane's instance args. Use TOML multi-line strings ("""...""") for longer messages. |
action |
string | No | "log" |
What to do when the pattern matches. |
| Value | Behavior |
|---|---|
"dialog" |
Show a modal error dialog in the TUI with title and message. |
"log" |
Log the match to the daemon log file (~/.quil/quild.log). No visible UI. |
Invalid action values are logged as a warning and treated as "log".
Error messages support these placeholders, extracted from the pane's instance args:
| Placeholder | Source | Example |
|---|---|---|
{host} |
Hostname from user@host pattern in args |
example.com |
{user} |
Username from user@host pattern in args |
admin |
{port} |
Port from -p <port> flag or host:port in args |
2222 |
Push events to Quil's notification center (M12) when a pattern matches PTY output. Unlike error handlers — which fire a modal dialog — notification handlers add an entry to the non-modal sidebar (Alt+N to toggle, F3 to focus).
[[notification_handlers]]
pattern = '✔ Build successful'
title = "Build OK"
severity = "info"
[[notification_handlers]]
pattern = '✗ Build failed|Error: '
title = "Build failed"
severity = "error"| Field | Type | Required | Default | Description |
|---|---|---|---|---|
pattern |
string | Yes | — | Go regex matched against PTY output. Same compilation rules as error handlers. |
title |
string | No | "" |
Title shown in the notification sidebar entry. |
severity |
string | No | "info" |
One of "info", "warning", "error". Drives the colour of the pane name in the sidebar. Unknown values are warned about and downgraded to "info". |
Context-aware idle detection: when a pane has produced no PTY output for ~5 seconds, the daemon strips ANSI from the last 4 KB of the pane's ring buffer and matches it against this plugin's idle patterns. A match emits a notification (same path as notification_handlers). 30-second cooldown per pane prevents floods.
[[idle_handlers]]
pattern = '\? .*\(Y/n\)'
title = "Waiting for confirmation"
severity = "warning"
[[idle_handlers]]
pattern = 'Password:|password:'
title = "Password prompt"
severity = "warning"| Field | Type | Required | Default | Description |
|---|---|---|---|---|
pattern |
string | Yes | — | Go regex matched against the ANSI-stripped tail (last 4 KB) of the pane's output buffer. |
title |
string | No | "" |
Title shown in the notification sidebar. |
severity |
string | No | "info" |
Same severity values as notification handlers. |
The default plugins ship with sensible idle handlers — terminal detects shell prompts that look like questions, claude-code detects "Press Enter to continue" prompts, ssh detects sudo password prompts.
TOML plugin files are fully portable — the same .toml works on Linux, macOS, and Windows without modification.
| Aspect | Behavior |
|---|---|
| Binary resolution | cmd and detect are resolved via exec.LookPath at runtime. Use the binary name (e.g., "ssh"), not an absolute path. |
Windows .exe |
Not needed — Go automatically appends .exe during PATH lookup on Windows. |
| Detection | detect runs the first word through PATH lookup (e.g., "ssh -V" checks if ssh is on PATH). |
| Environment vars | env entries use KEY=VALUE format on all platforms. |
| PTY | Unix uses creack/pty, Windows uses ConPTY. Transparent to plugins. |
| Shell integration | Reserved for the built-in terminal plugin. Handles bash, zsh, PowerShell, and fish automatically. |
These rules are enforced when loading a TOML plugin file:
| Rule | Behavior on violation |
|---|---|
name is required |
Plugin file skipped with error log |
cmd is required |
Plugin file skipped with error log |
strategy must be a valid value |
Plugin file skipped with error log |
Invalid regex in pattern |
Pattern skipped with warning log; plugin still loads |
Invalid action value |
Defaults to "log" with warning log |
Missing display_name |
Defaults to name |
Missing category |
Defaults to "tools" |
Missing ghost_buffer |
Defaults to true |
Daemon startup
├── Write default TOML files (if missing)
├── Load built-in terminal plugin (Go)
├── Load all *.toml from ~/.quil/plugins/
│ ├── Parse TOML → validate → compile regex patterns
│ └── TOML plugins override built-ins with same name
├── Detect availability (exec.LookPath on each binary)
└── Restore workspace (load panes, ghost buffers, plugin state)
User creates pane (Ctrl+N)
├── Dialog: Category → Plugin → Instance/Form → Split Direction
├── Expand arg_template from form field values
└── Send to daemon: type, instanceName, instanceArgs
Daemon spawns pane
├── Look up plugin in registry
├── Apply instance args (override default args)
├── If preassign_id: generate UUID, expand start_args
├── Merge plugin env vars into PTY process
├── Resolve cmd to absolute path via exec.LookPath
└── Start PTY process → begin output streaming
Pane running
├── PTY output → scrape patterns → store matches in plugin state
├── PTY output → error patterns → show dialog if matched
└── Periodic snapshots save plugin state to disk
Daemon restart
├── Load workspace + plugin state from disk
├── For each pane, dispatch by strategy:
│ ├── rerun → same cmd + saved args
│ ├── preassign_id → expand resume_args from state
│ ├── session_scrape → expand resume_args from scraped values
│ └── none → start fresh
└── Replay ghost buffers to reconnecting clients
Hot reload (F1 → Plugins → Reload)
├── Re-read all *.toml files
├── Re-compile regex patterns
└── Re-detect availability
A system monitor with no persistence or forms — just run a binary.
[plugin]
name = "htop"
display_name = "System Monitor"
category = "tools"
description = "Interactive process viewer"
[command]
cmd = "htop"A full-featured plugin with instance forms, error handlers, and persistence.
[plugin]
name = "ssh"
display_name = "SSH"
category = "remote"
description = "Remote SSH connection"
[command]
cmd = "ssh"
detect = "ssh -V"
arg_template = ["-p", "{port}", "{user}@{host}"]
# --- Form fields shown when creating a new instance ---
[[command.form_fields]]
name = "name"
label = "Connection Name"
required = true
[[command.form_fields]]
name = "host"
label = "Hostname"
required = true
[[command.form_fields]]
name = "user"
label = "Username"
required = true
[[command.form_fields]]
name = "port"
label = "Port"
default = "22"
[[command.form_fields]]
name = "description"
label = "Description"
# --- Visual settings ---
[display]
dialog_width = 60
# --- Resume after daemon restart ---
[persistence]
strategy = "rerun"
ghost_buffer = true
# --- Help dialogs for common errors ---
[[error_handlers]]
pattern = 'Permission denied \(publickey'
title = "SSH Authentication Failed"
message = """
SSH key not configured for {host}.
1. Generate: ssh-keygen -t ed25519
2. Copy key: ssh-copy-id {user}@{host}
3. Retry the connection"""
action = "dialog"
[[error_handlers]]
pattern = "Host key verification failed"
title = "Unknown Host"
message = "Run: ssh-keyscan {host} >> ~/.ssh/known_hosts"
action = "dialog"
[[error_handlers]]
pattern = "Connection refused|No route to host"
title = "Connection Failed"
message = "Cannot reach {host}. Check that the server is running and the address is correct."
action = "dialog"
# --- Pre-configured servers ---
[[instances]]
name = "staging"
display_name = "Staging Server"
args = ["-p", "2222", "deploy@staging.example.com"]
[[instances]]
name = "production"
display_name = "Production"
args = ["admin@prod.example.com"]A plugin that generates a session ID, passes it to the tool, and resumes the session after restart.
[plugin]
name = "my-ai-tool"
display_name = "My AI Tool"
category = "ai"
description = "AI assistant with session persistence"
[command]
cmd = "my-ai-tool"
detect = "my-ai-tool --version"
[persistence]
strategy = "preassign_id"
ghost_buffer = false
start_args = ["--session-id", "{session_id}"]
resume_args = ["--resume", "{session_id}"]
[[error_handlers]]
pattern = '(?i)API.?key.*not (found|set)|authentication.*failed'
title = "Authentication Required"
message = "Set MY_AI_TOOL_API_KEY in your environment."
action = "dialog"How it works:
- First launch: Quil generates a UUID (e.g.,
a1b2c3d4-...), stores it assession_id, and starts:my-ai-tool --session-id a1b2c3d4-... - Daemon restart: Quil loads the saved
session_idfrom disk and starts:my-ai-tool --resume a1b2c3d4-... ghost_buffer = false: The tool's TUI output is not saved/replayed (the tool redraws its own interface on resume).
A simple tool with default args, a form for URL override, and auth error handling.
[plugin]
name = "stripe"
display_name = "Stripe"
category = "tools"
description = "Stripe webhook listener"
[command]
cmd = "stripe"
args = ["listen"]
detect = "stripe --version"
arg_template = ["listen", "--forward-to", "{url}"]
[[command.form_fields]]
name = "name"
label = "Name"
required = true
[[command.form_fields]]
name = "url"
label = "Forward URL"
required = true
default = "http://localhost:8080/webhook"
[[command.form_fields]]
name = "description"
label = "Description"
[persistence]
strategy = "rerun"
ghost_buffer = true
[[error_handlers]]
pattern = "not logged in|login required"
title = "Stripe Authentication Required"
message = "Run 'stripe login' in a terminal pane first."
action = "dialog"- Raw strings (
'...'): No escape processing. Use for regex patterns:'Permission denied \(publickey' - Multi-line strings (
"""..."""): For long error messages with line breaks. - Array of tables (
[[section]]): Each[[command.form_fields]]or[[error_handlers]]block defines one entry in the array. - Boolean values:
true/false(lowercase, no quotes). - Comments: Lines starting with
#are ignored.