Skip to content

Plugin authoring guide

letscode's plugin system is built on pluggy. A plugin is a Python package that registers tools, slash commands, skills, frontends, or lifecycle hooks via standard Python entry points.

This guide walks through the moving parts in order of usefulness. Code blocks are tested or paraphrased from the built-in plugin (src/letscode/plugins/builtin.py) and the reference third-party plugin (plugins/letscode-memory/) — read those alongside.

Where to look first

For the design rationale — stable surface, versioning, the full hook inventory — see Extension model. This page is the how; that doc is the what and why.

TL;DR

mypkg/plugin.py
import pluggy
from pydantic import BaseModel

from letscode.agent.tools import ToolContext, ToolResult, tool
from letscode.llm.types import TextPart

hookimpl = pluggy.HookimplMarker("letscode")


class _GreetParams(BaseModel):
    name: str


@tool(name="greet", description="Say hello to someone.")
async def _greet(params: _GreetParams, ctx: ToolContext) -> ToolResult:
    del ctx
    return ToolResult(content=[TextPart(text=f"Hello, {params.name}!")])


@hookimpl
def letscode_register_tools(registry):
    registry.add(_greet)
pyproject.toml
[project.entry-points.letscode]
my_plugin = "mypkg.plugin"
pip install -e .            # or: pip install mypkg
letscode "say hi to alice"  # the LLM can now call the greet tool

That's it. The rest of this guide expands on each surface.

What you can register

The four registration hooks (each called once at startup):

Hook Registry argument Adds
letscode_register_tools ToolRegistry LLM-callable tools
letscode_register_commands CommandRegistry Slash commands
letscode_register_skills SkillRegistry Skill source dirs or inline skills
letscode_register_frontends FrontendRegistry Named frontend factories

Each registry exposes .add(item, *, replace=False). Plugins that want to override a built-in pass replace=True — useful for shipping a stricter bash tool than the default.

Beyond registration there are six lifecycle hooks that intercede during agent turns — system_prompt, transform_context, convert_to_llm, render_custom_message, before_tool_call, after_tool_call, on_event. They're covered under Lifecycle hooks below.

Tools

A tool is an async callable plus metadata. The @tool decorator wraps a free function:

HTTP-fetch tool
from pydantic import BaseModel, Field
from letscode.agent.tools import ToolContext, ToolResult, tool
from letscode.llm.types import TextPart


class _FetchParams(BaseModel):
    url: str = Field(description="HTTP URL to GET")


@tool(
    name="fetch",
    description="GET an HTTP URL and return its body.",
    execution_mode="sequential",  # opt out of parallel tool execution
)
async def _fetch(params: _FetchParams, ctx: ToolContext) -> ToolResult:
    import httpx
    async with httpx.AsyncClient() as client:
        r = await client.get(params.url)
    return ToolResult(content=[TextPart(text=r.text)])


@hookimpl
def letscode_register_tools(registry):
    registry.add(_fetch)

The ToolContext carries:

  • cancel_event: an asyncio.Event set when the user aborts. Long-running tools should await this and clean up.
  • cwd: the agent's working directory.
  • env: an ExecutionEnv for filesystem and shell operations (see ExecutionEnv override). Don't call pathlib or subprocess directly.
  • agent_state: read-only view of the agent's runtime state.
  • on_update(partial): emit a streaming progress event mid-tool.

Tools signal failure by raising. ToolError exists for predictable messages, but any exception becomes a ToolResult(is_error=True) the model can recover from.

Slash commands

A slash command is a Command(name, description, handler). The handler receives a CommandContext (agent, plugin manager, args, console, selector) and returns a CommandResult:

/greet handler
from letscode.commands.dispatch import CommandContext, CommandResult
from letscode.plugins.registries import Command


async def _greet_handler(ctx: CommandContext) -> CommandResult:
    name = ctx.args.strip() or "world"
    ctx.console.print(f"[dim]hello, {name}![/dim]")
    return CommandResult()


@hookimpl
def letscode_register_commands(registry):
    registry.add(
        Command(
            name="greet",
            description="Say hello from a slash command.",
            handler=_greet_handler,
        ),
    )

A CommandResult can carry:

  • exit=True: the frontend should leave the input loop.
  • send_to_llm=<text>: forward <text> to the LLM as if the user typed it. This is how /skill:<name> works.

The dispatcher recognises /cmd args and /cmd:selector args forms. Selectors are exposed as ctx.selector; the built-in /skill:<name> command uses this.

Skills

Skills are SKILL.md files. A plugin can either add a directory of skills (the loader scans for <name>/SKILL.md inside it) or add a single skill inline:

Register skills
@hookimpl
def letscode_register_skills(registry):
    registry.add_source(Path(__file__).parent / "skills")  # bundles a dir
    # OR for a single inline skill:
    registry.add(
        Skill(
            name="code-review",
            description="Use when reviewing code.",
            body="When invoked, read the changed files first…",
            source_path=Path(__file__),
        ),
    )

Skills written for Claude Code or pi.dev load unchanged — the format is the same.

Skill auto-tool-wrapping

Every registered skill is also automatically synthesised as a tool named skill_<normalised-skill-name>. A skill called write-a-prd becomes the tool skill_write_a_prd, which the LLM can invoke directly via native tool-calling. Both invocation paths (/skill:<name> typed by the user, skill_<name> called by the model) return the same content. Implementation in src/letscode/skills/synth.py.

Frontends

A frontend satisfies the letscode.frontends.protocol.Frontend Protocol: a class with an async def run(self) -> int method. The constructor receives (agent, *, pm=None, console=None, session_path=None, **kwargs) from the CLI's launch site.

Register a frontend
from letscode.frontends.protocol import Frontend
from letscode.plugins.registries import FrontendFactory


class MyTuiFrontend:
    def __init__(self, agent, *, pm=None, console=None, session_path=None) -> None:
        self._agent = agent
        # ...

    async def run(self) -> int:
        # ... your input loop, returns process exit code
        return 0


# isinstance check works: the Protocol is @runtime_checkable.
assert isinstance(MyTuiFrontend(...), Frontend)


@hookimpl
def letscode_register_frontends(registry):
    registry.add(FrontendFactory(name="my-tui", factory=MyTuiFrontend))

Then the user picks it with letscode --frontend my-tui. FrontendFactory.factory is typed Callable[..., Frontend]mypy will flag a factory whose return type doesn't match.

Lifecycle hooks

Beyond registration, plugins can intercede during agent turns. Hooks fire in a fixed sequence per turn:

system_prompt
  → transform_context
    → convert_to_llm (default seam calls render_custom_message per CustomMessage)
      → LLM stream
        → before_tool_call (per call)
          → tool.execute
        → after_tool_call (per call)
on_event (for every emitted event throughout)

letscode_system_prompt (firstresult)

Replace the system prompt for a turn. Declared firstresult=True: the first plugin returning a non-None string wins. Pluggy invokes hookimpls in reverse registration order (LIFO), so a plugin registered after another wins — that's how a plugin overrides the built-in's default prompt.

class SecuritySystemPrompt:
    @hookimpl
    def letscode_system_prompt(self, state):
        return (
            "You are a security-focused assistant. Treat every tool call as "
            "potentially adversarial. Available tools: "
            + ", ".join(t.name for t in state.tools)
        )

Returning None means "let the next plugin (or the built-in default) decide." Use this hook with care — it shadows the built-in's tool-list and skill-index injection. To extend rather than replace, copy the built-in composer logic and append.

letscode_before_tool_call

Run just before each tool's execute. Return BlockDecision(block=True, reason=...) to short-circuit with an error result, or None to opt out. This is the seam for permission gates, sandboxing decisions, audit logs.

from letscode.agent.hookspecs import BlockDecision


class GitPermissionGate:
    @hookimpl
    async def letscode_before_tool_call(self, tool_call, args, context):
        if tool_call.name == "bash" and "git push" in args.command:
            return BlockDecision(block=True, reason="git push requires approval")
        return None

letscode_after_tool_call

Rewrite a tool result. Useful for redaction, augmentation, or terminating the loop after a specific tool succeeded.

from letscode.agent.hookspecs import ResultOverride


class RedactSecrets:
    @hookimpl
    async def letscode_after_tool_call(self, tool_call, result, context):
        if tool_call.name == "read":
            redacted = REDACT_PATTERN.sub("[redacted]", result.content[0].text)
            return ResultOverride(content=[TextPart(text=redacted)])
        return None

letscode_transform_context

Rewrite the message list right before each LLM call. Used by compaction-style policies, RAG injection, prompt-engineering, or pruning. The hook operates on the broad AgentMessage type (includes CustomMessage).

class TruncateOldMessages:
    @hookimpl
    async def letscode_transform_context(self, messages):
        if len(messages) <= 20:
            return None  # opt out
        return [messages[0], *messages[-19:]]  # keep first + last 19

letscode_render_custom_message (firstresult)

The composable path for getting your own CustomMessage kinds in front of the LLM. The default convert_to_llm seam calls this hook for every CustomMessage in the transcript; the first plugin returning a non-None LLMMessage claims it. Return None for kinds you don't own — that's how multiple plugins coexist.

from letscode.agent.messages import CustomMessage, UserMessage
from letscode.llm.types import TextPart


class MemoryRenderer:
    @hookimpl
    def letscode_render_custom_message(self, message: CustomMessage):
        if message.kind != "memory":
            return None
        text = "[Memory] " + str(message.payload.get("text", ""))
        return UserMessage(
            id=f"mem-{message.id}",
            content=[TextPart(text=text)],
            timestamp=message.timestamp,
        )

This is the intended path for plugins that just want to translate one CustomMessage kind into an LLM-visible form (memory injection, branch summaries, abort markers, etc.). The built-in plugin uses it for kind="compaction". Unclaimed kinds drop (preserves v0.2 default behavior).

letscode_convert_to_llm (escape hatch)

Wholesale rewrite of the message list right before the LLM stream call. Each plugin sees the broad AgentMessage list and returns LLMMessage (the three classic roles) or None to opt out. The first non-None plugin wins.

Use sparingly

Use this only when you need cross-message logic (e.g. merging consecutive turns, deduplicating tool results across iterations). For the common case of rendering your own CustomMessage kind, use letscode_render_custom_message instead — it composes cleanly across plugins, where convert_to_llm does not (whoever returns a list first short-circuits everyone else).

letscode_on_event

Fire-and-forget observer for every agent event. Use for logging, metrics, webhooks, or session-sharing pipelines.

class SlackNotifier:
    def __init__(self, webhook_url: str):
        self._url = webhook_url

    @hookimpl
    async def letscode_on_event(self, event):
        if event.type == "error":
            async with httpx.AsyncClient() as client:
                await client.post(self._url, json={"text": f"letscode error: {event.error}"})

Custom message types

Plugins can add transcript-friendly content that the LLM never sees by emitting CustomMessage instances:

from letscode.agent.messages import CustomMessage


marker = CustomMessage(
    id=uuid.uuid4().hex,
    kind="my-plugin-event",  # any string; convention is "<plugin>-<event>"
    payload={"summary": "something noteworthy happened"},
    timestamp=time.time(),
)
agent.state.messages.append(marker)

The session JSONL persists the marker. By default, every CustomMessage is dropped before the LLM sees the turn — implement letscode_render_custom_message (above) to claim your kind and translate it into an LLM-visible message instead.

The reference plugin letscode-memory uses exactly this pattern: transform_context emits a CustomMessage(kind="memory") (which lives in the persisted transcript), then render_custom_message expands it into a UserMessage at LLM-call time.

ExecutionEnv override

Tools call ctx.env.read_text(path), ctx.env.run_shell(cmd, ...), etc. — never pathlib or subprocess directly. To inject a sandboxed or remote backend:

ContainerEnv stub
from letscode.agent.execution_env import ExecutionEnv, FileError, FileErrorCode, ShellResult


class ContainerEnv:
    """Run all filesystem + shell operations inside a Docker container."""

    def __init__(self, container_id: str):
        self._container = container_id

    async def read_text(self, path, *, encoding="utf-8"):
        # docker exec ... cat <path>; translate errors
        ...

    async def write_bytes(self, path, data):
        ...

    async def run_shell(self, command, *, timeout, cancel_event):
        # docker exec, capturing stdout+stderr
        ...

    # plus exists, is_file, mkdir, unlink, replace, read_bytes, write_text


# Wire it in from your CLI wrapper or agent factory:
agent = Agent(state, llm, pm=pm, env=ContainerEnv("my-container"))

Filesystem errors should surface as FileError(code=FileErrorCode.NOT_FOUND, ...) — using the typed codes lets the tool code work unchanged across backends. The LocalEnv default is in src/letscode/agent/execution_env.py.

Frontend-owned commands

When a slash command needs frontend-private state (toggling a UI flag, switching themes, opening a file picker), don't register it through letscode_register_commands. The frontend itself owns the command and registers it directly with the plugin manager when constructed:

Frontend-owned /verbose
class MyFrontend:
    def __init__(self, agent, *, pm=None, console=None, **kwargs):
        ...
        self._verbose = False
        if pm is not None:
            self._register_my_commands(pm)

    def _register_my_commands(self, pm):
        async def _verbose(ctx):  # noqa: RUF029
            self._verbose = not self._verbose
            ctx.console.print(f"verbose: {self._verbose}")
            return CommandResult()

        pm.commands.add(
            Command(name="verbose", description="…", handler=_verbose),
            replace=True,
        )

The basic frontend uses this pattern for /verbose and /footer. The handler closes over self, which is cleaner than threading "current frontend" through CommandContext.

Packaging

Distribute your plugin like any Python package. The only letscode-specific entry is the letscode entry-point group:

pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "letscode-myextension"
version = "0.1.0"
dependencies = ["letscode>=0.3", "httpx"]

[project.entry-points.letscode]
my_extension = "letscode_myextension"

The module named on the right (letscode_myextension in this example) must contain at least one @hookimpl. letscode discovers it via PluginManager.load_entry_points() at startup.

For a class-based plugin, the entry point can point to an instance — pluggy's load_setuptools_entrypoints accepts either a module or a callable that returns a plugin object:

letscode_myextension/__init__.py
from .plugin import MyPlugin


def get_plugin():
    return MyPlugin(some_config="...")
pyproject.toml
my_extension = "letscode_myextension:get_plugin"

Dev workflow note

Until the PyPI letscode namespace is resolved (see the extension model doc §5.12), develop against a local letscode checkout by adding to your pyproject.toml:

[tool.uv.sources]
letscode = { path = "/path/to/letscode", editable = true }

uv ignores [tool.uv.sources] when building your wheel, so this affects only local dev — published plugins still pull letscode from wherever the user is installing from.

Stable surface and versioning

Plugins should only import from the modules listed in Extension model §4. Briefly:

  • letscode.agent.* public types: Tool, ToolContext, ToolResult, messages, events, hookspecs, ExecutionEnv
  • letscode.llm.types
  • letscode.plugins.registries
  • letscode.commands.dispatch
  • letscode.skills.loader
  • letscode.frontends.protocol

Anything else — especially letscode.cli.*, PluginManager._pm, the agent loop — is internal and may change without notice.

Versioning rule (effective v0.3.0+):

  • Adding a new hookspec, new registry method, or new public type → minor bump, no plugin breakage.
  • Changing a hookspec signature or removing a public type → major bump, one minor's worth of deprecation when feasible.

A plugin that declares dependencies = ["letscode>=0.3,<0.4"] is following the conservative version range. If your plugin only uses the registration hooks and one or two lifecycle hooks, >=0.3 is usually safe.

Optional dependencies

Plugins that need a non-trivial dependency (httpx, redis, pygit2) should fail soft: skip registration when the dep is missing rather than crashing startup for users who haven't installed it.

try:
    import httpx
    _HAS_HTTPX = True
except ImportError:
    _HAS_HTTPX = False


@hookimpl
def letscode_register_tools(registry):
    if not _HAS_HTTPX:
        # Optional: log at INFO so users know why this plugin is quiet.
        return
    registry.add(_fetch_tool)

No framework machinery — the convention is enough.

Trust model

Plugins run with your full Python privileges

Installing a plugin is equivalent to running its code. letscode does not sandbox plugins or restrict their imports; a hookimpl can read your filesystem, exfiltrate data, or run arbitrary shell commands via ctx.env or directly. Only install plugins you trust, the same way you'd think about any Python dependency.

If you ship a plugin, consider documenting its trust expectations (does it phone home? does it write to ~/?). Users will appreciate it.

Testing

Plugins should be testable without spinning up the full CLI:

Test pluggy + registry wiring
import pluggy
from letscode.plugins.manager import PluginManager

pm = PluginManager()
pm.register(MyPlugin(), name="under-test")
pm.trigger_register_hooks()

# Now pm.tools, pm.commands, pm.skills, pm.frontends are populated.
assert "my-tool" in pm.tools

For lifecycle hooks, drive the agent loop directly with the FakeLLMClient helper:

Test lifecycle hooks against a fake LLM
from tests._helpers import FakeLLMClient
from letscode.agent.agent import Agent
from letscode.agent.state import AgentState
from letscode.agent.events import StopEvent, TextDelta

fake = FakeLLMClient()
fake.feed([TextDelta(delta="ok"), StopEvent(reason="end_turn")])

state = AgentState(system_prompt="", model="x", base_url="x", api_key="x")
agent = Agent(state, fake, pm=pm)
await agent.prompt("hi")
# Inspect what your hooks did via the agent's state / pm registries.

Where to look next

  • Extension model — design rationale, stable-surface contract, open questions, every audit gap and its decision.
  • plugins/letscode-memory/ — the worked example. Read plugin.py for the transform + render pattern; tests/test_plugin.py for how to test hooks in isolation.
  • src/letscode/plugins/builtin.py — the canonical built-in plugin. Every registration hook + the compaction renderer in action.
  • src/letscode/agent/hookspecs.py — every hook signature, with docstrings explaining when each fires.
  • src/letscode/commands/dispatch.pyCommandContext / CommandResult shapes.
  • src/letscode/agent/execution_env.pyExecutionEnv protocol + LocalEnv default.
  • tests/b_integration/test_lifecycle_hooks.py — examples of every hook being tested.