Skip to content

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

  1. The framework scans all @tool-decorated methods and registers them.
  2. init_agent() runs — use it for setup that needs access to the agent.
  3. @hook handlers 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 spawnablemode="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 implemented

Built-in spawn-ready plugins

PluginSpawn behavior
AgentSkillsCloned per worker
AgentsMdPluginCloned per worker
ContextOffloaderIn-memory storage cloned; file storage shared
GuardrailsPluginCloned per worker
LLMSteeringHandlerIsolated steering context per worker

See Agent as Tool — Concurrent invocation.


Built-in plugins

PluginPurpose
AgentSkillsOn-demand modular instructions agents discover and activate at runtime
AgentsMdPluginHierarchical AGENTS.md project context injected into the system prompt
LLMSteeringHandlerContext-aware guidance injected at decision points via lifecycle hooks
ContextOffloaderOffloads oversized tool results to storage and provides an auto-generated retrieval tool
SandboxPluginWorkspace execution tools (execute, read_file, write_file, list_dir) when a sandbox backend is active
GuardrailsPluginPolicy 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

  1. 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.
  2. Activation — When the agent determines it needs a skill, it calls the built-in skills tool. The full instructions and resource file listing are returned.
  3. 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 configs

SKILL.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 fieldRequiredDescription
nameYesLowercase alphanumeric + hyphens, 1–64 chars. Must match the directory name.
descriptionYesCapability summary shown in the system prompt
allowed-toolsNoSpace-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.0

See 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

ParameterDefaultDescription
skillsrequiredPath string, list of paths, or Skill instances
state_key"agent_skills"Agent state key for persisting activated skills
max_resource_files20Maximum resource files listed per activation
strictFalseIf 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

  1. Discovery — On init_agent, the plugin walks upward from start_path (default cwd) and collects AGENTS.md files on the ancestor path.
  2. Caching — Resolved context is stored in agent.state and is not re-read from disk on later invocations.
  3. 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

ModeConfigurationBehaviour
Hierarchical (default)merge_hierarchy=True, path=NoneCollect every AGENTS.md on the ancestor path; merge root → leaf
Nearest onlymerge_hierarchy=FalseLoad only the closest AGENTS.md walking upward
Explicit pathpath="/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

ParameterTypeDefaultDescription
pathPath | str | NoneNoneExplicit AGENTS.md file or directory. When set, hierarchy discovery and file limits are skipped.
start_pathPath | str | NoneNone (cwd)Directory to start the upward walk from.
rootPath | str | NoneNone (VCS root)Upper inclusive boundary for discovery. Must be an ancestor of start_path.
merge_hierarchyboolTrueTrue: merge all ancestors root→leaf. False: nearest file only.
max_filesint | None3Max 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_keystr"agents_md"Key in agent.state for cached context.

Parameter interaction

path set?merge_hierarchymax_files / on_exceedResult
YesignoredignoredSingle file from path
NoTrueactiveFull chain, then file limit
NoFalseignoredNearest file only

max_files example

From backend/services/ with root=sample_project/ and three AGENTS.md files (root, backend, services):

max_filesKeptSkipped
Noneroot, backend, services
3 (default)root, backend, services
2backend, servicesroot
1servicesroot, 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:

HookEvaluatesActions
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.0
python
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

ToolDescription
executeRun a shell command in the workspace (omitted when the backend has no shell support)
read_fileRead a text file with optional line offset and limit
write_fileWrite a text file
list_dirList 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

BackendPersistenceBest for
InMemoryStorageProcess lifetimeDevelopment and testing
FileStorageLocal diskDebugging, artifact inspection
S3StorageAmazon S3Production 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

ParameterDefaultDescription
storagerequiredStorage backend instance
max_result_tokens2500Results exceeding this are offloaded
preview_tokens1000How many tokens of preview to keep in context
include_retrieval_toolTrueWhether 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.

Copyright © 2026 elsai foundry.