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¶
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:
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: anasyncio.Eventset when the user aborts. Long-running tools should await this and clean up.cwd: the agent's working directory.env: anExecutionEnvfor filesystem and shell operations (seeExecutionEnvoverride). Don't callpathliborsubprocessdirectly.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:
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:
@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.
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:
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:
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:
[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:
from .plugin import MyPlugin
def get_plugin():
return MyPlugin(some_config="...")
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:
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,ExecutionEnvletscode.llm.typesletscode.plugins.registriesletscode.commands.dispatchletscode.skills.loaderletscode.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:
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:
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. Readplugin.pyfor the transform + render pattern;tests/test_plugin.pyfor 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.py—CommandContext/CommandResultshapes.src/letscode/agent/execution_env.py—ExecutionEnvprotocol +LocalEnvdefault.tests/b_integration/test_lifecycle_hooks.py— examples of every hook being tested.