@letta-ai/letta-code 0.27.17 → 0.27.19

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.
Files changed (30) hide show
  1. package/README.md +7 -0
  2. package/dist/app-server-client.js +11 -1
  3. package/dist/app-server-client.js.map +3 -3
  4. package/dist/types/app-server-client.d.ts +4 -1
  5. package/dist/types/app-server-client.d.ts.map +1 -1
  6. package/dist/types/types/protocol.d.ts +2 -1
  7. package/dist/types/types/protocol.d.ts.map +1 -1
  8. package/dist/types/types/protocol_v2.d.ts +17 -3
  9. package/dist/types/types/protocol_v2.d.ts.map +1 -1
  10. package/docs/examples/mods/README.md +60 -0
  11. package/docs/examples/mods/learning/memory-citations.env.json +165 -0
  12. package/docs/examples/mods/learning/uv-pip-install.env.json +199 -0
  13. package/docs/examples/mods/memory-citations.ts +347 -0
  14. package/letta.js +125665 -122383
  15. package/package.json +4 -2
  16. package/scripts/mod-learning/learn-mod.ts +306 -0
  17. package/skills/{context_doctor → context-doctor}/SKILL.md +1 -1
  18. package/skills/creating-mods/SKILL.md +10 -7
  19. package/skills/creating-mods/references/architecture.md +15 -4
  20. package/skills/creating-mods/references/commands.md +2 -1
  21. package/skills/creating-mods/references/events.md +141 -10
  22. package/skills/creating-mods/references/plan-mode.md +1 -1
  23. package/skills/creating-mods/references/ui.md +61 -23
  24. package/skills/customizing-statusline/SKILL.md +13 -14
  25. package/skills/customizing-statusline/references/api.md +74 -86
  26. package/skills/customizing-statusline/references/examples.md +79 -51
  27. package/skills/customizing-statusline/references/migration.md +38 -19
  28. package/skills/generating-mod-envs/SKILL.md +130 -0
  29. package/skills/generating-mod-envs/assets/mod-learning-env.template.json +57 -0
  30. package/skills/generating-mod-envs/scripts/validate-mod-env.ts +261 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letta-ai/letta-code",
3
- "version": "0.27.17",
3
+ "version": "0.27.19",
4
4
  "description": "Letta Code is a CLI tool for interacting with stateful Letta agents from the terminal.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.0",
@@ -47,6 +47,7 @@
47
47
  "@earendil-works/pi-ai": "^0.79.6",
48
48
  "@letta-ai/letta-client": "^1.10.2",
49
49
  "@pierre/diffs": "1.2.2",
50
+ "@scarf/scarf": "^1.4.0",
50
51
  "glob": "^13.0.0",
51
52
  "ink-link": "^5.0.0",
52
53
  "node-pty": "^1.1.0",
@@ -97,7 +98,8 @@
97
98
  "test:update-chain:manual": "bun run src/test-utils/update-chain-smoke.ts --mode manual",
98
99
  "test:update-chain:startup": "bun run src/test-utils/update-chain-smoke.ts --mode startup",
99
100
  "prepublishOnly": "bun run build",
100
- "postinstall": "node scripts/postinstall-patches.js || echo letta: vendor patches skipped && node -e \"try{require('fs').chmodSync(require('path').join(require.resolve('node-pty/package.json'),'../prebuilds/darwin-arm64/spawn-helper'),0o755)}catch(e){}\" || true"
101
+ "postinstall": "node scripts/postinstall-patches.js || echo letta: vendor patches skipped && node -e \"try{require('fs').chmodSync(require('path').join(require.resolve('node-pty/package.json'),'../prebuilds/darwin-arm64/spawn-helper'),0o755)}catch(e){}\" || true",
102
+ "mod-learning:memory-citations": "bun scripts/mod-learning/learn-mod.ts --env docs/examples/mods/learning/memory-citations.env.json"
101
103
  },
102
104
  "lint-staged": {
103
105
  "*.{ts,tsx,js,jsx,json}": [
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env bun
2
+ import { spawn } from "node:child_process";
3
+ import { closeSync, openSync } from "node:fs";
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import {
8
+ defaultModLearningRunDirectory,
9
+ type ModLearningProgress,
10
+ type ModLearningReport,
11
+ readModLearningEnv,
12
+ runModLearning,
13
+ } from "../../src/mods/learning-harness.ts";
14
+
15
+ const DEFAULT_OPTIMIZATION_STEPS = 5;
16
+
17
+ interface Args {
18
+ backend?: string;
19
+ candidate?: string;
20
+ candidateCount?: number;
21
+ candidateFileName?: string;
22
+ evalModel?: string;
23
+ generationModel?: string;
24
+ help: boolean;
25
+ env: string;
26
+ foreground: boolean;
27
+ out?: string;
28
+ promoteTo?: string;
29
+ repoRoot: string;
30
+ scenarioLimit?: number;
31
+ skipGeneration: boolean;
32
+ }
33
+
34
+ function readOptionValue(
35
+ argv: string[],
36
+ index: number,
37
+ optionName: string,
38
+ ): { nextIndex: number; value: string } {
39
+ const arg = argv[index] ?? "";
40
+ const equalsPrefix = `${optionName}=`;
41
+ if (arg.startsWith(equalsPrefix)) {
42
+ const value = arg.slice(equalsPrefix.length);
43
+ if (!value) throw new Error(`${optionName} requires a value`);
44
+ return { nextIndex: index, value };
45
+ }
46
+
47
+ const value = argv[index + 1];
48
+ if (!value || value.startsWith("--")) {
49
+ throw new Error(`${optionName} requires a value`);
50
+ }
51
+ return { nextIndex: index + 1, value };
52
+ }
53
+
54
+ function parseArgs(argv: string[]): Args {
55
+ const args: Args = {
56
+ env: "docs/examples/mods/learning/memory-citations.env.json",
57
+ foreground: false,
58
+ help: false,
59
+ repoRoot: process.cwd(),
60
+ skipGeneration: false,
61
+ };
62
+
63
+ for (let index = 0; index < argv.length; index += 1) {
64
+ const arg = argv[index];
65
+ if (arg === "--help" || arg === "-h") {
66
+ args.help = true;
67
+ } else if (arg === "--background") {
68
+ args.foreground = false;
69
+ } else if (arg === "--foreground") {
70
+ args.foreground = true;
71
+ } else if (arg === "--skip-generation") {
72
+ args.skipGeneration = true;
73
+ } else if (arg?.startsWith("--")) {
74
+ const optionName = arg.includes("=")
75
+ ? arg.slice(0, arg.indexOf("="))
76
+ : arg;
77
+ const { nextIndex, value } = readOptionValue(argv, index, optionName);
78
+ index = nextIndex;
79
+ switch (optionName) {
80
+ case "--backend":
81
+ args.backend = value;
82
+ break;
83
+ case "--candidate":
84
+ args.candidate = value;
85
+ break;
86
+ case "--candidates":
87
+ args.candidateCount = Number(value);
88
+ break;
89
+ case "--candidate-file-name":
90
+ args.candidateFileName = value;
91
+ break;
92
+ case "--eval-model":
93
+ args.evalModel = value;
94
+ break;
95
+ case "--env":
96
+ args.env = value;
97
+ break;
98
+ case "--generation-model":
99
+ args.generationModel = value;
100
+ break;
101
+ case "--model":
102
+ args.generationModel = value;
103
+ args.evalModel = value;
104
+ break;
105
+ case "--out":
106
+ args.out = value;
107
+ break;
108
+ case "--promote-to":
109
+ args.promoteTo = value;
110
+ break;
111
+ case "--repo-root":
112
+ args.repoRoot = value;
113
+ break;
114
+ case "--scenario-limit":
115
+ args.scenarioLimit = Number(value);
116
+ break;
117
+ default:
118
+ throw new Error(`Unknown argument: ${arg}`);
119
+ }
120
+ } else {
121
+ throw new Error(`Unknown argument: ${arg}`);
122
+ }
123
+ }
124
+
125
+ return args;
126
+ }
127
+
128
+ function printHelp(): void {
129
+ console.log(`Usage: bun scripts/mod-learning/learn-mod.ts [options]
130
+
131
+ Runs the mod learning dogfood loop:
132
+ env/demo -> generate candidate mod -> headless eval with LETTA_MODS_DIR (and legacy LETTA_EXTENSIONS_DIR for pre-rename branches) -> artifacts/report
133
+
134
+ Options:
135
+ --env <path> Learning env JSON (default: memory-citations env)
136
+ --out <dir> Run artifact directory (default: .letta/mod-learning-runs/<slug>-<timestamp>)
137
+ --candidate <path> Use an existing candidate mod instead of generation
138
+ --candidates <n> Run N optimization iterations for one learned mod (default: 5 for generated runs)
139
+ --candidate-file-name <name> Candidate filename inside the eval mod directory
140
+ --model <handle> Model for generation and eval
141
+ --generation-model <handle> Model for candidate generation
142
+ --eval-model <handle> Model for headless eval
143
+ --backend <mode> Backend flag forwarded to letta (api or local)
144
+ --scenario-limit <n> Evaluate only the first N scenarios (fast smoke testing)
145
+ --repo-root <path> Repo root (default: cwd)
146
+ --foreground Run learning in this process and return a pass/fail exit code
147
+ --background Explicitly use the default detached mode
148
+ --skip-generation Expect the candidate file to already exist in the run dir
149
+ --promote-to <path> Copy passing candidate to this repo-relative path
150
+ -h, --help Show this help
151
+ `);
152
+ }
153
+
154
+ async function launchBackground(params: {
155
+ argv: string[];
156
+ repoRoot: string;
157
+ runDir: string;
158
+ outWasProvided: boolean;
159
+ }): Promise<void> {
160
+ await mkdir(params.runDir, { recursive: true });
161
+ const stdoutPath = path.join(params.runDir, "background.stdout");
162
+ const stderrPath = path.join(params.runDir, "background.stderr");
163
+ const metadataPath = path.join(params.runDir, "background.json");
164
+ const childArgv = params.argv.filter(
165
+ (arg) => arg !== "--background" && arg !== "--foreground",
166
+ );
167
+ childArgv.push("--foreground");
168
+ if (!params.outWasProvided) childArgv.push("--out", params.runDir);
169
+
170
+ const stdoutFd = openSync(stdoutPath, "a");
171
+ const stderrFd = openSync(stderrPath, "a");
172
+ let childPid: number | undefined;
173
+ try {
174
+ const child = spawn(
175
+ process.execPath,
176
+ [fileURLToPath(import.meta.url), ...childArgv],
177
+ {
178
+ cwd: params.repoRoot,
179
+ detached: true,
180
+ env: process.env,
181
+ stdio: ["ignore", stdoutFd, stderrFd],
182
+ },
183
+ );
184
+ child.unref();
185
+ childPid = child.pid;
186
+ await writeFile(
187
+ metadataPath,
188
+ `${JSON.stringify(
189
+ {
190
+ args: childArgv,
191
+ command: process.execPath,
192
+ pid: childPid,
193
+ runDir: params.runDir,
194
+ startedAt: new Date().toISOString(),
195
+ stderrPath,
196
+ stdoutPath,
197
+ },
198
+ null,
199
+ 2,
200
+ )}\n`,
201
+ "utf8",
202
+ );
203
+ } finally {
204
+ closeSync(stdoutFd);
205
+ closeSync(stderrFd);
206
+ }
207
+
208
+ console.log(`BACKGROUND ${params.runDir}`);
209
+ console.log(`PID ${childPid ?? "unknown"}`);
210
+ console.log(`stdout ${stdoutPath}`);
211
+ console.log(`stderr ${stderrPath}`);
212
+ console.log(`progress tail -f ${stdoutPath}`);
213
+ console.log(`report ${path.join(params.runDir, "report.md")}`);
214
+ }
215
+
216
+ function formatScore(
217
+ score: number | undefined,
218
+ maxScore: number | undefined,
219
+ ): string {
220
+ if (score === undefined) return "";
221
+ if (maxScore === undefined) return ` score ${score}`;
222
+ const percentage = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
223
+ return ` score ${score}/${maxScore} (${percentage}%)`;
224
+ }
225
+
226
+ function progressPrefix(progress: ModLearningProgress): string {
227
+ if (progress.candidateIndex && progress.candidateCount) {
228
+ return `[${progress.candidateIndex}/${progress.candidateCount}]`;
229
+ }
230
+ if (progress.candidateIndex) return `[${progress.candidateIndex}]`;
231
+ return `[${progress.phase}]`;
232
+ }
233
+
234
+ function formatProgressLine(progress: ModLearningProgress): string {
235
+ return `${progressPrefix(progress)} ${progress.message}${formatScore(progress.score, progress.maxScore)}`;
236
+ }
237
+
238
+ function candidateForPromote(report: ModLearningReport): string {
239
+ return report.candidatePath;
240
+ }
241
+
242
+ async function main(): Promise<void> {
243
+ const argv = process.argv.slice(2);
244
+ const args = parseArgs(argv);
245
+ if (args.help) {
246
+ printHelp();
247
+ return;
248
+ }
249
+
250
+ const repoRoot = path.resolve(args.repoRoot);
251
+ const learningEnv = await readModLearningEnv(
252
+ path.resolve(repoRoot, args.env),
253
+ );
254
+ const runDir = args.out
255
+ ? path.resolve(repoRoot, args.out)
256
+ : path.resolve(repoRoot, defaultModLearningRunDirectory(learningEnv));
257
+
258
+ if (!args.foreground) {
259
+ await launchBackground({
260
+ argv,
261
+ outWasProvided: args.out !== undefined,
262
+ repoRoot,
263
+ runDir,
264
+ });
265
+ return;
266
+ }
267
+
268
+ let lastProgressLine = "";
269
+ const report = await runModLearning({
270
+ backend: args.backend,
271
+ candidateCount:
272
+ args.candidateCount ??
273
+ (args.candidate || args.skipGeneration
274
+ ? undefined
275
+ : DEFAULT_OPTIMIZATION_STEPS),
276
+ candidateFileName: args.candidateFileName,
277
+ candidateSourcePath: args.candidate,
278
+ evalModel: args.evalModel,
279
+ generationModel: args.generationModel,
280
+ promoteToPath: args.promoteTo,
281
+ repoRoot,
282
+ runDir,
283
+ scenarioLimit: args.scenarioLimit,
284
+ skipGeneration: args.skipGeneration,
285
+ spec: learningEnv,
286
+ onProgress: (progress) => {
287
+ const line = formatProgressLine(progress);
288
+ if (line === lastProgressLine) return;
289
+ lastProgressLine = line;
290
+ console.log(line);
291
+ },
292
+ });
293
+
294
+ const status = report.passed ? "PASS" : "FAIL";
295
+ console.log(`${status} ${report.reportPath}`);
296
+ console.log(`candidate ${candidateForPromote(report)}`);
297
+ if (report.passed) {
298
+ console.log(`promote letta mods promote ${candidateForPromote(report)}`);
299
+ }
300
+ if (!report.passed) process.exit(1);
301
+ }
302
+
303
+ main().catch((error) => {
304
+ console.error(error instanceof Error ? error.stack : String(error));
305
+ process.exit(1);
306
+ });
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: Context Doctor
3
- id: context_doctor
3
+ id: context-doctor
4
4
  description: Identify and repair degradation in system prompt, external memory, and skills preventing you from following instructions or remembering information as well as you should.
5
5
  ---
6
6
 
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: creating-mods
3
- description: Creates and edits trusted local Letta Code mods, including tools, slash commands, local-only model providers, lifecycle/turn events, scoped conversation helpers, panels, status values, and capability-gated behavior. Use when asked to make a mod, add an agent-callable tool, add a slash command, add a local provider/model adapter, transform turns, react to app events, or add lightweight mod UI outside the dedicated /statusline flow.
3
+ description: Creates and edits trusted local Letta Code mods, including tools, slash commands, local-only model providers, lifecycle/turn events, scoped conversation helpers, panels, and capability-gated behavior. Use when asked to make a mod, add an agent-callable tool, add a slash command, add a local provider/model adapter, transform turns, react to app events, or add lightweight mod UI outside the dedicated /statusline flow.
4
4
  ---
5
5
 
6
6
  # Creating Mods
7
7
 
8
- Use this skill to create or update trusted Letta Code mod files. Mods are trusted local code that add small composable capabilities through mod APIs, not by importing app internals. Dynamic agent/conversation/workspace/model state is passed as `ctx` to tool, command, event, permission, status, and statusline callbacks; do not read mutable global context for model-callable behavior. Prefer scoped handles (`ctx.conversation`, `ctx.cwd`, `ctx.agent`) and guard optional UI with `letta.capabilities`.
8
+ Use this skill to create or update trusted Letta Code mod files. Mods are trusted local code that add small composable capabilities through mod APIs, not by importing app internals. Dynamic agent/conversation/workspace/model state is passed as `ctx` to tool, command, event, and permission callbacks (panels receive live `agent`/`model` in their render context); do not read mutable global context for model-callable behavior. Prefer scoped handles (`ctx.conversation`, `ctx.cwd`, `ctx.agent`) and guard optional UI with `letta.capabilities`.
9
9
 
10
- Capabilities vary by surface. TUI/headless may load tools, commands, events, UI, and providers; the desktop listener loads provider-only mods for local provider discovery. Always guard optional capabilities.
10
+ Capabilities vary by surface — not every surface loads every capability. The TUI/headless host can load tools, commands, events, UI, and providers; the desktop listener loads tools, commands, providers, and tool/turn events, but not panel UI. Always guard each registration on the capabilities its behavior needs.
11
11
 
12
12
  ## Choose where the mod file lives
13
13
 
@@ -86,13 +86,15 @@ letta.capabilities.commands
86
86
  letta.capabilities.events.lifecycle
87
87
  letta.capabilities.events.tools
88
88
  letta.capabilities.events.turns
89
+ letta.capabilities.events.compact
90
+ letta.capabilities.events.llm
89
91
  letta.capabilities.permissions
90
92
  letta.capabilities.providers
91
93
  letta.capabilities.ui.panels
92
- letta.capabilities.ui.statusValues
93
- letta.capabilities.ui.customStatuslineRenderer
94
94
  ```
95
95
 
96
+ Guard each registration on every capability its behavior depends on — not just the one that registers it. Surfaces load different capability subsets, so a registration that relies on another capability (a command that opens UI, emits an event, or calls a provider) must guard on that capability too. Otherwise it is advertised or activated on a host that cannot fulfill it and silently does nothing. Register where the host can actually do the work.
97
+
96
98
  ## Scoped API model
97
99
 
98
100
  - In commands and events, use `ctx.conversation` for conversation operations:
@@ -135,7 +137,8 @@ Before finishing, verify:
135
137
  - Command/tool IDs are valid; command overrides of built-ins are intentional, and tool IDs do not collide with built-ins.
136
138
  - Tool descriptions explain when the model should call them.
137
139
  - JSON schemas are object schemas with useful descriptions.
138
- - Optional UI/event/statusline APIs are capability-guarded.
140
+ - Optional UI/event APIs are capability-guarded.
141
+ - Each registration is guarded by every capability its behavior depends on, not just the one that registers it, so it isn't advertised or activated on a surface that can't fulfill it.
139
142
  - Provider mods are capability-guarded and clearly documented as local-agent only.
140
143
  - Timers, intervals, event registrations, and panels are cleaned up in a disposer.
141
144
  - Busy commands return `{ type: "handled" }` quickly and avoid main-conversation sends.
@@ -152,7 +155,7 @@ Before finishing, verify:
152
155
  | `references/providers.md` | Adding a custom model/API provider for local agents |
153
156
  | `references/events.md` | Reacting to lifecycle/tool/turn events or transforming turns/tools |
154
157
  | `references/permissions.md` | Enforcing dynamic tool allow/ask/deny policy before approval/execution |
155
- | `references/ui.md` | Panels, status values, or statusline capability guards are involved |
158
+ | `references/ui.md` | Panels (including order-0 statusline) or `ui.panels` capability guards are involved |
156
159
  | `references/plan-mode.md` | Recreating plan mode with commands, tools, events, permissions, and local state |
157
160
  | `references/analysis-mode.md` | Phrase-triggered diagnostic mode with turn reminders (simpler than plan-mode) |
158
161
  | `references/architecture.md` | Multiple capabilities, local state, cleanup, background model work, or non-trivial composition |
@@ -78,14 +78,22 @@ const stream = await forked.sendMessageStream([
78
78
 
79
79
  Do not call `ctx.conversation.sendMessageStream()` on the active conversation from a busy command; direct sends can conflict with the active run.
80
80
 
81
- ### Event + status value
81
+ ### Event + panel
82
82
 
83
- Use lifecycle events to maintain small status values such as active conversation state. Guard both event and status capabilities.
83
+ Use lifecycle events to maintain a small panel such as active conversation state. Guard both event and panel capabilities, and re-render with `panel.update()`.
84
84
 
85
85
  ```ts
86
- if (letta.capabilities.events.lifecycle && letta.capabilities.ui.statusValues) {
86
+ if (letta.capabilities.events.lifecycle && letta.capabilities.ui.panels) {
87
+ let conversation = "";
88
+ const panel = letta.ui.openPanel({
89
+ id: "conversation",
90
+ order: 100,
91
+ render: ({ width, row }) => row("conversation", conversation, width),
92
+ });
93
+ disposers.push(() => panel.close());
87
94
  disposers.push(letta.events.on("conversation_open", (event) => {
88
- letta.ui.setStatus("conversation", event.reason);
95
+ conversation = event.reason;
96
+ panel.update();
89
97
  }));
90
98
  }
91
99
  ```
@@ -126,10 +134,13 @@ ctx.conversation.id // string | null
126
134
  ctx.conversation.getHistory(opts) // recent messages
127
135
  ctx.conversation.fork(opts) // returns a scoped handle
128
136
  ctx.conversation.sendMessageStream(messages, opts)
137
+ ctx.conversation.updateLlmConfig(opts) // change model / reasoning effort / context window
129
138
  ```
130
139
 
131
140
  A forked handle keeps the same agent/backend defaults and targets the forked conversation. Use forked handles for background model work. Use `getHistory({ limit, order, includeErrors })` when local logic needs conversation context.
132
141
 
142
+ `updateLlmConfig({ model?, reasoningEffort?, contextWindow?, scope? })` changes the model, reasoning effort, and/or context window, and works across local and Constellation backends. Only the fields you pass change; the rest are preserved, so `updateLlmConfig({ contextWindow })` adjusts just the context window without touching the model or reasoning effort. `scope` defaults to `"conversation"` (a conversation-scoped override that leaves the agent's default untouched); pass `scope: "agent"` to change the agent default. Changing reasoning effort without a model resolves the current model to rebuild provider-specific settings. The change takes effect on the next turn (the model is resolved per provider request).
143
+
133
144
  Tools currently receive `ctx.conversation.getHistory()` but not fork/send helpers. If a tool needs model-side follow-up, return information for the model to act on instead of starting a hidden run from the tool.
134
145
 
135
146
  ## Error handling
@@ -96,9 +96,10 @@ export default function activate(letta) {
96
96
  return { type: "output", output: `hello ${ctx.args || "there"}` };
97
97
  }
98
98
 
99
+ const greeting = `hello ${ctx.args || "there"}`;
99
100
  const panel = letta.ui.openPanel({
100
101
  id: "hello-panel",
101
- content: [`hello ${ctx.args || "there"}`],
102
+ render: () => greeting,
102
103
  });
103
104
  setTimeout(() => panel.close(), 5_000);
104
105
  return { type: "handled" };
@@ -20,6 +20,8 @@ This is the first slice of the hooks-v2 direction. The long-term goal is for typ
20
20
  letta.capabilities.events.lifecycle
21
21
  letta.capabilities.events.tools
22
22
  letta.capabilities.events.turns
23
+ letta.capabilities.events.compact
24
+ letta.capabilities.events.llm
23
25
  ```
24
26
 
25
27
  Guard events when writing portable mods:
@@ -55,9 +57,11 @@ letta.events.on("tool_start", (event, ctx) => {
55
57
  });
56
58
  ```
57
59
 
58
- Lifecycle, turn-start, and tool-start events are wired today.
60
+ Lifecycle, turn, tool, compaction, and llm events are wired today.
59
61
 
60
- Lifecycle handlers are notification-only and should not return values. `turn_start` handlers can transform the outbound input for the next model turn. `tool_start` handlers can transform the tool arguments before execution.
62
+ Lifecycle handlers are notification-only and should not return values. `turn_start` handlers can transform or cancel outbound user-message turns. `tool_start` handlers can transform the tool arguments before execution. Compaction and llm handlers are notification-only.
63
+
64
+ `compact_start`/`compact_end` and `llm_start`/`llm_end` only fire on the **local backend**, where compaction and provider requests run client-side. On the constellation backend that work happens server-side and these events do not fire, so guard with `letta.capabilities.events.compact` / `letta.capabilities.events.llm` for portable mods.
61
65
 
62
66
  ## Supported events
63
67
 
@@ -65,7 +69,12 @@ Lifecycle handlers are notification-only and should not return values. `turn_sta
65
69
  "conversation_open"
66
70
  "conversation_close"
67
71
  "tool_start"
72
+ "tool_end"
68
73
  "turn_start"
74
+ "compact_start"
75
+ "compact_end"
76
+ "llm_start"
77
+ "llm_end"
69
78
  ```
70
79
 
71
80
  `conversation_open` event:
@@ -138,7 +147,46 @@ Handlers run in registration order. Later handlers see the current args after ea
138
147
 
139
148
  `tool_start` is intentionally a trusted local mod point: it can rewrite commands, file paths, and other tool inputs before execution. Keep transforms focused and unsurprising.
140
149
 
141
- `turn_start` fires before outbound turns that include a user message. In the TUI this includes normal submits and prompt-style command turns. In headless it includes one-shot prompts and bidirectional user turns.
150
+ `tool_end` event:
151
+
152
+ ```ts
153
+ {
154
+ agentId: string | null;
155
+ conversationId: string | null;
156
+ toolCallId: string | null;
157
+ toolName: string;
158
+ args: Record<string, unknown>;
159
+ status: "success" | "error";
160
+ output: string;
161
+ }
162
+ ```
163
+
164
+ `tool_end` fires immediately after a tool produces a result, before the agent sees it. `event.args` contains the effective tool invocation arguments after `tool_start` transforms, so handlers can react to the specific file, command, query, etc. Handlers can inspect the result, or return `{ result: { status, output } }` to replace it:
165
+
166
+ ```ts
167
+ letta.events.on("tool_end", (event) => {
168
+ if (event.toolName !== "Bash" || event.status !== "success") return;
169
+ if (typeof event.args.command !== "string") return;
170
+ return { result: { status: "success", output: redactSecrets(event.output) } };
171
+ });
172
+ ```
173
+
174
+ The first handler that returns a `result` wins; later handlers are shadowed. Only string results are surfaced — multimodal/image results pass through unchanged. `tool_end` is the trusted-local-mod equivalent of the `PostToolUse` / `PostToolUseFailure` hooks for observing and rewriting tool results.
175
+
176
+ A handler can also react to a specific tool completing by adjusting conversation state. For example, switch model and reasoning effort when entering and exiting plan mode (`tool_end` fires only after the tool succeeds, so a denied approval won't switch):
177
+
178
+ ```ts
179
+ letta.events.on("tool_end", async (event, ctx) => {
180
+ if (event.status !== "success") return;
181
+ if (event.toolName === "enter_plan_mode") {
182
+ await ctx.conversation.updateLlmConfig({ model: "anthropic/claude-opus-4-8", reasoningEffort: "high" });
183
+ } else if (event.toolName === "exit_plan_mode") {
184
+ await ctx.conversation.updateLlmConfig({ model: "openai/gpt-5.5", reasoningEffort: "max" });
185
+ }
186
+ });
187
+ ```
188
+
189
+ `turn_start` fires before outbound turns that include a user message. In the TUI this includes normal submits and prompt-style command turns. In headless it includes one-shot prompts and bidirectional user turns. Listener/Desktop skips approval-only continuations so mods do not rewrite approval payloads; do not rely on `turn_start` to block approval-only continuations on every surface.
142
190
 
143
191
  Handlers can mutate `event.input` directly or return replacement input:
144
192
 
@@ -166,10 +214,83 @@ letta.events.on("turn_start", (event) => {
166
214
  });
167
215
  ```
168
216
 
217
+ Handlers can also cancel a user-message turn before it reaches the backend/model:
218
+
219
+ ```ts
220
+ letta.events.on("turn_start", (event) => {
221
+ if (!isPlanModeActive(event.conversationId)) {
222
+ return { cancel: { reason: "Run /plan first." } };
223
+ }
224
+ });
225
+ ```
226
+
227
+ If multiple handlers cancel, the first valid cancel reason wins. A valid reason is a non-empty string after trimming. Cancellation does not synthesize an assistant response or tool result; it only tells the host not to submit this turn.
228
+
169
229
  Handlers run in registration order. Later handlers see the current input after earlier mutations/returns. If a handler throws, its partial `event.input` mutation is rolled back and the error is recorded as a mod diagnostic.
170
230
 
171
231
  `turn_start` is intentionally a trusted local mod point: it can rewrite user messages, approval results, and ordering. Keep transforms focused and unsurprising.
172
232
 
233
+ `compact_start` event:
234
+
235
+ ```ts
236
+ {
237
+ agentId: string | null;
238
+ conversationId: string | null;
239
+ trigger: "manual" | "context_window_overflow" | "context_window_limit";
240
+ }
241
+ ```
242
+
243
+ `compact_start` fires before the local backend compacts a conversation, while the full message history is still in context. `trigger` distinguishes a manual `/compact` from the two automatic triggers (provider context-window overflow, and exceeding the configured context window). Use it to checkpoint state before eviction.
244
+
245
+ `compact_end` event:
246
+
247
+ ```ts
248
+ {
249
+ agentId: string | null;
250
+ conversationId: string | null;
251
+ trigger: "manual" | "context_window_overflow" | "context_window_limit";
252
+ messagesBefore: number;
253
+ messagesAfter: number;
254
+ contextTokensBefore: number;
255
+ contextTokensAfter: number;
256
+ }
257
+ ```
258
+
259
+ `compact_end` fires after compaction completes, carrying the before/after message and context-token counts. Both events are notification-only; return values are ignored. A throwing handler is isolated and never breaks compaction.
260
+
261
+ `llm_start` event:
262
+
263
+ ```ts
264
+ {
265
+ agentId: string | null;
266
+ conversationId: string | null;
267
+ model: string;
268
+ messageCount: number;
269
+ contextWindow: number;
270
+ }
271
+ ```
272
+
273
+ `llm_start` fires right before each provider request, with the model handle, the number of messages being sent, and the model's context window. It fires once per provider request, so a retry or an overflow-triggered re-request emits another `llm_start`.
274
+
275
+ `llm_end` event:
276
+
277
+ ```ts
278
+ {
279
+ agentId: string | null;
280
+ conversationId: string | null;
281
+ model: string;
282
+ stopReason: string;
283
+ usage: {
284
+ promptTokens: number;
285
+ completionTokens: number;
286
+ totalTokens: number;
287
+ };
288
+ durationMs: number;
289
+ }
290
+ ```
291
+
292
+ `llm_end` fires once a provider request produces a final message, carrying the stop reason, token usage, and wall-clock duration. A request that fails before producing a final message (e.g. a transport error that triggers a retry) emits no `llm_end`. Both events are notification-only; return values are ignored. A throwing handler is isolated and never breaks the provider request.
293
+
173
294
  Handlers also receive:
174
295
 
175
296
  ```ts
@@ -199,12 +320,23 @@ export default function activate(letta) {
199
320
  if (!letta.capabilities.events.lifecycle) return;
200
321
 
201
322
  const disposers = [];
202
-
203
- disposers.push(
204
- letta.events.on("conversation_open", (event) => {
205
- letta.ui.setStatus("conversation", event.reason);
206
- }),
207
- );
323
+ let conversation = "";
324
+
325
+ if (letta.capabilities.ui.panels) {
326
+ const panel = letta.ui.openPanel({
327
+ id: "conversation",
328
+ order: 100,
329
+ render: ({ width, row }) => row("conversation", conversation, width),
330
+ });
331
+ disposers.push(() => panel.close());
332
+
333
+ disposers.push(
334
+ letta.events.on("conversation_open", (event) => {
335
+ conversation = event.reason;
336
+ panel.update();
337
+ }),
338
+ );
339
+ }
208
340
 
209
341
  disposers.push(
210
342
  letta.events.on("conversation_close", (event) => {
@@ -214,7 +346,6 @@ export default function activate(letta) {
214
346
 
215
347
  return () => {
216
348
  for (const dispose of disposers.reverse()) dispose();
217
- letta.ui.clearStatus("conversation");
218
349
  };
219
350
  }
220
351
  ```
@@ -42,7 +42,7 @@ Guard each registration with the matching capability:
42
42
  - `events.turns`: append a focused plan-mode reminder while active
43
43
  - `permissions`: block mutating tools except planning coordination tools and plan-file writes
44
44
 
45
- 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.
45
+ Do not use panels for persistent mode state. Panels are transient UI and can be noisy/fragile for mode indicators. Do not claim the order-0 statusline panel just to show plan mode; that slot is a single primary line, not an additive indicator. This example intentionally keeps visible mode state out of scope.
46
46
 
47
47
  ## State
48
48