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. If your command answers to a fixed set of selectors, list them in Command(..., selectors=("panel", "verify")) so they tab-complete as /cmd:panel. It's a completion hint only and never restricts which ctx.selector the handler accepts.
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, and the first plugin returning a non-None LLMMessage claims it. Return None for kinds you don't own so several plugins can 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, while 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.
Dispatching subagents¶
A plugin that fans work out to child letscode -p processes (a batch driver, an audit suite, an autonomous loop) must make each child run on the model the parent is actually using. This bites because the parent's model frequently comes from a place that is not in os.environ: a restored session (-c), an interactive /model pick, or a --model flag. A child spawned with a bare os.environ.copy() falls through to config.model and can land on a completely different provider, silently.
Use subagent_env to pin the parent's resolved endpoint into the child's environment:
import asyncio
from letscode.agent.subagent import subagent_env
async def run_child(ctx, prompt: str) -> tuple[int, str]:
env = subagent_env(ctx.agent.state) # pins model / base_url / api_key / thinking
proc = await asyncio.create_subprocess_exec(
"letscode", "-p", "--no-session", prompt,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
out, _err = await proc.communicate()
return proc.returncode or 0, out.decode("utf-8", "replace")
ctx.agent.state is available on both ToolContext and CommandContext. subagent_env returns a fresh copy of the environment (it does not mutate os.environ), so it's safe to call per child. The subprocess boilerplate (timeout, kill semantics, output handling) is yours to shape; subagent_env is only the one piece every dispatcher needs and every ad-hoc version got wrong.
Frontend-owned commands¶
When a slash command needs frontend-private state (toggling a UI flag, switching themes, opening a file picker), register it directly with the plugin manager from the frontend's constructor:
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.
Installing (since v0.7)¶
Users install into their letscode env with:
letscode install letscode-myextension # from PyPI
letscode install /path/to/local/checkout # local path
letscode install "git+https://github.com/foo/bar.git#subdirectory=name" # from git
letscode install figures out whether the letscode env has pip in it (typical for pip / pipx / source installs) or needs uv pip (typical for uv tool install envs) and shells to whichever works. Anything pip accepts as a source spec works: PyPI names, local paths, git URLs with #subdirectory= for monorepo layouts. Extras work too: letscode install "letscode-myextension[extra]" installs the extra's dependencies.
Command-line tools: letscode run¶
A plugin can ship command-line tools as [project.scripts] console entry points:
letscode install puts those scripts in the shared plugin venv's bin/, which is deliberately not on PATH. letscode run is the front door:
letscode run --list # tools the installed plugins provide
letscode run mytool --flag arg # everything after the name is passed through verbatim
letscode run resolves the tool no matter the caller's directory, so a plugin tool is callable from anywhere without putting the plugin venv on PATH. --list shows only console scripts from distributions that are letscode plugins (those with a letscode entry point), so a dependency's stray scripts don't clutter it. --project looks in the active venv (where letscode install --project writes) instead of the shared one.
Inspecting: letscode plugins¶
letscode plugins lists what's installed and where: the shared per-user venv (~/.letscode/plugins/) versus the active environment, with versions:
It flags a plugin present in both tiers (the user-venv copy is searched first, so it usually wins). It also warns loudly when a shared venv exists but has no site-packages for the running Python, in which case its plugins are silently invisible to that letscode. This is the install-topology complement to the /plugins slash command, which lists what actually loaded at runtime. A plugin whose import fails is skipped with a warning rather than crashing the CLI, so /plugins (loaded) and letscode plugins (installed) can legitimately differ: the difference is the diagnostic.
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,ExecutionEnv,subagent_envletscode.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 starting 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.