AgentPlugin¶
Purpose¶
Agent plugins extend the embedded Claude agent. One AgentPlugin can contribute, when enabled:
a skill — a system-prompt snippet materialized as a
SKILL.mdfile in the per-session SDK plugin directory (viaget_system_prompt()), optionally with supplementary reference documents loaded lazily on demand (viaget_references_dir());a bag of MCP tools — an in-process MCP server assembled from
@tool-decorated callables (viacreate_tools()).
A plugin may provide either or both; one settings toggle controls a plugin’s prompt and tools together.
Base Class¶
from lightfall.plugins.agent_plugin import AgentPlugin
Class attribute |
Value |
|---|---|
|
|
|
|
Required Properties¶
@property
def name(self) -> str: ...
@property
def description(self) -> str: ...
name— unique plugin identifier, at most 64 characters, lowercase with hyphens/underscores. It is used as the manifest entry name, theSKILL.mdfrontmattername(underscores are converted to hyphens at materialization), the MCP server name (tools becomemcp__<name>__<tool_name>), and the settings-UI preference identifier.description— one-line description shown in the settings UI and used as theSKILL.mdfrontmatterdescription. Truncated to 1024 characters at materialization (an SDK limit); a warning is logged if truncation occurs.
Optional Properties¶
Property |
Default |
Purpose |
|---|---|---|
|
|
Human-readable name in the settings UI |
|
|
Settings-UI grouping; common values: |
|
|
Whether the plugin is active before the user touches the toggle |
|
|
Sort order (lower = first) in the settings UI and in session assembly |
Optional Methods¶
def get_system_prompt(self) -> str:
"""Return the SKILL.md body. Empty string = no skill contribution."""
return ""
def create_tools(self) -> list[Any]:
"""Return @tool-decorated callables. Empty = no MCP server contribution."""
return []
def get_references_dir(self) -> Path | None:
"""Optional package directory of supplementary docs, copied to
references/ next to the SKILL.md and loaded lazily by the SDK Skill tool."""
return None
Tools are created with the claude_agent_sdk.tool decorator, which takes a name, description, and JSON input_schema, and wraps an async function receiving the arguments dict. Tool results must use the MCP content format; the helpers in lightfall.plugins.agents._mcp_helpers (mcp_result, mcp_error) produce it. Tools that touch Qt objects must hop to the main thread (lightfall.claude._internal.threading.run_on_main_thread).
Lifecycle: how a session is assembled¶
Registration.
PluginLoaderinstantiates each manifest entry withtype_name="agent"and registers the instance withAgentRegistry(lightfall.ui.panels.claude.agent_registry).Enablement.
AgentRegistry.enabled_plugins()applies the user’s toggles from the Claude Tools settings page, returning plugins sorted bypriority. Semantics are opt-out: a plugin is enabled when its name is not in thedisabled_tool_pluginspreference and eitherenabled_by_defaultis true or its name is inforced_enabled_tool_plugins— so newly installed plugins take their declared default.Session assembly. At agent construction time (
QtClaudeAgent.__init__, implemented inlightfall.claude._session_assembly):A fresh temporary SDK plugin directory is created with a minimal
plugin.json.For each enabled plugin with a non-empty prompt,
materialize_skill()writesskills/<name>/SKILL.md(frontmattername+description, then the prompt body) and copiesget_references_dir()toskills/<name>/references/if provided. References are not injected into the prompt — the SDK’s Skill tool loads them on demand when the skill is invoked.For each enabled plugin with tools,
assemble_mcp_servers()callscreate_sdk_mcp_server(name=plugin.name, ...)and addsmcp__<plugin.name>__<tool_name>entries to the session’s allowed-tools list. These per-plugin servers are merged with the always-onqtserver (screenshot, widget tree, click, type, logs).
Teardown. The session plugin directory is temporary and rebuilt on the next agent construction. Toggling a plugin in settings therefore takes effect on the next agent session, not mid-conversation.
Complete Example¶
A plugin contributing both a skill prompt and one tool. The prompt-only pattern matches the built-in panel_design plugin (lightfall.plugins.agents.panel_design); the tool pattern matches device_tools.
"""Ring-status agent plugin."""
from __future__ import annotations
from typing import Any
from lightfall.plugins.agent_plugin import AgentPlugin
from lightfall.plugins.agents._mcp_helpers import mcp_result
class RingStatusAgent(AgentPlugin):
"""Gives Claude storage-ring context and a current-reading tool."""
@property
def name(self) -> str:
return "ring_status"
@property
def display_name(self) -> str:
return "Ring Status"
@property
def description(self) -> str:
return "Storage-ring status context and a beam-current tool"
@property
def category(self) -> str:
return "operations"
@property
def priority(self) -> int:
return 50
def get_system_prompt(self) -> str:
return (
"## Storage Ring Status\n\n"
"When the user asks about beam availability, check the ring "
"current with the ring_current tool before answering. "
"Top-off mode holds the current near 500 mA; values near zero "
"mean no beam."
)
def create_tools(self) -> list[Any]:
try:
from claude_agent_sdk import tool
except ImportError:
return []
@tool(
name="ring_current",
description="Read the current storage-ring beam current in mA",
input_schema={"type": "object", "properties": {}},
)
async def ring_current(args: dict) -> dict[str, Any]:
value = read_ring_current_somehow() # your data source
return mcp_result({"current_mA": value})
return [ring_current]
With this plugin enabled, the agent’s session contains a skills/ring_status/SKILL.md and the tool is callable as mcp__ring_status__ring_current.
Registration¶
Add a manifest entry (built-in manifest during development, or your package’s manifest for distribution — see External Packages):
PluginEntry(
type_name="agent",
name="ring_status",
import_path="my_beamline.agents.ring_status:RingStatusAgent",
),
Inter-agent messaging: the bus¶
Every QtClaudeAgent session (regardless of which AgentSpec is driving it) gets an
always-on bus SDK MCP server, separate from the per-plugin servers described above:
mcp__bus__send_message—{"to": "<agent name>", "message": "<text>"}. Delivers to another agent registered on the sharedAgentBus(lightfall.agents.bus.AgentBus), or returns an error result (with the current roster in the detail) iftoisn’t registered.mcp__bus__list_agents— no arguments. Returns each registered agent’sname,description, and currentbusystate, so an agent can check availability before sending.
Registration happens per session — a session’s bus_name is whatever name it was
registered under (duplicate names get a #2, #3, … suffix from AgentBus.register).
The tools read the sender’s name live at call time via a callable, not a captured
string, since a session’s bus name can be reassigned after registration.
Delivery policy: lightfall.on_message¶
Each AgentSpec (parsed from an agent definition’s lightfall: frontmatter block, see
lightfall/agents/spec.py) declares how incoming bus messages are handled by that
agent’s ClaudeSessionEndpoint (lightfall.claude.bus_endpoint.ClaudeSessionEndpoint):
lightfall:
on_message: queue # or: auto
"queue"(default) — every incoming message is queued and the UI is notified (on_queued) so a pending-message banner appears in the Claude panel. The user reviews and explicitly flushes it (the “Respond” action), which callsflush_pending()and submits the queued prompts as a turn."auto"— if the session is idle (is_busy()is false), the message is submitted immediately as a turn. If the session is busy (orsubmitrefuses, e.g. a race where busy-ness flips between the check and the call), the message is queued instead of dropped, and gets flushed the next time the caller decides the session is free (e.g. onquery_completedfor widget wiring).
The Claude panel also exposes a “Auto-respond to agent messages” checkable toggle
in its tune/settings popup menu (ClaudeAssistantWidget._on_toggle_auto_respond in
lightfall/claude/widget.py), which calls bus_endpoint.set_policy("auto"/"queue")
directly — this overrides the spec’s declared default for the running session without
editing the agent file.
Every delivered or queued message is rendered into the session as:
[Message from agent '<sender>']
<message text>
via format_bus_prompt() — this is the exact text submit receives, whether
delivered immediately (auto) or via flush_pending() (both policies).
Skill drafts & approval¶
Agents can propose new skills or revisions to existing ones via the draft_skill MCP tool
(mcp__skills__draft_skill). This tool takes three parameters:
name— skill name (lowercase alphanumeric, hyphens, underscores; 1-64 chars).description— one-line human-readable description (must not contain newlines; ≤1024 chars).body— skill body content (markdown).
Drafts are always inert — a human must review and approve them before they take effect. The tool returns the filesystem path where the draft was saved and indicates whether it is a new draft or a revision of an existing skill.
File layout and collision detection¶
Drafts are stored in ~/lightfall/skills/_drafts/ with provenance frontmatter:
New draft — if
namedoes not collide with an active skill, the draft is written to_drafts/<name>/SKILL.md.Revision — if
namecollides with an active skill, a.proposedrevision is written to_drafts/<name>/SKILL.md.proposed. This signals that the draft is intended to replace or update an existing skill, not create a parallel one.
Redrafting an existing draft (same name, same collision state) overwrites the previous version. When a revision is drafted, any stale non-revision draft for that name is deleted.
Provenance frontmatter¶
Every draft file includes metadata in YAML frontmatter under a lightfall-draft: key:
---
name: my-skill
description: "What this skill does"
lightfall-draft:
author: Alice
created: 2026-08-02
session_id: abc123...
---
author— name of the session’s Claude agent (e.g., the bus name).created— ISO date the draft was created.session_id— optional session identifier for traceability; omitted if not available.
Inertness guarantee¶
Drafts in _drafts/ are never injected into agent prompts during session assembly.
The session assembler materializes only skills from the active ~/lightfall/skills/<name>/
directory. This means agents cannot accidentally ship incomplete or unapproved drafts, and
drafts are completely safe to experiment with.
Manual approval procedure¶
To promote a draft into active use, a human must:
New skill — move the draft directory out of staging:
mv ~/lightfall/skills/_drafts/<name>/ ~/lightfall/skills/<name>/The next agent session will automatically load the active skill.
Revision of existing skill — diff the
.proposedfile against the active one, review the changes, then:# Replace the active skill with the revision mv ~/lightfall/skills/_drafts/<name>/SKILL.md.proposed ~/lightfall/skills/<name>/SKILL.md # Clean up the draft directory rm -rf ~/lightfall/skills/_drafts/<name>/The next agent session will use the updated skill.
Rejection — delete the draft if it should not be promoted:
rm -rf ~/lightfall/skills/_drafts/<name>/
The frontmatter (author, created, session_id) is retained in the active skill when promoted. This preserves provenance for auditing and reference.
Phase 4: Tabbed multi-agent UI & editor panel¶
The Claude panel UI in Phase 4 introduces:
Tabbed multi-agent sessions — Each agent gets its own tab, independent conversation state, and unread badges.
Inline message cards — Inter-agent messages appear as Accept/Dismiss cards instead of a banner.
Agent openability control — Per-agent frontmatter field to prevent certain agents (e.g. monitor/observer) from being user-opened.
Editor panel — A combined Agents & Skills management UI with draft approval workflows, replacing manual filesystem operations.
Tabbed panel and agent picker¶
The Claude panel hosts a QTabWidget with a “+” button in the corner. On first launch, a single uncloseable lightfall tab appears, hosting the main assistant session. Users can open additional agent tabs via the picker menu.
The picker button (“+” button in the panel corner) opens a context menu listing available agents:
Agents not currently open and marked
openable: truein their specDisplayed with scope suffix (e.g. “Observer (core)”) and description tooltip
Sorted by scope (core agents first) then name
Observer and other system agents are not openable (
openable: falsein their spec frontmatter)
Agent specifications:
---
name: my_custom_agent
description: Describes what this agent does
lightfall:
openable: false # Prevent user from opening this agent in a tab
---
<agent prompt body>
The openable field (default: true) controls whether a user can open this agent as a tab.
System agents like observer set openable: false to remain background-only (forwarding monitor summaries to the lightfall tab).
Tab behavior:
All tabs are closable except the uncloseable lightfall tab
Tab text shows agent name; when unread agent messages are pending, it becomes
"<name> (N)"(badge)Closing a tab terminates that agent’s session (unregisters from the agent bus)
submit_external_prompt()anddiscuss_observation()routing always targets the lightfall tab, activating it if needed
Inline Accept/Dismiss message cards¶
When an agent sends a message to another agent (via send_message MCP tool), the message routing policy (defined in the receiving agent’s spec) determines how it is rendered:
Queue policy (lightfall: {on_message: queue}, the default):
lightfall:
on_message: queue
Messages appear as interactive cards in the receiving agent’s session:
⚡ from observer · 14:32
Severity info: Ring current stable at 495 mA. Ready for new topups.
[Accept] [Dismiss]
Accept — submits the message as a turn in the conversation (Claude sees and responds to it)
Dismiss — greys out the card and removes it from the pending queue without submitting
A badge shows the unread count on the receiving agent’s tab when it is not focused
On agent bus congestion (session busy when Accept is clicked), a transient “agent busy — try again” note appears; the card remains
Auto policy (lightfall: {on_message: auto}):
lightfall:
on_message: auto
Messages are submitted immediately if the session is idle; if the session is busy, they queue and flush when idle. Cards appear with “auto-accepted” label instead of buttons:
⚡ from observer · 14:32 (auto-accepted)
Severity info: Ring current stable at 495 mA. Ready for new topups.
Both policies are runtime-toggleable via the tune-popup menu: “Auto-accept agent messages” checkbox overrides the spec’s declared policy for the running session without editing the agent file.
Agents & Skills editor panel¶
The Agents & Skills editor panel (sidebar, left area, id lightfall.panels.agent_editor) provides:
Agents tab¶
Lists all agent definitions grouped by scope (User, Beamline, Core):
Read-only rows (built-in/beamline agents):
Description, scope, source-path link
Copy to user scope button — creates an editable shadow copy in the user agent directory
(If the agent is already shadowed:) Reset to default button — deletes the user shadow and restores the original
Editable rows (user-scope agents):
Form editor: description (text), model (dropdown: blank/opus/sonnet/haiku), reasoning effort (dropdown: blank/low/medium/high), memory/subagent/openable toggles, on_message policy (queue/auto), available tools and skills (checkable lists)
Prompt editor — large text area with a static hint showing available template variables (
{{beamline}},{{user}},{{endstation}})Save button — serializes frontmatter + body, validates by round-tripping through
parse_agent_file(), writes the file; errors shown inlineExport… button — file dialog to copy the agent definition to another location
Error rows (grayed, unparseable agent files):
Error message, scope, source path
New agent button — prompts for a name, creates a template user-scope agent file
Skills tab¶
Lists active skills (from resolve_skills()) grouped by scope, plus a Drafts section when pending skill drafts exist:
Drafts section (appears only when
list_drafts()is non-empty):Tabbed view: active skill vs. proposed draft (for revisions) or full draft text (for new skills)
Unified diff view (collapsible, generated via
difflib) for revisionsApprove button — calls
approve_draft(name)(moves new draft to active, or replaces active with proposed), increments Skills-tab badge counter, refreshes listsReject button (confirm dialog) — calls
reject_draft(name), removes draft, refreshesEdit then approve — opens the draft in an inline editor, saves changes back to the draft file, then approves
Active skills section:
Read-only display grouped by scope
Revisions are shown as side-by-side diffs: active (left) vs. draft proposal (right), highlighting changes
Skills-tab badge — the tab text shows
"Skills (N)"when N > 0 drafts are pending
The panel watches the drafts directory (QFileSystemWatcher on drafts_dir()). New or changed drafts automatically refresh the UI and update the badge.
Manual approval procedure (fallback)¶
For users who prefer command-line workflows or if the editor panel is unavailable, manual promotion remains supported:
To promote a draft into active use:
New skill — move the draft directory out of staging:
mv ~/lightfall/skills/_drafts/<name>/ ~/lightfall/skills/<name>/The next agent session will automatically load the active skill.
Revision of existing skill — diff the
.proposedfile against the active one, review the changes, then:# Replace the active skill with the revision mv ~/lightfall/skills/_drafts/<name>/SKILL.md.proposed ~/lightfall/skills/<name>/SKILL.md # Clean up the draft directory rm -rf ~/lightfall/skills/_drafts/<name>/The next agent session will use the updated skill.
Rejection — delete the draft:
rm -rf ~/lightfall/skills/_drafts/<name>/
The editor panel is the primary path for approval workflows; this manual procedure is a fallback.
Observer forwarding: proactive monitor summaries¶
The always-on MonitorService (lightfall.monitor.service.MonitorService) batches
observations from monitor feeds, periodically asks the MonitorAdvisor for a plain-text
summary (_flush_advisor / _on_advisor_reply), and — subject to a severity gate —
forwards that summary onto the bus as agent "observer" addressed to "lightfall":
Proactive monitor summary (<severity>): <advisor reply>
The severity gate is deterministic code, not the LLM¶
_forward_to_agent looks up the observer agent’s AgentSpec.forward_min_severity
(from that agent file’s lightfall.forward_min_severity: info|warn|critical
frontmatter key; defaults to "warn" if unset or the spec can’t be resolved) and
compares it against the batch’s max severity with severity_at_least(). Only if the
batch clears the floor does the summary go out over AgentBus.send. If no "lightfall"
endpoint is registered, the send fails and is degraded to a log line — it never raises
into the monitor’s event loop.
This gate is plain Python, evaluated before any bus/LLM call is made — the advisor
LLM only ever produces the summary text, and it never decides whether that summary is
allowed to reach the assistant. The same invariant holds for the toast/panel path:
_on_observation raises a toast for warn/critical observations independent of, and
before, any advisor/forwarding logic runs. No amount of prompt content can widen or
bypass either gate.
Per-feed preferences¶
Three preference keys (lightfall.ui.preferences.monitor_settings), one dict entry per
feed name, control what advisor batching sees before the forwarding gate above ever
runs:
Pref key |
Shape |
Purpose |
|---|---|---|
|
|
Feed names excluded from monitoring entirely |
|
|
Per-feed polling interval override (seconds) |
|
|
Per-feed floor ( |
These feed-level floors and the observer spec’s forward_min_severity floor are two
independent gates in series: a feed can contribute low-severity noise to the advisor’s
context while the forwarded summary is still held back until the batch’s overall
severity clears the observer’s floor.
Built-in agent plugins¶
The built-in manifest registers agent plugins covering panel interaction (lightfall_core_tools), devices (device_tools), plans (plan_tools), the engine (engine_tools), the IPython console (ipython_tools), panel and plan authoring expertise (panel_design, panel_builder, plan_design), scan planning and alignment guidance (scan_planning, alignment), adaptive experiments (autonomous_experiment), and the current ESAF (current_esaf). Reading their sources under src/lightfall/plugins/agents/ is the fastest way to learn the patterns.