Skip to content

Plugins

A reusable capability may need Tools, instructions, per-turn reminders, hooks, and cleanup. A plugin packages them together. Skills, MCP, Todo, and Memory all use this extension mechanism.

from lovia import Agent, Memory, Skills, Todo

agent = Agent(
    name="builder",
    model="<model>",
    plugins=[Todo(), Skills("./skills"), Memory("./.lovia/memory")],
)

If a capability is one independent function, an ordinary @tool is usually clearer. Reach for a Plugin when the capability must also carry instructions, state, per-turn reminders, or cleanup.

The contract

A plugin is any object with a name and an async setup() returning a PluginInstance:

class Plugin(Protocol):
    name: str
    async def setup(self) -> PluginInstance: ...

The runner awaits setup() once per run — and once per agent a handoff reaches — then merges the returned contributions into the loop's fixed slots:

PluginInstance field Effect
tools merged into the agent's tool set (one namespace — clashes raise like any other tool source)
instructions static text appended to the system prompt, rendered once at run start
view_injectors called every turn; their entries are appended to that turn's model view only — never persisted
hooks an AgentHooks receiving every run event, dispatched alongside the agent's own
input_guardrails / output_guardrails merged with the agent's own at the loop's existing checkpoints
aclose coroutine awaited when the run ends (LIFO across plugins, best effort)

Plugin contributions merge into fixed extension points; the Plugin does not take over control flow. The loop still executes abort, retry, and handoff. A Plugin guardrail can stop a Run only at the same checkpoint as an Agent guardrail.

name is the plugin's identity: unique per agent (validated before any setup() runs) and stable.

View injectors: the per-turn seam

The novel slot. A ViewInjector is called with the live RunContext each turn and returns transcript entries to append to that turn's model view:

def inject(ctx: RunContext) -> list[TranscriptEntry] | None:
    if not store.items:
        return None
    return [InputEntry(role="user", content=f"<system-reminder>\n{render(store.items)}\n</system-reminder>")]

Because injected entries never enter the transcript or the session, they don't accumulate as turns grow, they don't bust the provider's cached prompt prefix, and a resumed run doesn't replay them — an injector is expected to regenerate its content each turn (reminders, clocks, todo lists). Injectors are fail-open: one that raises is logged and skipped, never aborting the run. Keep them small — they are appended after the context policy has shaped the view.

State scoping: the one design decision

Where a plugin keeps state decides its behavior under concurrency:

  • Per-run state is built inside setup() and closed over — every run gets a fresh copy, concurrency-safe by construction. The todo list below works this way.
  • Cross-run state (a database, an index, a glossary) lives on the plugin object, passed in at construction. It is shared by every run — possibly concurrently — so it must be safe for concurrent use, and the plugin never closes it: its lifecycle belongs to whoever created it. Memory works this way.

Worked example: a cross-session glossary

The full shape of a stateful plugin — a shared backend, one tool, prompt text — in one screen:

from dataclasses import dataclass
from typing import Protocol

from lovia import Agent, PluginInstance, tool


class Glossary(Protocol):
    """Your shared backend — a DB, a file, an in-memory dict."""

    async def define(self, term: str, meaning: str) -> None: ...
    async def lookup(self, term: str) -> str | None: ...


@dataclass
class GlossaryPlugin:
    """Cross-session glossary the agent can write to and read back."""

    store: Glossary          # long-lived, shared by every run
    name: str = "glossary"

    async def setup(self) -> PluginInstance:
        store = self.store

        @tool
        async def define(term: str, meaning: str) -> str:
            """Record what a domain term means, for this and later sessions."""
            await store.define(term, meaning)
            return f"Noted: {term}."

        @tool
        async def lookup(term: str) -> str:
            """Look up a previously defined domain term."""
            return await store.lookup(term) or f"No definition for {term!r}."

        return PluginInstance(
            tools=[define, lookup],
            instructions="Use `define` to record domain terms the user explains, "
            "and `lookup` before asking the user to re-explain one.",
        )


agent = Agent(name="assistant", model="<model>", plugins=[GlossaryPlugin(MyGlossary())])

A plugin that opens a resource in setup() (an MCP connection, an HTTP client) returns it via aclose:

async def setup(self) -> PluginInstance:
    conn = await open_connection(self.url)
    return PluginInstance(tools=tools_from(conn), aclose=conn.close)

The built-in plugins

Plugin One line Guide
Todo() externalized checklist, re-shown every turn Todo
Skills(...) instruction bundles with progressive disclosure Skills
MCP(...) tools from Model Context Protocol servers MCP
Memory(...) long-term, cross-session memory Memory

Sharp edges

  • Run state on the plugin object is a concurrency bug. Two concurrent runs of one agent share the plugin instance; anything mutable that isn't built inside setup() is shared mutable state.
  • setup() runs per agent, per run — including handoff targets. A plugin attached to both sides of a handoff activates twice in one run; design setup() to be cheap and idempotent in effect.
  • Instructions are static per run. PluginInstance.instructions is rendered once at run start; content that must vary turn-by-turn belongs in a view injector instead.
  • Injected view entries are invisible to persistence — by design. If a reminder must be auditable later, make it a tool result instead.

See also