@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/README.md +12 -0
- package/letta.js +73427 -52818
- package/package.json +2 -1
- package/skills/creating-extensions/SKILL.md +48 -9
- package/skills/creating-extensions/references/architecture.md +138 -0
- package/skills/creating-extensions/references/btw-command.md +30 -14
- package/skills/creating-extensions/references/commands.md +18 -13
- package/skills/creating-extensions/references/events.md +215 -0
- package/skills/creating-extensions/references/tools.md +4 -0
- package/skills/creating-extensions/references/ui.md +5 -1
- package/skills/customizing-commands/SKILL.md +6 -4
- package/skills/customizing-statusline/SKILL.md +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@letta-ai/letta-code",
|
|
3
|
-
"version": "0.26.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
38
|
-
5.
|
|
39
|
-
6.
|
|
40
|
-
7.
|
|
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,
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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({
|
|
77
|
+
panel?.update({
|
|
78
|
+
content: [`/btw ${question}`, parts.join("") || "..."],
|
|
79
|
+
});
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
panel?.update({
|
|
70
|
-
content: [
|
|
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
|
|
101
|
+
## Busy-safe conversation command
|
|
95
102
|
|
|
96
|
-
For commands with `runWhenBusy: true`, do not return `prompt` while the agent is running. Use the
|
|
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 `
|
|
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
|
|
107
|
+
Common pattern:
|
|
101
108
|
|
|
102
109
|
```ts
|
|
103
|
-
await
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 () => {
|