Appearance
Plugins
Plugins are reusable bundles of hooks, tools, and initialisation logic that extend agent behaviour. Attach one or many at construction time — each registers its own functionality independently.
Creating a plugin
Extend Plugin and decorate methods with @hook or @tool:
python
from elsai import Agent, tool
from elsai.plugins import Plugin
from elsai.hooks import BeforeModelCallEvent, hook
class LoggingPlugin(Plugin):
name = "logging"
@hook
def on_model_call(self, event: BeforeModelCallEvent) -> None:
print(f"[{self.name}] calling model — agent: {event.agent.name}")
@tool
def ping(self) -> str:
"""Check if the logging plugin is active."""
return f"{self.name} plugin is active"
agent = Agent(plugins=[LoggingPlugin()])
result = agent.tool.ping() # → "logging plugin is active"Plugin lifecycle
- The framework scans all
@tool-decorated methods and registers them. init_agent()runs — use it for setup that needs access to the agent.@hookhandlers bind to the event bus.
Async initialisation
python
class DataPlugin(Plugin):
name = "data"
async def init_agent(self, agent: "Agent") -> None:
self.config = await fetch_remote_config()Accessing agent state
Plugins can read and write the agent's persistent state store:
python
class CounterPlugin(Plugin):
name = "counter"
@hook
def count(self, event: BeforeModelCallEvent) -> None:
calls = event.agent.state.get("call_count", 0)
event.agent.state.set("call_count", calls + 1)
print(f"Total model calls: {calls + 1}")Composing multiple plugins
Plugins stack cleanly — each operates independently:
python
agent = Agent(
tools=[my_search_tool],
plugins=[
LoggingPlugin(),
CounterPlugin(),
SkillsPlugin(skills="./skills/"),
ContextOffloader(storage=InMemoryStorage()),
],
)Built-in plugins
| Plugin | Purpose |
|---|---|
AgentSkills | On-demand modular instructions agents discover and activate at runtime |
LLMSteeringHandler | Context-aware guidance injected at decision points via lifecycle hooks |
ContextOffloader | Offloads oversized tool results to storage and provides an auto-generated retrieval tool |
AgentSkills
Skills solve context bloat in complex agents. Instead of embedding all instructions in one monolithic system prompt, skills implement progressive disclosure: lightweight metadata stays in the prompt, and full instructions load only when the agent activates a specific skill.
How it works
- Discovery — On init, skill names and descriptions are injected as XML into the system prompt so the agent knows what's available without loading full instructions.
- Activation — When the agent determines it needs a skill, it calls the built-in
skillstool. The full instructions and resource file listing are returned. - Execution — The agent follows the loaded instructions and accesses resource files through provided tools.
The injected block looks like:
xml
<available_skills>
<skill>
<name>pdf-processing</name>
<description>Extract text and tables from PDF documents</description>
</skill>
</available_skills>SKILL.md format
Skills follow the Agent Skills specification. Each skill lives in its own directory:
skills/
└── pdf-processing/
├── SKILL.md ← required: frontmatter + instructions
├── scripts/ ← executable files the agent can run
├── references/ ← documentation and guides
└── assets/ ← static templates and configsSKILL.md frontmatter:
markdown
---
name: pdf-processing
description: Extract text and tables from PDF documents using pdfplumber
allowed-tools: file_read shell
---
# PDF Processing
Use `pdfplumber` to extract content from PDF files.
## Steps
1. Open the file with `pdfplumber.open(path)`
2. Iterate pages with `pdf.pages`
3. Extract text with `page.extract_text()`
4. Extract tables with `page.extract_tables()`| Frontmatter field | Required | Description |
|---|---|---|
name | Yes | Lowercase alphanumeric + hyphens, 1–64 chars. Must match the directory name. |
description | Yes | Capability summary shown in the system prompt |
allowed-tools | No | Space-delimited tool names the skill uses (informational) |
Usage
python
from elsai import Agent
from elsai.plugins.skills import AgentSkills
from elsai.tools import file_read, shell
# Load skills from a directory
plugin = AgentSkills(skills="./skills/")
agent = Agent(plugins=[plugin], tools=[file_read, shell])
result = agent("Extract the tables from report.pdf")Create skills programmatically:
python
from elsai.plugins.skills import AgentSkills, Skill
skill = Skill(
name="code-review",
description="Review Python code for best practices and bugs",
instructions="Review the provided code. Check for: 1) PEP 8 compliance..."
)
plugin = AgentSkills(skills=[skill])
agent = Agent(plugins=[plugin])Load from a SKILL.md string:
python
skill = Skill.from_content("""---
name: data-analysis
description: Analyse tabular data using pandas
---
Use pandas to load and analyse the data...""")
plugin = AgentSkills(skills=[skill])Configuration
| Parameter | Default | Description |
|---|---|---|
skills | required | Path string, list of paths, or Skill instances |
state_key | "agent_skills" | Agent state key for persisting activated skills |
max_resource_files | 20 | Maximum resource files listed per activation |
strict | False | If True, raises errors on validation failures instead of warning |
Runtime management
python
# View available skills
plugin.get_available_skills()
# Add or replace skills at runtime
plugin.set_available_skills([new_skill])
# See which skills the agent has activated this session
plugin.get_activated_skills()Activated skills persist in agent state across sessions when a SessionManager is configured.
When to use skills vs. multi-agent
Skills are ideal when a single agent handles multiple specialised domains and you want to avoid context bloat without the overhead of separate agents. For truly independent workloads, prefer multi-agent patterns.
LLMSteeringHandler
Steering provides just-in-time guidance at decision points inside the agent loop. Instead of front-loading 30+ instructions into a system prompt (where models tend to ignore them), steering intercepts tool calls and model responses, evaluates them, and injects corrective feedback only when needed.
How it works
Two steering points:
| Hook | Evaluates | Actions |
|---|---|---|
steer_before_tool() | Incoming tool call (BeforeToolCallEvent) | Proceed — run the tool; Guide — cancel and inject feedback; Interrupt — pause for human |
steer_after_model() | Model response (AfterModelCallEvent) | Proceed — accept response; Guide — discard and retry with guidance injected |
Usage
python
from elsai import Agent
from elsai.plugins.steering import LLMSteeringHandler
handler = LLMSteeringHandler(
system_prompt="""You are a safety reviewer for an agent that manages files.
Rules:
- Never allow deletion of files outside /tmp
- Always confirm before overwriting existing files
- Reject shell commands that contain 'rm -rf'
"""
)
agent = Agent(tools=[file_read, file_write, shell], plugins=[handler])
result = agent("Clean up the project directory")Context providers
The LedgerProvider (built-in) tracks tool call history and makes it available to the steering model:
python
from elsai.plugins.steering import LLMSteeringHandler, LedgerProvider
handler = LLMSteeringHandler(
system_prompt="Review tool calls for safety...",
context_providers=[LedgerProvider()],
)The ledger captures per-tool-call data: inputs, outputs, timing, and success/failure status — all in steering_context["ledger"].
Steering actions
python
from elsai.plugins.steering import LLMSteeringHandler, ToolSteeringAction
class MySteeringHandler(LLMSteeringHandler):
def steer_before_tool(self, event):
tool_name = event.tool_use["name"]
if tool_name == "shell":
cmd = event.tool_use["input"].get("command", "")
if "rm -rf" in cmd:
return ToolSteeringAction.guide("Destructive commands are not allowed.")
return ToolSteeringAction.proceed()Python only
LLMSteeringHandler is currently available in Python only.
ContextOffloader
The ContextOffloader plugin prevents large tool results from exhausting the context window. It intercepts results after tool execution, stores oversized ones in a backend, and replaces them with a short preview plus a reference — keeping the context window lean.
How it works
After offloading, the agent can call the auto-registered retrieve_offloaded_content tool to fetch specific sections on demand.
Storage backends
| Backend | Persistence | Best for |
|---|---|---|
InMemoryStorage | Process lifetime | Development and testing |
FileStorage | Local disk | Debugging, artifact inspection |
S3Storage | Amazon S3 | Production deployments |
Usage
python
from elsai import Agent
from elsai.plugins.context_offloader import ContextOffloader, InMemoryStorage, FileStorage, S3Storage
# Development
agent = Agent(plugins=[
ContextOffloader(storage=InMemoryStorage())
])
# Local disk
agent = Agent(plugins=[
ContextOffloader(
storage=FileStorage("./offloaded-results/"),
max_result_tokens=5_000,
preview_tokens=2_000,
)
])
# Production (S3)
agent = Agent(plugins=[
ContextOffloader(
storage=S3Storage(
bucket="my-agent-artifacts",
prefix="tool-results/",
),
max_result_tokens=5_000,
preview_tokens=2_000,
)
])Configuration
| Parameter | Default | Description |
|---|---|---|
storage | required | Storage backend instance |
max_result_tokens | 2500 | Results exceeding this are offloaded |
preview_tokens | 1000 | How many tokens of preview to keep in context |
include_retrieval_tool | True | Whether to register retrieve_offloaded_content tool |
Retrieving offloaded content
The auto-registered tool lets the agent fetch stored results selectively:
python
# The agent calls this automatically — shown here for illustration
agent.tool.retrieve_offloaded_content(
reference="offload://abc123",
pattern="error", # optional: keyword/regex search
line_range=[100, 150], # optional: specific lines
)Token estimation
Token counts are estimated using the model's native tokeniser when available, or a character-based heuristic (chars ÷ 4 for text, chars ÷ 2 for JSON). Adjust max_result_tokens based on your model's context window and workload.