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, hook
from elsai.hooks import BeforeModelCallEvent

class MyPlugin(Plugin):
    name = "my_plugin"

    @hook
    def on_model_call(self, event: BeforeModelCallEvent) -> None:
        print(f"[{self.name}] calling model")

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 import Agent, tool
from elsai.agent import AgentConfig
from elsai.plugins.skills import AgentSkills
from elsai.plugins.context_offloader import ContextOffloader, InMemoryStorage

@tool
def my_search_tool(query: str) -> str:
    """Search a knowledge base (demo stub)."""
    return f"Demo results for: {query}"

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 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

Filesystem and execution permission plugins are not listed here — they are hook providers registered automatically from AgentConfig.filesystem_permissions and execution_permissions.


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.

Governed skills from Instructions Manager

AgentSkills loads local skill directories. To pull approved, versioned skills from Instructions Manager — Skills into those folders, use the pm-skills CLI (pm-skills pull).

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==0.3.5 elsai-agents-tools==0.3.0
pip install --extra-index-url https://core-packages.elsai.ai/root/elsai-model/ "elsai-model[bedrock]==2.1.1"

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")
print(result)

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.