Extension model¶
This page is the architectural contract for letscode plugins. For the authoring guide — "how do I write a tool?" — see Plugin authoring.
Goals
- Pin down the taxonomy: what kinds of extensions exist, how each is registered, what its stable shape is.
- Inventory every hook in one place — signature, timing, return semantics, first-result-or-not.
- State the stability contract explicitly: which modules plugins may import; which are internal.
- Document the versioning rule effective from v0.3.0+.
1. The five extension types¶
| Type | Registered via | Value object / Protocol | Discovery |
|---|---|---|---|
| Tool | letscode_register_tools(registry) |
Tool Protocol or @tool decorator |
Entry point → registration hook |
| Command | letscode_register_commands(registry) |
Command(name, description, handler) |
Entry point → registration hook |
| Skill | letscode_register_skills(registry) |
Skill (markdown + frontmatter) |
Hook adds a source path; loader walks SKILL.md |
| Frontend | letscode_register_frontends(registry) |
FrontendFactory(name, factory) satisfying Frontend Protocol |
Entry point → registration hook; CLI selects by name |
| Lifecycle hook | @hookimpl on a hookspec |
(free function — no registry) | Entry point → pluggy invokes per event |
The first four are named things you add to a registry. The fifth is behavior layered on the agent loop. This split is the whole model — everything else is detail.
2. Hook inventory¶
Ten hookspecs total, declared in src/letscode/agent/hookspecs.py. The table is the canonical list — if a hook is not in this table, it does not exist.
| Hook | When it fires | Signature | Returns | firstresult |
|---|---|---|---|---|
letscode_register_tools |
startup | (registry: ToolRegistry) |
None (mutates) |
no |
letscode_register_commands |
startup | (registry: CommandRegistry) |
None (mutates) |
no |
letscode_register_skills |
startup | (registry: SkillRegistry) |
None (mutates) |
no |
letscode_register_frontends |
startup | (registry: FrontendRegistry) |
None (mutates) |
no |
letscode_system_prompt |
every turn, before LLM call | (state: AgentState) |
str \| None |
yes |
letscode_transform_context |
every turn, after system prompt | (messages: list[AgentMessage]) |
list[AgentMessage] \| None |
no (first non-None wins per loop) |
letscode_convert_to_llm |
every turn, after transform_context |
(messages: Sequence[AgentMessage]) |
list[LLMMessage] \| None |
no (first non-None wins per loop) |
letscode_render_custom_message |
per CustomMessage in default seam |
(message: CustomMessage) |
LLMMessage \| None |
yes |
letscode_before_tool_call |
per pending tool call | (tool_call, args, context) |
BlockDecision \| None |
no (any block wins) |
letscode_after_tool_call |
per completed tool call | (tool_call, result, context) |
ResultOverride \| None |
no (overrides fold) |
letscode_on_event |
per event emitted on the bus | (event: Event) |
None (fire-and-observe) |
no |
A few non-obvious notes:
firstresultvs. "first non-None wins per loop".letscode_system_promptandletscode_render_custom_messageare declaredfirstresult=True(pluggy short-circuits at the first non-None). The plural-result hooks (transform_context,convert_to_llm) are notfirstresultfor technical reasons (we need to await async impls), but the agent loop applies "first non-None wins" in code. Net effect is identical for plugin authors.- Pluggy invokes hookimpls in reverse registration order (LIFO). A plugin registered after the built-in wins over the built-in for any non-None-wins hook. This is how plugins override defaults without touching core.
on_eventis observe-only. It cannot influence agent behavior. Usebefore/after_tool_callortransform_contextif you need to intervene.before/after_tool_callget the validatedargsPydantic model, not raw JSON. Validation is the loop's job, not the hook's.
3. Discovery — three paths¶
load_builtin()— registersletscode.plugins.builtinunconditionally. The four built-in tools, six commands, default skill sources,basicfrontend, default system prompt, and compaction-marker renderer all live here.-
load_entry_points()— callspluggy.PluginManager.load_setuptools_entrypoints("letscode"). Third-party plugins declare: -
Filesystem (skills only) —
SkillRegistry.add_source(path)registers a directory;pm.skills.load()parses everySKILL.mdunderneath. The built-in plugin registers the four~/.{letscode,agents,pi,claude}/skills/paths and the four matching project-local paths.
There is currently no project-local plugin-discovery path (i.e. no .letscode/plugins/). See §5.7 below.
4. Stability contract¶
The contract a third-party plugin can rely on across minor versions:
letscode.agent.tools—Tool,ToolContext,ToolResult,ToolError,ToolPartialResult,tooldecorator,ToolRegistryletscode.agent.messages—AgentMessagediscriminated union,UserMessage,AssistantMessage,ToolCallMessage,ToolResultMessage,CustomMessage,LLMMessageletscode.agent.events—Eventdiscriminated union and concrete event typesletscode.agent.execution_env—ExecutionEnvProtocol,LocalEnv,FileErrorletscode.agent.hookspecs— every@hookspecdeclared there, plusBlockDecision,ResultOverride,AgentContextletscode.agent.state—AgentState(read-only-ish from a plugin's POV)letscode.llm.types—TextPart,ContentPart,ToolCall, etc.letscode.plugins.registries—Command,Skill,FrontendFactory, all four registriesletscode.commands.dispatch—CommandContext,CommandResultletscode.frontends.protocol—FrontendProtocolletscode.skills.loader—Skill,parse_skill
- Anything under
letscode.cli.*(it's the CLI shell, not a library) PluginManager._pm(the underlying pluggy instance — useregister()/ hooks instead)- Anything starting with
_ - Anything under
letscode.agent.loop(the loop is the orchestrator, not a public API) letscode.frontends.basic.*internals (frontend implementations are not a stable contract — onlyFrontendFactoryandFrontendProtocol are)
Versioning rule (effective v0.3.0+)¶
- Adding a new hookspec, new registry method, new public type → minor bump, no plugin breakage.
- Changing a hookspec signature, removing a public type → major bump, deprecation cycle of one minor release where possible.
- Anything internal can change at any time without notice.
A plugin that declares dependencies = ["letscode>=0.3,<0.4"] is following the conservative range. If your plugin only uses the registration hooks and one or two lifecycle hooks, >=0.3 is usually safe.
5. Audit findings — status¶
The original audit of the extension surface surfaced twelve potential gaps. Status retrofitted at end of v0.3:
5.1 Frontend-owned commands ✅ already documented¶
BasicFrontend.__init__ registers /verbose and /footer directly via pm.commands.add(..., replace=True). Frontends should be able to register state-dependent commands; replace=True makes the pattern collision-safe. Documented in Plugin authoring §Frontend-owned commands.
5.2 letscode_system_prompt LIFO semantics ✅ documented¶
Pluggy LIFO + firstresult=True means a plugin registered after the built-in wins. Documented in Plugin authoring §letscode_system_prompt.
5.3 Optional dependencies ✅ documented¶
Convention: try/except ImportError at module level, skip registration if missing. Documented in Plugin authoring §Optional dependencies.
5.4 FrontendFactory.factory typing ✅ resolved in v0.3¶
New letscode.frontends.protocol.Frontend Protocol. FrontendFactory.factory tightened to Callable[..., Frontend]. Backwards-compatible.
5.5 Version contract ✅ documented¶
See §4 above. v0.3.0 marks the start of the contract.
5.6 /plugins command ✅ resolved in v0.3¶
Lists every loaded plugin via pm._pm.list_name_plugin + list_plugin_distinfo.
5.7 Project-local plugin discovery¶
A user prototyping a plugin must pip install -e . to get entry-point discovery. For one-file experiments that's friction. No demand surfaced in v0.3; deferred indefinitely. Could resurface if a future plugin author hits the wall.
5.8 Skill.files companion files¶
Original design §6.3 promised it; not implemented. Provisional defer. Revisit when a skill author actually needs to ship template files alongside their skill.
5.9 Event subscription beyond on_event¶
Fine for v0.3. Revisit when there are >20 event types.
5.10 Trust model ✅ documented¶
Plugin authoring §Trust model.
5.11 convert_to_llm does not compose across plugins ✅ resolved in v0.3¶
Surfaced by writing the letscode-memory plugin. The natural injection path — a CustomMessage(kind="memory") + a letscode_convert_to_llm hook that expands it — collided with the built-in's compaction handler. Pluggy LIFO + "first non-None wins" meant a memory plugin's convert_to_llm shadowed the built-in's compaction expansion.
Resolved in v0.3: introduced a per-CustomMessage-kind dispatch:
@hookspec(firstresult=True)
def letscode_render_custom_message(message: CustomMessage) -> LLMMessage | None: ...
The default convert_to_llm seam iterates messages and, for each CustomMessage, calls this per-kind hook. Plugins register one renderer per kind they own; composition is automatic. The built-in's compaction handler migrated; letscode-memory followed.
5.12 PyPI name letscode is taken — dev-only sources hack required¶
The name letscode resolves on PyPI to an unrelated package. Two bundled issues:
- Plugin-author workflow needs the
[tool.uv.sources]hack for local dev. Documented in Plugin authoring §Dev workflow note. - PyPI namespace is occupied. Release blocker, parked for end-of-cycle decision: rename, contact owner about transfer, or self-host.
6. Outcome — v0.3 close¶
What landed in v0.3:
- §5.1, §5.2, §5.3, §5.5, §5.10 — doc-only fixes in the plugin guide refresh.
- §5.4 —
FrontendProtocol. - §5.6 —
/pluginscommand. - §5.11 —
letscode_render_custom_messagehookspec + reference-plugin migration.
What remains open:
- §5.7 — project-local plugin discovery. Deferred indefinitely.
- §5.8 —
Skill.filescompanion files. Provisional defer. - §5.9 — event subscription scaling. Fine as-is.
- §5.12 — PyPI namespace decision. Release blocker.
That's 8 closed, 3 punted, 1 release-blocker — matches the design doc's prediction ("six of ten are write-words-not-code; two are S-sized code changes; one is an external/legal track").
7. Validation plan ✅ executed¶
The validation cycle ran as planned:
Writing the letscode-memory reference plugin surfaced exactly the kind of friction the audit hoped for: §5.11 composition gap, §5.12 namespace conflict, §4 stable-surface gap (letscode.commands.dispatch was missing from the original list). The doc survived without major revision. The extension model is sound.
For any future changes to the surface, run the same loop.