@letta-ai/letta-code 0.27.4 → 0.27.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,285 @@
1
+ # Plan mode extension example
2
+
3
+ Use this as the canonical multi-capability extension example. It composes a slash command, model-callable tools, turn reminders, permission overlays, and local state to recreate the old built-in plan-mode flow with extension APIs.
4
+
5
+ This is a pattern reference, not a full product implementation. Keep local extensions self-contained and avoid importing Letta Code internals.
6
+
7
+ ## Contents
8
+
9
+ - Flow
10
+ - Capabilities used
11
+ - State
12
+ - Entry command and tool
13
+ - Turn reminder
14
+ - Permission overlay
15
+ - Exit tool
16
+ - Notes
17
+
18
+ ## Flow
19
+
20
+ ```text
21
+ /plan or enter_plan_mode
22
+ -> create ~/.letta/plans/<random>.md
23
+ -> remember active plan state for this conversation
24
+ -> remind the agent that only read-only tools and plan-file writes are allowed
25
+ -> permission overlay denies mutations outside ~/.letta/plans/*.md
26
+ -> agent writes the plan with normal Write/Edit/ApplyPatch tools
27
+ -> agent reads the plan and calls AskUserQuestion with Approve / Revise
28
+ -> if approved, agent calls exit_plan_mode
29
+ -> exit_plan_mode clears state and returns the approved-plan execution handoff
30
+ ```
31
+
32
+ Plan files are normal markdown files. Do not add a special `update_plan_file` tool unless the user explicitly wants that abstraction. Let the agent use normal write tools and constrain those tools with permissions.
33
+
34
+ ## Capabilities used
35
+
36
+ Guard each registration with the matching capability:
37
+
38
+ - `commands`: `/plan` for explicit human entry
39
+ - `tools`: `enter_plan_mode` and `exit_plan_mode` for model-driven entry/exit
40
+ - `events.turns`: append a focused plan-mode reminder while active
41
+ - `permissions`: block mutating tools except plan-file writes
42
+
43
+ Do not use panels for persistent mode state. Panels are transient UI and can be noisy/fragile for mode indicators. Do not add a custom statusline renderer just to show plan mode; `setStatuslineRenderer` is a single global renderer, not an additive slot. This example intentionally keeps visible mode state out of scope.
44
+
45
+ ## State
46
+
47
+ Use small local state under `~/.letta/extensions/`, keyed by conversation ID:
48
+
49
+ ```ts
50
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
51
+ import { homedir } from "node:os";
52
+ import { join, relative } from "node:path";
53
+
54
+ const PLANS_DIR = join(homedir(), ".letta", "plans");
55
+ const STATE_PATH = join(homedir(), ".letta", "extensions", "plan-mode.state.json");
56
+ const GLOBAL_CONVERSATION_ID = "__global__";
57
+
58
+ type PlanSession = {
59
+ conversationId: string;
60
+ planFilePath: string;
61
+ startedAt: number;
62
+ cwd: string;
63
+ };
64
+
65
+ type PlanState = { sessions: Record<string, PlanSession> };
66
+
67
+ function conversationKey(id: string | null | undefined): string {
68
+ return id || GLOBAL_CONVERSATION_ID;
69
+ }
70
+
71
+ function readState(): PlanState {
72
+ try {
73
+ if (!existsSync(STATE_PATH)) return { sessions: {} };
74
+ const parsed = JSON.parse(readFileSync(STATE_PATH, "utf8"));
75
+ return parsed?.sessions ? { sessions: parsed.sessions } : { sessions: {} };
76
+ } catch {
77
+ return { sessions: {} };
78
+ }
79
+ }
80
+
81
+ function writeState(state: PlanState): void {
82
+ mkdirSync(join(homedir(), ".letta", "extensions"), { recursive: true });
83
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
84
+ }
85
+ ```
86
+
87
+ Generate plan paths under `~/.letta/plans/`. The old built-in used random adjective/adjective/noun names like `zesty-dazzling-coral.md`; any collision-resistant readable name is fine.
88
+
89
+ ## Entry command and tool
90
+
91
+ `/plan` and `enter_plan_mode` should call the same activation helper. The command returns a prompt/system reminder so the agent receives the path. The tool returns the same text as a tool result.
92
+
93
+ ```ts
94
+ function buildEnterPlanModeMessage(session, cwd) {
95
+ const relativePatchPath = relative(cwd, session.planFilePath).replace(/\\/g, "/");
96
+ return `Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.
97
+
98
+ In plan mode, you should:
99
+ 1. Thoroughly explore the codebase to understand existing patterns
100
+ 2. Identify similar features and architectural approaches
101
+ 3. Consider multiple approaches and their trade-offs
102
+ 4. Use AskUserQuestion if you need to clarify the approach
103
+ 5. Design a concrete implementation strategy
104
+ 6. When ready, write the plan to the plan file, use AskUserQuestion to present the full plan for approval, and call exit_plan_mode after the user approves
105
+
106
+ Remember: DO NOT write or edit any files except the plan file. This is a read-only exploration and planning phase.
107
+
108
+ Plan file path: ${session.planFilePath}
109
+ If using apply_patch, use this exact relative patch path: ${relativePatchPath}`;
110
+ }
111
+
112
+ export default function activate(letta) {
113
+ const disposers = [];
114
+
115
+ if (letta.capabilities.commands) {
116
+ disposers.push(letta.commands.register({
117
+ id: "plan",
118
+ description: "Enter plan mode",
119
+ override: true,
120
+ run(ctx) {
121
+ const session = activatePlanMode(ctx.conversation.id, ctx.cwd);
122
+ return {
123
+ type: "prompt",
124
+ systemReminder: true,
125
+ content: buildEnterPlanModeMessage(session, ctx.cwd),
126
+ };
127
+ },
128
+ }));
129
+ }
130
+
131
+ if (letta.capabilities.tools) {
132
+ disposers.push(letta.tools.register({
133
+ name: "enter_plan_mode",
134
+ description:
135
+ "Enter plan mode before a non-trivial implementation task. Use this for new features, multi-file changes, architectural decisions, unclear requirements, or tasks where the user should approve the approach before implementation.",
136
+ parameters: { type: "object", properties: {}, additionalProperties: false },
137
+ requiresApproval: true,
138
+ parallelSafe: false,
139
+ run(ctx) {
140
+ const session = activatePlanMode(ctx.conversation.id, ctx.cwd);
141
+ return buildEnterPlanModeMessage(session, ctx.cwd);
142
+ },
143
+ }));
144
+ }
145
+
146
+ return () => disposers.reverse().forEach((dispose) => dispose());
147
+ }
148
+ ```
149
+
150
+ ## Turn reminder
151
+
152
+ Append a reminder while plan mode is active. Keep it narrow and explicit about the allowed plan-file exception:
153
+
154
+ ```ts
155
+ function buildActiveReminder(session, cwd) {
156
+ const relativePatchPath = relative(cwd, session.planFilePath).replace(/\\/g, "/");
157
+ return `<system-reminder>
158
+ Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. Instead, you should:
159
+ 1. Answer the user's query comprehensively, using the AskUserQuestion tool if you need to ask the user clarifying questions.
160
+ 2. Write your implementation plan to the plan file. Plan file path: ${session.planFilePath}
161
+ 3. If using apply_patch, use this exact relative path in patch headers: ${relativePatchPath}
162
+ 4. When the plan is complete, read the plan file and present the full plan to the user with AskUserQuestion. The question should offer at least "Approve" and "Revise" options.
163
+ 5. If the user approves, call exit_plan_mode immediately. If the user asks to revise, stay in plan mode and update the plan file.
164
+ Do NOT make any file changes outside the plan file or run any tools that modify the system state until the user has approved the plan and you have called exit_plan_mode.
165
+ </system-reminder>`;
166
+ }
167
+
168
+ if (letta.capabilities.events.turns) {
169
+ disposers.push(letta.events.on("turn_start", (event) => {
170
+ const session = getSession(event.conversationId);
171
+ if (!session) return;
172
+ return { input: [{ role: "user", content: buildActiveReminder(session, session.cwd) }, ...event.input] };
173
+ }));
174
+ }
175
+ ```
176
+
177
+ ## Permission overlay
178
+
179
+ Use a permission overlay, not `tool_start`, for policy. Normalize tool names by family; UI display names and provider-specific tool names drift (`Read`, `read`, `read_file`, `ReadFile`, `SearchFileContent`, etc.).
180
+
181
+ ```ts
182
+ const readOnlyToolNames = new Set([
183
+ "askuserquestion",
184
+ "ask_user_question",
185
+ "glob",
186
+ "grep",
187
+ "listdir",
188
+ "list_directory",
189
+ "ls",
190
+ "read",
191
+ "read_file",
192
+ "readfile",
193
+ "search",
194
+ "search_file_content",
195
+ "skill",
196
+ "taskoutput",
197
+ "update_plan",
198
+ "view_image",
199
+ ]);
200
+
201
+ function normalizedToolName(toolName) {
202
+ return toolName.replace(/[\s-]/g, "").toLowerCase();
203
+ }
204
+
205
+ function isReadOnlyToolName(toolName) {
206
+ const raw = toolName.toLowerCase();
207
+ return readOnlyToolNames.has(raw) || readOnlyToolNames.has(normalizedToolName(toolName));
208
+ }
209
+
210
+ function isPlanFileWrite(toolName, args, cwd) {
211
+ // For Write/Edit-style tools, check file_path/path/notebook_path.
212
+ // For ApplyPatch-style tools, parse *** Add/Update/Delete File and *** Move to directives.
213
+ // Allow only if every target resolves to a .md file under ~/.letta/plans/.
214
+ }
215
+
216
+ if (letta.capabilities.permissions) {
217
+ disposers.push(letta.permissions.register({
218
+ id: "plan-mode",
219
+ description: "Allow read-only tools and writes only to ~/.letta/plans/*.md while plan mode is active.",
220
+ check(event) {
221
+ const session = getSession(event.conversationId);
222
+ if (!session) return;
223
+
224
+ if (isReadOnlyToolName(event.toolName)) return { decision: "allow" };
225
+ if (isPlanFileWrite(event.toolName, event.args, event.workingDirectory || event.cwd)) {
226
+ return { decision: "allow", reason: "plan file" };
227
+ }
228
+
229
+ return {
230
+ decision: "deny",
231
+ reason:
232
+ `Plan mode is active. You can only use read-only tools (Read, Grep, Glob, etc.) and write to the plan file. ` +
233
+ `Write your plan to: ${session.planFilePath}. ` +
234
+ `Use AskUserQuestion when your plan is ready for user approval, then call exit_plan_mode after approval.`,
235
+ };
236
+ },
237
+ }));
238
+ }
239
+ ```
240
+
241
+ Shell allowlists are easy to get wrong. Start conservative: allow clearly read-only shell commands if needed, plus a narrow plan-file heredoc or `mv old.md new.md` only when every target is inside `~/.letta/plans/*.md`. Deny mutating shell patterns such as `sed -i`, `find -delete`, `rm`, `cp`, `touch`, package installs, and arbitrary interpreters.
242
+
243
+ ## Exit tool
244
+
245
+ In the extension version, `exit_plan_mode` is not the approval UI. The agent should present the plan with `AskUserQuestion` first, then call `exit_plan_mode` only after the user approves.
246
+
247
+ ```ts
248
+ if (letta.capabilities.tools) {
249
+ disposers.push(letta.tools.register({
250
+ name: "exit_plan_mode",
251
+ description:
252
+ "Exit plan mode only after the plan file has been written, the full plan has been presented with AskUserQuestion, and the user has approved it.",
253
+ parameters: { type: "object", properties: {}, additionalProperties: false },
254
+ requiresApproval: false,
255
+ parallelSafe: false,
256
+ run(ctx) {
257
+ const session = getSession(ctx.conversation.id);
258
+ if (!session) {
259
+ return { status: "error", content: "Plan mode is not active for this conversation." };
260
+ }
261
+ if (!planFileExists(session)) {
262
+ return {
263
+ status: "error",
264
+ content:
265
+ `You must write your plan to a plan file before exiting plan mode.\n` +
266
+ `Plan file path: ${session.planFilePath}\n` +
267
+ `Use a write tool to create your plan in ${PLANS_DIR}, then use AskUserQuestion to present the plan to the user.`,
268
+ };
269
+ }
270
+
271
+ clearSession(ctx.conversation.id);
272
+ return (
273
+ "User has approved your plan. You can now start coding.\n" +
274
+ "Start with updating your todo list if applicable.\n\n" +
275
+ "Tip: If this plan will be referenced in the future by your future-self, other agents, or humans, consider renaming the plan file to something easily identifiable with a timestamp (e.g., `2026-01-auth-refactor.md`) rather than the random name."
276
+ );
277
+ },
278
+ }));
279
+ }
280
+ ```
281
+
282
+ ## Notes
283
+
284
+ - Keep `exit_plan_mode` as the final state transition and execution handoff. The approved-plan text in its tool return is useful model context.
285
+ - If the user renames the plan file, exit logic can use the newest non-empty `~/.letta/plans/*.md` modified after plan mode started, or accept an optional plan path. Keep the user-facing flow normal: write plan file, ask approval, then exit.
@@ -0,0 +1,110 @@
1
+ # Extension provider recipes
2
+
3
+ Use provider extensions when the user wants a **local agent** to use a model provider that is not built into `/connect` and `/model`.
4
+
5
+ Important: provider extensions are local-backend/local-agent only. They register local provider metadata for the TUI, headless local runtime, and desktop listener. They do not add providers for Constellation/cloud agents.
6
+
7
+ For multi-capability extensions that combine a provider with commands, tools, UI, or state, also read `architecture.md`.
8
+
9
+ ## Quick pattern
10
+
11
+ ```ts
12
+ // ~/.letta/extensions/kilo.ts
13
+ export default function activate(letta) {
14
+ if (!letta.capabilities.providers) return;
15
+
16
+ return letta.providers.register("kilo", {
17
+ name: "Kilo",
18
+ description: "Connect to Kilo's OpenAI-compatible API",
19
+ api: "openai-completions",
20
+ baseUrl: "https://api.kilo.example/v1",
21
+ apiKey: "KILO_API_KEY", // env var name, not the raw secret
22
+ authHeader: true,
23
+ models: [
24
+ {
25
+ id: "kilo-code",
26
+ name: "Kilo Code",
27
+ reasoning: true,
28
+ input: ["text"],
29
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
30
+ contextWindow: 128000,
31
+ maxTokens: 8192,
32
+ compat: {
33
+ supportsDeveloperRole: false,
34
+ supportsReasoningEffort: false,
35
+ },
36
+ },
37
+ ],
38
+ connect: {
39
+ fields: [{ key: "apiKey", label: "Kilo API Key", secret: true }],
40
+ },
41
+ });
42
+ }
43
+ ```
44
+
45
+ After `/reload`, the provider appears in local `/connect` and desktop Connect model providers. Model handles are `<provider-id>/<model-id>`, for example `kilo/kilo-code`.
46
+
47
+ ## Key rules
48
+
49
+ - Always guard with `letta.capabilities.providers`.
50
+ - Prefer `letta.providers.register(...)` over legacy `letta.registerProvider(...)`.
51
+ - Keep provider registration independent from commands/tools/UI/events and `letta.client`; the desktop listener loads provider-only extensions.
52
+ - Do not hardcode real secrets. `apiKey: "ENV_VAR"` resolves `process.env.ENV_VAR` when present, or lets `/connect` save a local key.
53
+ - Use stable lowercase provider ids. Model ids must be unprefixed and must not contain `/`.
54
+ - Set `api` at provider or model level. Common values include `"openai-completions"`, `"openai-responses"`, `"anthropic-messages"`, and `"bedrock-converse-stream"`; check `src/backend/dev/pi-provider-extension-types.ts` and pi-ai model types before using uncommon values.
55
+
56
+ ## Model metadata
57
+
58
+ Each model needs enough local runtime metadata for selection and context display:
59
+
60
+ ```ts
61
+ {
62
+ id: "model-id-without-provider-prefix",
63
+ name: "Display Name",
64
+ reasoning: false,
65
+ input: ["text"], // or ["text", "image"]
66
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
67
+ contextWindow: 128000,
68
+ maxTokens: 8192,
69
+ // Optional: compat: { supportsDeveloperRole: false, supportsReasoningEffort: false }
70
+ }
71
+ ```
72
+
73
+ Do not set `model.baseUrl` when all models use the provider-level URL. Model-level `baseUrl` overrides the connected provider base URL, so `/connect` base URL overrides are ignored. Use it only when a specific model intentionally needs a different endpoint.
74
+
75
+ ## Connect fields
76
+
77
+ - `connect: undefined` / `true` uses default API key + base URL fields.
78
+ - `connect: { fields: [...] }` customizes local `/connect` / desktop fields.
79
+ - `connect: false` hides the provider from `/connect`; use only when credentials come entirely from env/local code.
80
+ - Custom fields are currently required. TUI pre-fills non-secret placeholders for convenience, but placeholders are not backend/protocol defaults.
81
+ - If the provider has a normal fixed `baseUrl`, set provider-level `baseUrl` and omit `baseUrl` from `connect.fields`; the local runtime uses the provider-level URL after the API key is saved.
82
+ - Include `{ key: "baseUrl", ... }` only when the user must enter or review/override the endpoint during connect.
83
+
84
+ ## Dynamic model discovery
85
+
86
+ Use `listModels(connection)` only when the provider exposes a models endpoint or the model list depends on credentials:
87
+
88
+ ```ts
89
+ async listModels(connection) {
90
+ const response = await fetch(`${connection.baseUrl}/models`, {
91
+ headers: {
92
+ Authorization: `Bearer ${connection.apiKey}`,
93
+ ...connection.headers,
94
+ },
95
+ });
96
+ if (!response.ok) throw new Error(`Model list failed: ${response.status}`);
97
+ const body = await response.json();
98
+ return body.data.map((model) => ({
99
+ id: model.id,
100
+ name: model.id,
101
+ reasoning: false,
102
+ input: ["text"],
103
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
104
+ contextWindow: 128000,
105
+ maxTokens: 8192,
106
+ }));
107
+ }
108
+ ```
109
+
110
+ `connection` has `{ id, providerName, baseUrl?, apiKey?, headers? }`. Keep dynamic listing lightweight; if it is flaky, prefer static `models`.
@@ -4,6 +4,13 @@ Use tools when the agent/model should call a local capability autonomously.
4
4
 
5
5
  For tools that are part of a larger extension with commands, UI, local state, or events, also read `architecture.md`.
6
6
 
7
+ ## Contents
8
+
9
+ - Defaults
10
+ - Read-only shell tool
11
+ - Tool with arguments
12
+ - Mutating or risky tool
13
+
7
14
  ## Defaults
8
15
 
9
16
  - Name: lowercase/underscore tool name, e.g. `branch_summary`.
@@ -5,7 +5,7 @@ description: Creates, edits, and enables Letta Code extension-provided slash com
5
5
 
6
6
  # Customizing Commands
7
7
 
8
- Use this as the command-specific entrypoint for local extension slash commands. For broader extension work, recipes live in `../creating-extensions/references/commands.md`, `../creating-extensions/references/architecture.md`, `../creating-extensions/references/ui.md`, and `../creating-extensions/references/btw-command.md`.
8
+ Use this as the command-specific entrypoint for local extension slash commands. For broader extension work, recipes live in `../creating-extensions/references/commands.md`, `../creating-extensions/references/architecture.md`, `../creating-extensions/references/ui.md`, and `../creating-extensions/references/plan-mode.md`.
9
9
 
10
10
  Extension files live in:
11
11
 
@@ -87,4 +87,4 @@ type ExtensionCommandResult =
87
87
  - Simple output command, panel command, busy-safe conversation command: `../creating-extensions/references/commands.md`
88
88
  - Complex command architecture, state, cleanup: `../creating-extensions/references/architecture.md`
89
89
  - Panel/status UI patterns: `../creating-extensions/references/ui.md`
90
- - Complete `/btw` side-question recipe: `../creating-extensions/references/btw-command.md`
90
+ - Worked plan-mode command/tool composition: `../creating-extensions/references/plan-mode.md`
@@ -1,106 +0,0 @@
1
- # `/btw` side-question extension example
2
-
3
- This example runs while the main agent is busy because it forks the scoped conversation, streams a response in the fork, renders progress in a panel when panels are available, and returns `{ type: "handled" }` immediately.
4
-
5
- ```ts
6
- export default function activate(letta) {
7
- if (!letta.capabilities.commands) return;
8
-
9
- function createOtid() {
10
- return (
11
- globalThis.crypto?.randomUUID?.() ??
12
- `btw-${Date.now()}-${Math.random().toString(16).slice(2)}`
13
- );
14
- }
15
-
16
- function appendAssistantText(chunk, parts) {
17
- if (chunk.message_type !== "assistant_message") return;
18
- const content = chunk.content;
19
- if (typeof content === "string") {
20
- parts.push(content);
21
- return;
22
- }
23
- if (Array.isArray(content)) {
24
- for (const part of content) {
25
- if (part && typeof part === "object" && "text" in part) {
26
- parts.push(String(part.text));
27
- }
28
- }
29
- return;
30
- }
31
- if (content && typeof content === "object" && "text" in content) {
32
- parts.push(String(content.text));
33
- }
34
- }
35
-
36
- function openPanelOrNull(content) {
37
- if (!letta.capabilities.ui.panels) return null;
38
- return letta.ui.openPanel({ id: "btw", content });
39
- }
40
-
41
- return letta.commands.register({
42
- id: "btw",
43
- description: "Ask a side question in a forked conversation",
44
- args: "<question>",
45
- runWhenBusy: true,
46
- showInTranscript: false,
47
- run(ctx) {
48
- const question = ctx.args.trim();
49
- if (!question) {
50
- const panel = openPanelOrNull(["/btw", "Usage: /btw <question>"]);
51
- if (panel) setTimeout(() => panel.close(), 5_000);
52
- return { type: "handled" };
53
- }
54
-
55
- const panel = openPanelOrNull([`/btw ${question}`, "..."]);
56
-
57
- void (async () => {
58
- try {
59
- const forked = await ctx.conversation.fork({ hidden: true });
60
- const stream = await forked.sendMessageStream(
61
- [
62
- {
63
- role: "user",
64
- content: `${question}\n\nAnswer briefly in 1-3 short sentences.`,
65
- otid: createOtid(),
66
- },
67
- ],
68
- {
69
- overrideModel: ctx.model.id ?? undefined,
70
- workingDirectory: ctx.cwd,
71
- },
72
- );
73
-
74
- const parts = [];
75
- for await (const chunk of stream) {
76
- appendAssistantText(chunk, parts);
77
- panel?.update({
78
- content: [`/btw ${question}`, parts.join("") || "..."],
79
- });
80
- }
81
-
82
- panel?.update({
83
- content: [
84
- `done /btw ${question}`,
85
- parts.join("").trim() || "No response.",
86
- ],
87
- });
88
- if (panel) setTimeout(() => panel.close(), 10_000);
89
- } catch (error) {
90
- panel?.update({
91
- content: [
92
- `error /btw ${question}`,
93
- error instanceof Error ? error.message : String(error),
94
- ],
95
- });
96
- if (panel) setTimeout(() => panel.close(), 15_000);
97
- }
98
- })();
99
-
100
- return { type: "handled" };
101
- },
102
- });
103
- }
104
- ```
105
-
106
- Add custom borders, right alignment, wrapping, or history only if the user asks for that polish.