@letta-ai/letta-code 0.26.2 → 0.26.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letta-ai/letta-code",
3
- "version": "0.26.2",
3
+ "version": "0.26.4",
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",
@@ -36,6 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@letta-ai/letta-client": "^1.10.2",
39
+ "@pierre/diffs": "1.2.2",
39
40
  "glob": "^13.0.0",
40
41
  "ink-link": "^5.0.0",
41
42
  "node-pty": "^1.1.0",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: creating-extensions
3
- description: Creates and edits Letta Code local extensions, including extension tools, slash commands, panels, status values, and capability-gated behavior. Use when the user asks to make an extension, add a tool the agent can call, add a slash command, or add lightweight extension UI outside the dedicated /statusline flow.
3
+ description: Creates and edits trusted local Letta Code extensions, including extension tools, slash commands, lifecycle/turn events, scoped conversation helpers, panels, status values, and capability-gated behavior. Use when the user asks to make an extension, add a tool the agent can call, add a slash command, transform turns, react to app events, or add lightweight extension UI outside the dedicated /statusline flow.
4
4
  ---
5
5
 
6
6
  # Creating Extensions
@@ -11,7 +11,7 @@ Use this skill to create or update trusted global Letta Code extensions in:
11
11
  ~/.letta/extensions/
12
12
  ```
13
13
 
14
- Extensions are local runtime capabilities, not TUI-only plugins. Prefer portable APIs and guard optional UI with `letta.capabilities`.
14
+ Extensions are trusted local apps for Letta Code. They add small composable capabilities through extension APIs, not by importing app internals. Prefer scoped handles (`ctx.conversation`, `ctx.cwd`, `ctx.agent`, `letta.getContext()`) and guard optional UI with `letta.capabilities`.
15
15
 
16
16
  ## Choose the right capability
17
17
 
@@ -20,24 +20,31 @@ Extensions are local runtime capabilities, not TUI-only plugins. Prefer portable
20
20
  | Agent/model should autonomously call a local capability | Extension tool |
21
21
  | User wants `/foo` to send a prompt or run local UI logic | Extension command |
22
22
  | Slash command represents a reusable agent workflow | Skill + thin extension command |
23
+ | Command should work while the main agent is busy | Command with `runWhenBusy: true`, `handled`, panel/status, and usually `ctx.conversation.fork()` |
23
24
  | Show transient output above input | Panel, usually from a command |
24
25
  | Show small persistent state | Status value |
26
+ | React to app/session lifecycle or transform outbound turns | Event |
25
27
  | Change the bottom statusline appearance | Use `customizing-statusline`, not this skill |
26
28
 
27
- Default to a **tool** when the model should decide when to use the capability. Default to a **command** when the human explicitly invokes it.
29
+ Default to a **tool** when the model should decide when to use the capability. Default to a **command** when the human explicitly invokes it. Compose capabilities when the UX needs it, e.g. command + panel + scoped conversation fork.
28
30
 
29
31
  ## Workflow
30
32
 
31
33
  1. Inspect `~/.letta/extensions/` for related files.
32
34
  2. Preserve unrelated extension code. Prefer a focused new file if merging would be messy.
33
- 3. Choose one capability recipe:
35
+ 3. Choose the extension shape:
36
+ - simple tool/command/event: read the specific recipe below
37
+ - multi-capability or stateful extension: also read `references/architecture.md`
38
+ 4. Load only the needed recipe:
34
39
  - tools: `references/tools.md`
35
40
  - commands: `references/commands.md`
41
+ - events: `references/events.md`
36
42
  - panels/status/capabilities: `references/ui.md`
37
- 4. Write a single-file extension unless the user asks for something larger.
38
- 5. Return disposers for registered commands/tools, timers, subscriptions, and panels that should close on reload.
39
- 6. Do a basic syntax/shape review: valid names, descriptions present, JSON schemas are object schemas, capability guards around optional UI.
40
- 7. Tell the user the absolute file path changed and to run `/reload`.
43
+ - busy side-question pattern: `references/btw-command.md`
44
+ 5. Write a single-file extension unless the user asks for something larger.
45
+ 6. Return disposers for registered commands/tools/events, timers, subscriptions, and panels that should close on reload.
46
+ 7. Do a basic review: valid names, descriptions present, schemas are object schemas, optional capabilities guarded, scoped APIs used, cleanup returned.
47
+ 8. Tell the user the absolute file path changed and to run `/reload`.
41
48
 
42
49
  ## Core extension shape
43
50
 
@@ -64,24 +71,56 @@ Use `letta.capabilities` for optional behavior:
64
71
  ```ts
65
72
  letta.capabilities.tools
66
73
  letta.capabilities.commands
74
+ letta.capabilities.events.lifecycle
75
+ letta.capabilities.events.tools
76
+ letta.capabilities.events.turns
67
77
  letta.capabilities.ui.panels
68
78
  letta.capabilities.ui.statusValues
69
79
  letta.capabilities.ui.customStatuslineRenderer
70
80
  ```
71
81
 
82
+ ## Scoped API model
83
+
84
+ - In commands and events, use `ctx.conversation` for conversation operations:
85
+ - `ctx.conversation.getHistory()` for recent messages
86
+ - `ctx.conversation.fork()` for independent/background model work
87
+ - `forked.sendMessageStream([...])` to stream from a fork
88
+ - In tools, use `ctx.conversation.getHistory()` when the tool needs recent context.
89
+ - Use `letta.client` only for server-specific Letta API calls; do not use it as a substitute for scoped conversation helpers.
90
+ - Do not import `@/backend`, `@/cli`, or other Letta Code internals from extension files.
91
+
72
92
  ## Rules
73
93
 
74
94
  - Global trusted code only for now. Do not create project extensions.
75
- - Do not import Letta Code app internals from extension files.
76
95
  - Do not assume extra npm packages are available.
77
96
  - Do not do surprising side effects on startup; extensions activate on app start and `/reload`.
78
97
  - Keep user-facing output short and intentional.
79
98
  - Prefer Node/Bun standard APIs (`node:child_process`, `node:fs`, etc.) for local work.
80
99
  - For shell execution, prefer `execFile`/`spawn` over shell strings.
81
100
  - Do not use emojis for loading states; use text or spinner-like characters if the user asks for loading UI.
101
+ - For `runWhenBusy: true`, do not return `prompt`; return `handled` and own the UI/background work.
102
+ - Treat `turn_start` as powerful trusted code: keep transforms narrow and unsurprising.
103
+
104
+ ## Pre-flight checklist for complex extensions
105
+
106
+ Before finishing, verify:
107
+
108
+ - The extension has one clear owner/file and does not mix unrelated features.
109
+ - Command/tool IDs are valid and do not collide with built-ins.
110
+ - Tool descriptions explain when the model should call them.
111
+ - JSON schemas are object schemas with useful descriptions.
112
+ - Optional UI/event/statusline APIs are capability-guarded.
113
+ - Timers, intervals, event registrations, and panels are cleaned up in a disposer.
114
+ - Busy commands return `{ type: "handled" }` quickly and avoid main-conversation sends.
115
+ - Conversation work uses `ctx.conversation` or forked handles, not app internals.
116
+ - Local shell/file work is scoped to `ctx.cwd` / `ctx.workingDirectory` unless intentionally global.
117
+ - Errors shown to the user are short and actionable.
82
118
 
83
119
  ## References
84
120
 
121
+ - `references/architecture.md` - composition, state, cleanup, scoped conversation, and review checklist for complex extensions
85
122
  - `references/tools.md` - extension tools the model can call
86
123
  - `references/commands.md` - slash commands, command results, and skill-backed commands
124
+ - `references/events.md` - lifecycle and turn event handlers
87
125
  - `references/ui.md` - panels, status values, capability guards
126
+ - `references/btw-command.md` - advanced busy-safe side-question command using scoped conversation helpers
@@ -0,0 +1,138 @@
1
+ # Extension architecture patterns
2
+
3
+ Use this reference for non-trivial extensions: multiple capabilities, local state, timers, background model work, or UI.
4
+
5
+ ## Mental model
6
+
7
+ An extension is trusted local code that registers capabilities during activation and cleans them up on reload/shutdown. Keep the public surface small:
8
+
9
+ - activation registers commands/tools/events/UI
10
+ - command/tool/event handlers receive scoped context
11
+ - state is local and explicit
12
+ - cleanup is returned from activation
13
+
14
+ Do not import Letta Code internals. If the extension API does not expose a capability yet, avoid reaching around it.
15
+
16
+ ## Capability composition patterns
17
+
18
+ ### Tool + command
19
+
20
+ Use a tool for autonomous model use and a command for explicit human invocation. Keep shared local helper functions inside the same file.
21
+
22
+ ```ts
23
+ function summarizeBranch(cwd) {
24
+ // local implementation
25
+ }
26
+
27
+ export default function activate(letta) {
28
+ const disposers = [];
29
+
30
+ if (letta.capabilities.tools) {
31
+ disposers.push(letta.tools.register({
32
+ name: "branch_summary",
33
+ description: "Summarize the current git branch when repository state matters.",
34
+ parameters: { type: "object", properties: {}, additionalProperties: false },
35
+ requiresApproval: false,
36
+ async run(ctx) {
37
+ return summarizeBranch(ctx.cwd);
38
+ },
39
+ }));
40
+ }
41
+
42
+ if (letta.capabilities.commands) {
43
+ disposers.push(letta.commands.register({
44
+ id: "branch-summary",
45
+ description: "Show current branch summary",
46
+ async run(ctx) {
47
+ return { type: "output", output: await summarizeBranch(ctx.cwd) };
48
+ },
49
+ }));
50
+ }
51
+
52
+ return () => disposers.reverse().forEach((dispose) => dispose());
53
+ }
54
+ ```
55
+
56
+ ### Command + panel + background conversation
57
+
58
+ Use this for side questions, long local work, or busy-safe commands. For commands with `runWhenBusy: true`, return `{ type: "handled" }` quickly and update a panel/status asynchronously. If model output is needed while the main agent is busy, fork first:
59
+
60
+ ```ts
61
+ const forked = await ctx.conversation.fork({ hidden: true });
62
+ const stream = await forked.sendMessageStream([
63
+ { role: "user", content: prompt },
64
+ ]);
65
+ ```
66
+
67
+ Do not call `ctx.conversation.sendMessageStream()` on the active conversation from a busy command; direct sends can conflict with the active run.
68
+
69
+ ### Event + status value
70
+
71
+ Use lifecycle events to maintain small status values such as active conversation state. Guard both event and status capabilities.
72
+
73
+ ```ts
74
+ if (letta.capabilities.events.lifecycle && letta.capabilities.ui.statusValues) {
75
+ disposers.push(letta.events.on("conversation_open", (event) => {
76
+ letta.ui.setStatus("conversation", event.reason);
77
+ }));
78
+ }
79
+ ```
80
+
81
+ ### `turn_start` transform
82
+
83
+ Use `turn_start` only when the extension needs to inspect or transform the outbound user-message turn. Keep transforms local and predictable. Prefer appending/prepending focused context or replacing explicit shortcuts over broad rewrites.
84
+
85
+ ## Local state
86
+
87
+ For small persistent state, use a clearly named file under `~/.letta/extensions/`, for example:
88
+
89
+ ```text
90
+ ~/.letta/extensions/my-extension.state.json
91
+ ```
92
+
93
+ Use atomic-ish writes when practical: write the full JSON file from an in-memory object after each change. Validate parsed state and fall back gracefully if the file is missing or malformed.
94
+
95
+ Keep state separate from source code. Do not store secrets in plain JSON; use existing secret/provider mechanisms when credentials are needed.
96
+
97
+ ## Timers and subscriptions
98
+
99
+ Timers are okay for active-session behavior, but they only run while the extension host is alive. Always clear them:
100
+
101
+ ```ts
102
+ const timer = setInterval(update, 30_000);
103
+ return () => clearInterval(timer);
104
+ ```
105
+
106
+ For long async loops, check `letta.signal.aborted` or `ctx.signal.aborted` and stop quietly.
107
+
108
+ ## Scoped conversation handles
109
+
110
+ Commands and events receive `ctx.conversation`:
111
+
112
+ ```ts
113
+ ctx.conversation.id // string | null
114
+ ctx.conversation.getHistory(opts) // recent messages
115
+ ctx.conversation.fork(opts) // returns a scoped handle
116
+ ctx.conversation.sendMessageStream(messages, opts)
117
+ ```
118
+
119
+ 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.
120
+
121
+ 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.
122
+
123
+ ## Error handling
124
+
125
+ - Catch expected local errors and return short user-facing text.
126
+ - Let unexpected errors throw when diagnostics are better than hiding the failure.
127
+ - For panel workflows, update the panel with a concise error and close it after a delay.
128
+ - For tools, return `{ status: "error", content: "..." }` or throw depending on whether the model can recover.
129
+
130
+ ## Final review checklist
131
+
132
+ - Capability guards are present for commands/tools/events/UI.
133
+ - Activation does not do heavy work unless the feature needs startup state.
134
+ - All disposers/timers/subscriptions are cleaned up.
135
+ - `runWhenBusy: true` commands return `handled`, not `prompt`.
136
+ - Background model work uses a forked conversation.
137
+ - Local filesystem and shell work uses scoped paths and `execFile`/`spawn`.
138
+ - Extension output is concise and actionable.
@@ -1,11 +1,18 @@
1
1
  # `/btw` side-question extension example
2
2
 
3
- This example runs while the main agent is busy because it forks the conversation, uses the SDK directly, renders progress in a panel when panels are available, and returns `{ type: "handled" }` immediately.
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
4
 
5
5
  ```ts
6
6
  export default function activate(letta) {
7
7
  if (!letta.capabilities.commands) return;
8
8
 
9
+ function createOtid() {
10
+ return (
11
+ globalThis.crypto?.randomUUID?.() ??
12
+ `btw-${Date.now()}-${Math.random().toString(16).slice(2)}`
13
+ );
14
+ }
15
+
9
16
  function appendAssistantText(chunk, parts) {
10
17
  if (chunk.message_type !== "assistant_message") return;
11
18
  const content = chunk.content;
@@ -19,6 +26,10 @@ export default function activate(letta) {
19
26
  parts.push(String(part.text));
20
27
  }
21
28
  }
29
+ return;
30
+ }
31
+ if (content && typeof content === "object" && "text" in content) {
32
+ parts.push(String(content.text));
22
33
  }
23
34
  }
24
35
 
@@ -45,29 +56,34 @@ export default function activate(letta) {
45
56
 
46
57
  void (async () => {
47
58
  try {
48
- const forked = await letta.client.conversations.fork(
49
- ctx.conversation.id || "default",
50
- { agent_id: ctx.agent.id },
51
- );
52
- const stream = await letta.client.conversations.messages.create(
53
- forked.id,
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
+ ],
54
68
  {
55
- agent_id: ctx.agent.id,
56
- input: `${question}
57
-
58
- Answer briefly in 1-3 short sentences.`,
59
- streaming: true,
69
+ overrideModel: ctx.model.id ?? undefined,
70
+ workingDirectory: ctx.cwd,
60
71
  },
61
72
  );
62
73
 
63
74
  const parts = [];
64
75
  for await (const chunk of stream) {
65
76
  appendAssistantText(chunk, parts);
66
- panel?.update({ content: [`/btw ${question}`, parts.join("") || "..."] });
77
+ panel?.update({
78
+ content: [`/btw ${question}`, parts.join("") || "..."],
79
+ });
67
80
  }
68
81
 
69
82
  panel?.update({
70
- content: [`done /btw ${question}`, parts.join("").trim() || "No response."],
83
+ content: [
84
+ `done /btw ${question}`,
85
+ parts.join("").trim() || "No response.",
86
+ ],
71
87
  });
72
88
  if (panel) setTimeout(() => panel.close(), 10_000);
73
89
  } catch (error) {
@@ -2,6 +2,8 @@
2
2
 
3
3
  Use commands when the human explicitly invokes `/foo`.
4
4
 
5
+ For complex command-driven extensions with panels, timers, local state, or background model work, also read `architecture.md`.
6
+
5
7
  ## Decide command vs skill vs tool
6
8
 
7
9
  | Need | Use |
@@ -10,6 +12,7 @@ Use commands when the human explicitly invokes `/foo`.
10
12
  | `/foo` starts a complex reusable workflow | Skill + thin extension command |
11
13
  | Model should call the capability by itself | Extension tool |
12
14
  | Command needs transient UI while doing local work | Extension command + panel |
15
+ | Command needs model output while the main agent is busy | `runWhenBusy: true` command + forked `ctx.conversation` |
13
16
 
14
17
  If the command represents a durable agent workflow (for example `/goal`), put the workflow instructions in a skill and keep the command as a small launcher/prompt.
15
18
 
@@ -22,6 +25,8 @@ If the command represents a durable agent workflow (for example `/goal`), put th
22
25
 
23
26
  ## Prompt command
24
27
 
28
+ Use `prompt` for normal slash shortcuts that should become the next agent turn. Prompt commands are not busy-safe.
29
+
25
30
  ```ts
26
31
  export default function activate(letta) {
27
32
  if (!letta.capabilities.commands) return;
@@ -46,6 +51,8 @@ export default function activate(letta) {
46
51
 
47
52
  ## Output-only command
48
53
 
54
+ Use `output` for local results that do not need the model.
55
+
49
56
  ```ts
50
57
  export default function activate(letta) {
51
58
  if (!letta.capabilities.commands) return;
@@ -91,24 +98,22 @@ export default function activate(letta) {
91
98
  }
92
99
  ```
93
100
 
94
- ## Busy-safe SDK command
101
+ ## Busy-safe conversation command
95
102
 
96
- For commands with `runWhenBusy: true`, do not return `prompt` while the agent is running. Use the SDK directly, update a panel if available, and return `{ type: "handled" }` quickly.
103
+ For commands with `runWhenBusy: true`, do not return `prompt` while the agent is running. Use the scoped conversation handle directly, update a panel/status if available, and return `{ type: "handled" }` quickly.
97
104
 
98
- Use `letta.client` or `await letta.getClient()` instead of raw `fetch`; SDK initialization is lazy and uses the current backend/auth context.
105
+ Use `ctx.conversation` for conversation operations that should work across local and Constellation backends. The handle is bound to the active conversation and backend for that command invocation, so composed flows like fork-then-send stay on the same backend. Use `letta.client` only for server-specific API calls.
99
106
 
100
- Common calls:
107
+ Common pattern:
101
108
 
102
109
  ```ts
103
- await letta.client.conversations.fork(ctx.conversation.id || "default", {
104
- agent_id: ctx.agent.id,
105
- });
106
-
107
- await letta.client.conversations.messages.create(conversationId, {
108
- agent_id: ctx.agent.id,
109
- input,
110
- streaming: true,
111
- });
110
+ const forked = await ctx.conversation.fork({ hidden: true });
111
+
112
+ const stream = await forked.sendMessageStream([
113
+ { role: "user", content: input },
114
+ ]);
112
115
  ```
113
116
 
117
+ Do not send directly to the active conversation from a busy command; fork first unless the user explicitly asked to affect the main conversation later.
118
+
114
119
  For a complete side-question example, see `btw-command.md`.
@@ -0,0 +1,215 @@
1
+ # Extension event recipes
2
+
3
+ Use events when trusted local code should react to app/session changes or transform outbound turns without the human explicitly invoking a command. For event-driven extensions with state, timers, panels, or background model work, also read `architecture.md`.
4
+
5
+ This is the first slice of the hooks-v2 direction. The long-term goal is for typed extension events to replace settings-based hooks. Existing hooks still own blocking decisions and model feedback injection until each event has a typed return contract.
6
+
7
+ ## Capabilities
8
+
9
+ ```ts
10
+ letta.capabilities.events.lifecycle
11
+ letta.capabilities.events.tools
12
+ letta.capabilities.events.turns
13
+ ```
14
+
15
+ Guard events when writing portable extensions:
16
+
17
+ ```ts
18
+ export default function activate(letta) {
19
+ if (!letta.capabilities.events.lifecycle) return;
20
+
21
+ return letta.events.on("conversation_open", (event, ctx) => {
22
+ console.log(`conversation ${event.reason}: ${event.agentName ?? event.agentId}`);
23
+ console.log(`cwd: ${ctx.context.workspace.currentDir}`);
24
+ });
25
+ }
26
+ ```
27
+
28
+ The API intentionally follows the Pi-style event shape:
29
+
30
+ ```ts
31
+ letta.events.on("event_name", (event, ctx) => {
32
+ // event is specific to event_name
33
+ // ctx contains host context and an AbortSignal
34
+ });
35
+ ```
36
+
37
+ Tool events use this same API. Existing settings-based hooks still own blocking decisions and model feedback injection until those contracts are explicitly added to extension events.
38
+
39
+ ```ts
40
+ letta.events.on("tool_start", (event, ctx) => {
41
+ if (event.toolName !== "Bash") return;
42
+ if (String(event.args.command).startsWith("npm test")) {
43
+ return { args: { ...event.args, command: "bun test" } };
44
+ }
45
+ });
46
+ ```
47
+
48
+ Lifecycle, turn-start, and tool-start events are wired today.
49
+
50
+ 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.
51
+
52
+ ## Supported events
53
+
54
+ ```ts
55
+ "conversation_open"
56
+ "conversation_close"
57
+ "tool_start"
58
+ "turn_start"
59
+ ```
60
+
61
+ `conversation_open` event:
62
+
63
+ ```ts
64
+ {
65
+ agentId: string | null;
66
+ agentName: string | null;
67
+ conversationId: string | null;
68
+ previousConversationId?: string | null;
69
+ reason: "startup" | "new" | "resume" | "fork";
70
+ }
71
+ ```
72
+
73
+ `conversation_close` event:
74
+
75
+ ```ts
76
+ {
77
+ agentId: string | null;
78
+ conversationId: string | null;
79
+ durationMs: number | null;
80
+ messageCount: number | null;
81
+ reason: "quit" | "new" | "resume" | "fork";
82
+ toolCallCount: number | null;
83
+ }
84
+ ```
85
+
86
+ `turn_start` event:
87
+
88
+ ```ts
89
+ {
90
+ agentId: string | null;
91
+ conversationId: string | null;
92
+ input: Array<MessageCreate | ApprovalCreate>;
93
+ }
94
+ ```
95
+
96
+ `tool_start` event:
97
+
98
+ ```ts
99
+ {
100
+ agentId: string | null;
101
+ conversationId: string | null;
102
+ toolCallId: string | null;
103
+ toolName: string;
104
+ args: Record<string, unknown>;
105
+ }
106
+ ```
107
+
108
+ `tool_start` fires immediately before a client-side tool executes. This includes built-in tools, extension tools, and external tools executed through the local tool manager. It runs after permission/approval classification and before `PreToolUse` hooks, so trusted local extensions can change the actual executed arguments after the approval UI has already classified the original request.
109
+
110
+ Handlers can inspect `event.args`, mutate it directly, or return replacement args:
111
+
112
+ ```ts
113
+ letta.events.on("tool_start", (event) => {
114
+ if (event.toolName !== "Bash") return;
115
+ event.args = {
116
+ ...event.args,
117
+ command: String(event.args.command).replaceAll("npm test", "bun test"),
118
+ };
119
+ });
120
+
121
+ letta.events.on("tool_start", (event) => {
122
+ if (event.toolName !== "Read") return;
123
+ return { args: { ...event.args, limit: 200 } };
124
+ });
125
+ ```
126
+
127
+ Handlers run in registration order. Later handlers see the current args after earlier mutations/returns. If a handler throws, its partial `event.args` mutation is rolled back and the error is recorded as an extension diagnostic.
128
+
129
+ `tool_start` is intentionally a trusted local extension point: it can rewrite commands, file paths, and other tool inputs before execution. Keep transforms focused and unsurprising. It does not support blocking; use existing hooks for blocking safety decisions.
130
+
131
+ `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.
132
+
133
+ Handlers can mutate `event.input` directly or return replacement input:
134
+
135
+ ```ts
136
+ function replaceTextContent(content, from, to) {
137
+ if (typeof content === "string") return content.replaceAll(from, to);
138
+ if (!Array.isArray(content)) return content;
139
+ return content.map((part) =>
140
+ part?.type === "text" && typeof part.text === "string"
141
+ ? { ...part, text: part.text.replaceAll(from, to) }
142
+ : part,
143
+ );
144
+ }
145
+
146
+ letta.events.on("turn_start", (event) => {
147
+ event.input = event.input.map((item) =>
148
+ item.type !== "approval" && item.role === "user"
149
+ ? { ...item, content: replaceTextContent(item.content, "??", new Date().toLocaleString()) }
150
+ : item,
151
+ );
152
+ });
153
+
154
+ letta.events.on("turn_start", (event) => {
155
+ return { input: event.input };
156
+ });
157
+ ```
158
+
159
+ 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 an extension diagnostic.
160
+
161
+ `turn_start` is intentionally a trusted local extension point: it can rewrite user messages, approval results, and ordering. Keep transforms focused and unsurprising.
162
+
163
+ Handlers also receive:
164
+
165
+ ```ts
166
+ {
167
+ conversation: {
168
+ id: string | null;
169
+ fork(options?): Promise<conversation>;
170
+ getHistory(options?): Promise<Message[]>;
171
+ sendMessageStream(messages, options?): Promise<AsyncIterable<chunk>>;
172
+ };
173
+ context: letta.getContext();
174
+ getContext: () => letta.getContext();
175
+ signal: AbortSignal;
176
+ }
177
+ ```
178
+
179
+ `ctx.conversation` is bound when the event is dispatched. Use it for scoped conversation calls made while handling that event. If an event needs background model work, prefer `ctx.conversation.fork()` and send to the fork. Do not send to the active conversation from `turn_start`; that event is already in the path of sending a turn.
180
+
181
+ Respect `ctx.signal` for long-running async work. It is aborted on `/reload` and app shutdown.
182
+
183
+ ## Conversation status example
184
+
185
+ ```ts
186
+ export default function activate(letta) {
187
+ if (!letta.capabilities.events.lifecycle) return;
188
+
189
+ const disposers = [];
190
+
191
+ disposers.push(
192
+ letta.events.on("conversation_open", (event) => {
193
+ letta.ui.setStatus("conversation", event.reason);
194
+ }),
195
+ );
196
+
197
+ disposers.push(
198
+ letta.events.on("conversation_close", (event) => {
199
+ console.log(`conversation ${event.reason}: ${event.durationMs ?? 0}ms`);
200
+ }),
201
+ );
202
+
203
+ return () => {
204
+ for (const dispose of disposers.reverse()) dispose();
205
+ letta.ui.clearStatus("conversation");
206
+ };
207
+ }
208
+ ```
209
+
210
+ ## Rules
211
+
212
+ - Do not block user flow unless the event's typed contract explicitly supports blocking.
213
+ - Do not use lifecycle events for safety decisions yet. Existing hooks still own blocking behavior.
214
+ - Catch expected local errors if the user-facing outcome matters. Uncaught errors are isolated and recorded as extension diagnostics.
215
+ - Return disposers from activation for event registrations, timers, subscriptions, and status values.
@@ -2,6 +2,8 @@
2
2
 
3
3
  Use tools when the agent/model should call a local capability autonomously.
4
4
 
5
+ For tools that are part of a larger extension with commands, UI, local state, or events, also read `architecture.md`.
6
+
5
7
  ## Defaults
6
8
 
7
9
  - Name: lowercase/underscore tool name, e.g. `branch_summary`.
@@ -10,7 +12,9 @@ Use tools when the agent/model should call a local capability autonomously.
10
12
  - `requiresApproval: false` only for read-only, low-risk local introspection.
11
13
  - `parallelSafe: true` only for read-only tools with no shared mutation or long-lived exclusive resource.
12
14
  - Use `ctx.cwd` / `ctx.workingDirectory` as the workspace.
15
+ - Use `await ctx.conversation.getHistory()` when a tool needs recent conversation context. It returns the most recent messages in chronological order by default.
13
16
  - Respect `ctx.signal` for long-running work when practical.
17
+ - Tools should return information for the model to use; they should not start hidden model runs.
14
18
 
15
19
  ## Read-only shell tool
16
20
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  UI capabilities are optional. Always guard UI work with `letta.capabilities.ui.*` when writing portable extensions.
4
4
 
5
+ For UI that belongs to a larger command/event extension, also read `architecture.md` for cleanup and composition patterns.
6
+
5
7
  ## Capabilities
6
8
 
7
9
  ```ts
@@ -31,6 +33,8 @@ if (letta.capabilities.ui.panels) {
31
33
 
32
34
  Panel content is plain text: a string or string array. Keep it short; use command `output` for longer text.
33
35
 
36
+ Close panels when they are transient, and close/replace long-lived panels from the activation disposer if reload should remove them.
37
+
34
38
  ## Status values
35
39
 
36
40
  ```ts
@@ -39,7 +43,7 @@ if (letta.capabilities.ui.statusValues) {
39
43
  }
40
44
  ```
41
45
 
42
- Clear status values in disposers if they are owned by timers or external state:
46
+ Clear status values in disposers if they are owned by timers, events, or external state:
43
47
 
44
48
  ```ts
45
49
  return () => {