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.
Registering plugins
Pass plugins through AgentConfig, not as a direct Agent(plugins=...) keyword argument:
python
from elsai import Agent
from elsai.agent import AgentConfig
from elsai.plugins import Plugin
agent = Agent(
config=AgentConfig(plugins=[MyPlugin()]),
)AgentConfig
plugins, hooks, session_manager, and other metadata fields belong in AgentConfig. Direct keyword arguments such as Agent(plugins=[...]) still work but emit a deprecation warning.
Creating a plugin
Extend Plugin and decorate methods with @hook or @tool:
python
from elsai import Agent, tool
from elsai.agent import AgentConfig
from elsai.plugins import Plugin, hook
from elsai.hooks import BeforeModelCallEvent
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(config=AgentConfig(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
from elsai.plugins.skills import AgentSkills
from elsai.plugins.context_offloader import ContextOffloader, InMemoryStorage
agent = Agent(
tools=[my_search_tool],
config=AgentConfig(
plugins=[
LoggingPlugin(),
CounterPlugin(),
AgentSkills(skills="./skills/"),
ContextOffloader(storage=InMemoryStorage()),
],
),
)Spawn compatibility (for agent-as-tool)
When a plugin is attached to an agent used as as_tool(mode="spawn"), it must opt in to spawn cloning. Custom plugins default to not spawnable — mode="spawn" raises a ValueError at construction naming the plugin. Use mode="queue" or mode="reject" instead, or implement both methods below.
Spawn-safe plugin (immutable config)
Use when each worker can hold its own plugin instance with the same configuration and no shared mutable state:
python
from elsai.plugins import Plugin, hook
from elsai.hooks import BeforeModelCallEvent
class MetricsPlugin(Plugin):
name = "metrics"
def __init__(self, prefix: str = "call"):
self._prefix = prefix # immutable config — safe to copy per worker
def supports_spawn(self) -> bool:
return True
def clone_for_spawn(self) -> "MetricsPlugin":
return MetricsPlugin(prefix=self._prefix)
@hook
def on_model_call(self, event: BeforeModelCallEvent) -> None:
print(f"[{self._prefix}] model call — agent: {event.agent.name}")Not spawn-safe (shared mutable state)
When a plugin holds shared mutable state or an external resource, leave supports_spawn() at its default (False) and use as_tool(mode="queue") or mode="reject" on the parent agent:
python
from elsai.plugins import Plugin
class SharedCachePlugin(Plugin):
name = "shared-cache"
def __init__(self, cache: dict):
self._cache = cache # shared across workers — unsafe for spawn
# supports_spawn() defaults to False
# clone_for_spawn() is not implementedBuilt-in spawn-ready plugins
| Plugin | Spawn behavior |
|---|---|
AgentSkills | Cloned per worker |
AgentsMdPlugin | Cloned per worker |
ContextOffloader | In-memory storage cloned; file storage shared |
GuardrailsPlugin | Cloned per worker |
LLMSteeringHandler | Isolated steering context per worker |
See Agent as Tool — Concurrent invocation.
Built-in plugins
| Plugin | Purpose |
|---|---|
AgentSkills | On-demand modular instructions agents discover and activate at runtime |
AgentsMdPlugin | Hierarchical AGENTS.md project context injected into the system prompt |
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 |
SandboxPlugin | Workspace execution tools (execute, read_file, write_file, list_dir) when a sandbox backend is active |
GuardrailsPlugin | Policy enforcement and audit logging via the guardrails pipeline |
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
Prebuilt tools required
AgentSkills provides skill discovery and activation only — it does not ship file, shell, or other execution tools. Install the companion elsai-agents-tools package separately and pass the tools your skills reference:
bash
pip install --extra-index-url https://elsai-agents.elsai.ai/root/ elsai-agents-tools==0.3.0See Prebuilt Tools for the full catalog and optional extras.
python
from elsai import Agent
from elsai.agent import AgentConfig
from elsai.plugins.skills import AgentSkills
from elsai_tools.file_read import file_read
from elsai_tools.shell import shell
# Load skills from a directory
plugin = AgentSkills(skills="./skills/")
agent = Agent(
tools=[file_read, shell],
config=AgentConfig(plugins=[plugin]),
)
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(config=AgentConfig(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])
agent = Agent(config=AgentConfig(plugins=[plugin]))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.
AgentsMdPlugin
Load hierarchical AGENTS.md project guidance into the agent's system prompt. Use this for repo-wide coding standards, monorepo subfolder overrides, and environment setup notes. Pair with AgentSkills when you need both static project context and on-demand procedural skills.
How it works
- Discovery — On
init_agent, the plugin walks upward fromstart_path(default cwd) and collectsAGENTS.mdfiles on the ancestor path. - Caching — Resolved context is stored in
agent.stateand is not re-read from disk on later invocations. - Injection — On each
BeforeModelCallEvent, merged context is applied to the system prompt as a<project_context>XML block (after skill metadata is injected).
Skills metadata (<available_skills>) appears before project context (<project_context>) in the effective system prompt.
Discovery modes
| Mode | Configuration | Behaviour |
|---|---|---|
| Hierarchical (default) | merge_hierarchy=True, path=None | Collect every AGENTS.md on the ancestor path; merge root → leaf |
| Nearest only | merge_hierarchy=False | Load only the closest AGENTS.md walking upward |
| Explicit path | path="/foo/AGENTS.md" | Load that file only; hierarchy options ignored |
File names: AGENTS.md (preferred), agents.md (fallback).
The chain boundary defaults to the nearest .git directory upward (supports submodule .git files). Set root= to cap discovery below the git root.
Injected format
xml
<project_context>
<document source="/abs/path/to/repo/AGENTS.md" depth="0">
...raw markdown body...
</document>
<document source="/abs/path/to/repo/backend/AGENTS.md" depth="1">
...raw markdown body...
</document>
</project_context>All kept documents contribute (concat root → leaf); no silent override within a file.
Usage
python
from elsai import Agent
from elsai.agent import AgentConfig
from elsai.plugins.agents_md import AgentsMdPlugin
agent = Agent(
config=AgentConfig(plugins=[AgentsMdPlugin()]),
)
result = agent("What testing framework should I use for this project?")With AgentSkills:
python
from elsai.plugins.skills import AgentSkills
agent = Agent(
system_prompt="You are a helpful assistant.",
config=AgentConfig(
plugins=[
AgentSkills(skills=["./skills/"]),
AgentsMdPlugin(),
],
),
)Explicit single file:
python
AgentsMdPlugin(path="/abs/path/to/AGENTS.md")Monorepo with bounded discovery:
python
from pathlib import Path
AgentsMdPlugin(
start_path=Path.cwd(),
root="/abs/path/to/repo",
merge_hierarchy=True,
max_files=3,
on_exceed="warn",
)Configuration
| Parameter | Type | Default | Description |
|---|---|---|---|
path | Path | str | None | None | Explicit AGENTS.md file or directory. When set, hierarchy discovery and file limits are skipped. |
start_path | Path | str | None | None (cwd) | Directory to start the upward walk from. |
root | Path | str | None | None (VCS root) | Upper inclusive boundary for discovery. Must be an ancestor of start_path. |
merge_hierarchy | bool | True | True: merge all ancestors root→leaf. False: nearest file only. |
max_files | int | None | 3 | Max AGENTS.md files to keep (nearest win). None = unlimited. Only applies when merge_hierarchy=True and path is unset. |
on_exceed | "warn" | "error" | "warn" | When the chain exceeds max_files: warn and trim root-side files, or raise ValueError. |
state_key | str | "agents_md" | Key in agent.state for cached context. |
Parameter interaction
path set? | merge_hierarchy | max_files / on_exceed | Result |
|---|---|---|---|
| Yes | ignored | ignored | Single file from path |
| No | True | active | Full chain, then file limit |
| No | False | ignored | Nearest file only |
max_files example
From backend/services/ with root=sample_project/ and three AGENTS.md files (root, backend, services):
max_files | Kept | Skipped |
|---|---|---|
None | root, backend, services | — |
3 (default) | root, backend, services | — |
2 | backend, services | root |
1 | services | root, backend |
When the chain exceeds max_files, leaf-side (most specific) files are kept and root-side files are dropped. Whole files are kept or skipped — content is never truncated mid-file.
Caching
Discovery runs once at agent initialisation. Editing AGENTS.md on disk after the agent is constructed does not refresh context.
Advanced APIs
Inspect discovery without constructing an agent:
python
from elsai.plugins.agents_md import AgentsMdContext, AgentsMdDocument
# Hierarchical chain
context = AgentsMdContext.discover_chain(
start="/repo/backend/services",
root="/repo",
max_files=3,
on_exceed="warn",
)
print(context.merged_content)
# Single file
document = AgentsMdDocument.from_file("/repo/AGENTS.md")Delimiter collision
Avoid literal </project_context> or </document> strings inside AGENTS.md file bodies. If present, the model may see truncated context (no crash).
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
Prebuilt tools required
The steering example below uses file and shell tools from elsai-agents-tools, a separate package from the core SDK:
bash
pip install --extra-index-url https://elsai-agents.elsai.ai/root/ elsai-agents-tools==0.3.0python
from elsai import Agent
from elsai.agent import AgentConfig
from elsai.plugins.steering import LLMSteeringHandler
from elsai_tools.file_read import file_read
from elsai_tools.file_write import file_write
from elsai_tools.shell import shell
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],
config=AgentConfig(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()SandboxPlugin
SandboxPlugin registers workspace execution tools when a sandbox backend is configured through AgentConfig(sandbox=...) or the per-agent execution_backend path.
When AgentConfig(sandbox=...) is set, the plugin is auto-registered on attach if not already in plugins.
Tools
| Tool | Description |
|---|---|
execute | Run a shell command in the workspace (omitted when the backend has no shell support) |
read_file | Read a text file with optional line offset and limit |
write_file | Write a text file |
list_dir | List a directory |
Usage
python
from elsai import Agent
from elsai.agent import AgentConfig
from elsai.execution import Sandbox
from elsai.plugins.sandbox import SandboxPlugin
agent = Agent(
config=AgentConfig(
sandbox=sandbox,
plugins=[SandboxPlugin()], # optional when sandbox= is set — auto-added on attach
),
)Read-only attach
When sandbox_attach_mode=READ_ONLY, the agent receives read_file and list_dir only — execute and write_file are removed.
Custom sandbox tools
Use helpers from elsai.plugins.sandbox in your own @tool functions:
python
from elsai import tool
from elsai.plugins.sandbox import run_command
@tool
async def run_tests(agent, path: str = "tests/") -> str:
return await run_command(agent, f"python -m pytest {path} -q")See Sandbox — Shared workspace and Building Tools.
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.agent import AgentConfig
from elsai.plugins.context_offloader import ContextOffloader, InMemoryStorage, FileStorage, S3Storage
# Development
agent = Agent(config=AgentConfig(plugins=[
ContextOffloader(storage=InMemoryStorage())
]))
# Local disk
agent = Agent(config=AgentConfig(plugins=[
ContextOffloader(
storage=FileStorage("./offloaded-results/"),
max_result_tokens=5_000,
preview_tokens=2_000,
)
]))
# Production (S3)
agent = Agent(config=AgentConfig(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.