@letta-ai/letta-code 0.26.1 → 0.26.2

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.1",
3
+ "version": "0.26.2",
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",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "license": "Apache-2.0",
31
31
  "engines": {
32
- "node": ">=18"
32
+ "node": ">=22.19.0"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
@@ -40,6 +40,7 @@
40
40
  "ink-link": "^5.0.0",
41
41
  "node-pty": "^1.1.0",
42
42
  "open": "^10.2.0",
43
+ "react": "18.2.0",
43
44
  "sharp": "^0.34.5",
44
45
  "shiki": "^4.0.2",
45
46
  "strip-ansi": "^7.2.0",
@@ -49,7 +50,7 @@
49
50
  "@vscode/ripgrep": "^1.17.0"
50
51
  },
51
52
  "devDependencies": {
52
- "@earendil-works/pi-ai": "^0.74.0",
53
+ "@earendil-works/pi-ai": "^0.75.5",
53
54
  "@slack/bolt": "^4.7.0",
54
55
  "@types/bun": "^1.3.7",
55
56
  "@types/diff": "^8.0.0",
@@ -66,7 +67,6 @@
66
67
  "madge": "^8.0.0",
67
68
  "minimatch": "^10.0.3",
68
69
  "picomatch": "^2.3.1",
69
- "react": "18.2.0",
70
70
  "typescript": "^5.0.0"
71
71
  },
72
72
  "scripts": {
@@ -79,6 +79,7 @@
79
79
  "check:exported-functions": "node scripts/check-exported-functions.js",
80
80
  "check:filename-casing": "node scripts/check-filename-casing.js",
81
81
  "check:test-mock-isolation": "bun run scripts/check-test-mock-isolation.js",
82
+ "check:test-coverage": "node scripts/check-test-coverage.cjs",
82
83
  "check": "bun run scripts/check.js",
83
84
  "dev": "LETTA_DEBUG=${LETTA_DEBUG:-1} LETTA_RESPONSES_WS=${LETTA_RESPONSES_WS:-1} bun --loader=.md:text --loader=.mdx:text --loader=.txt:text run src/index.ts",
84
85
  "build": "node scripts/postinstall-patches.js && bun run build.js",
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Detects test files (*.test.ts, *.test.tsx) under src/ that are NOT covered
5
+ * by the CI unit test runner (scripts/run-unit-tests.cjs).
6
+ *
7
+ * This catches two problems:
8
+ * 1. A new src/ directory gets test files but nobody updates the dirs list
9
+ * in run-unit-tests.cjs, so those tests silently stop running in CI.
10
+ * 2. Tests are added to forbidden directories (e.g. src/tests) instead of
11
+ * being collocated with their source files.
12
+ *
13
+ * Excluded by design:
14
+ * src/integration-tests — API-gated, run separately in CI
15
+ * src/channels — special-cased in run-unit-tests.cjs (isolation requirements)
16
+ */
17
+
18
+ const { readdirSync, existsSync } = require("node:fs");
19
+ const path = require("node:path");
20
+
21
+ // ---- Collect all test files under src/ ----
22
+ function findTestFiles(dir) {
23
+ const results = [];
24
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
25
+ const full = path.join(dir, entry.name);
26
+ if (entry.isDirectory()) {
27
+ results.push(...findTestFiles(full));
28
+ } else if (
29
+ entry.name.endsWith(".test.ts") ||
30
+ entry.name.endsWith(".test.tsx")
31
+ ) {
32
+ results.push(full.replace(/\\/g, "/"));
33
+ }
34
+ }
35
+ return results;
36
+ }
37
+
38
+ const allTestFiles = findTestFiles("src");
39
+
40
+ // ---- Forbidden directories ----
41
+ // Tests must be collocated with their source (e.g. src/cli/components/Foo.test.tsx),
42
+ // not in a separate test directory.
43
+ const FORBIDDEN_DIRS = ["src/tests"];
44
+
45
+ const forbiddenFiles = allTestFiles.filter((f) =>
46
+ FORBIDDEN_DIRS.some(
47
+ (prefix) => f === prefix || f.startsWith(prefix + "/"),
48
+ ),
49
+ );
50
+
51
+ if (forbiddenFiles.length > 0) {
52
+ console.error(
53
+ `check-test-coverage: ${forbiddenFiles.length} test file(s) in forbidden directory:`,
54
+ );
55
+ for (const file of forbiddenFiles) {
56
+ console.error(` ${file}`);
57
+ }
58
+ console.error(
59
+ "\nTests must be collocated with their source files, not in src/tests/.",
60
+ );
61
+ console.error(
62
+ "Move the test next to the module it tests (e.g. src/cli/components/Foo.test.tsx).",
63
+ );
64
+ process.exit(1);
65
+ }
66
+
67
+ // ---- Determine which directories/patterns CI covers ----
68
+ // These must match scripts/run-unit-tests.cjs
69
+ const ciDirs = [
70
+ "src/agent",
71
+ "src/auth",
72
+ "src/backend",
73
+ "src/cli",
74
+ "src/cron",
75
+ "src/experiments",
76
+ "src/extensions",
77
+ "src/hooks",
78
+ "src/lsp",
79
+ "src/permissions",
80
+ "src/providers",
81
+ "src/queue",
82
+ "src/reminders",
83
+ "src/skills",
84
+ "src/telemetry",
85
+ "src/test-utils",
86
+ "src/tools",
87
+ "src/types",
88
+ "src/updater",
89
+ "src/utils",
90
+ "src/web",
91
+ "src/websocket",
92
+ ];
93
+
94
+ // Special cases: channels is run separately, integration-tests is API-gated
95
+ const specialDirs = ["src/channels", "src/integration-tests"];
96
+
97
+ // Root-level: src/*.test.ts is covered by glob
98
+ const coveredPrefixes = [...ciDirs, ...specialDirs];
99
+
100
+ function isCovered(file) {
101
+ // Root-level src/*.test.ts files (no subdirectory)
102
+ if (/^src\/[^/]+\.test\.tsx?$/.test(file)) return true;
103
+
104
+ return coveredPrefixes.some(
105
+ (prefix) => file === prefix || file.startsWith(prefix + "/"),
106
+ );
107
+ }
108
+
109
+ // ---- Report ----
110
+ const uncovered = allTestFiles.filter((f) => !isCovered(f));
111
+
112
+ if (uncovered.length === 0) {
113
+ console.log(
114
+ `check-test-coverage: all ${allTestFiles.length} test files are covered by CI`,
115
+ );
116
+ process.exit(0);
117
+ }
118
+
119
+ console.error(
120
+ `check-test-coverage: ${uncovered.length} test file(s) not covered by CI:`,
121
+ );
122
+ for (const file of uncovered) {
123
+ console.error(` ${file}`);
124
+ }
125
+ console.error(
126
+ "\nAdd the parent directory to the 'dirs' list in scripts/run-unit-tests.cjs,",
127
+ );
128
+ console.error(
129
+ "or add it to 'specialDirs' in scripts/check-test-coverage.cjs if it's run separately.",
130
+ );
131
+ process.exit(1);
package/scripts/check.js CHANGED
@@ -18,6 +18,7 @@ const checks = [
18
18
  { name: "exported function style", script: ["check:exported-functions"] },
19
19
  { name: "filename casing", script: ["check:filename-casing"] },
20
20
  { name: "test mock isolation", script: ["check:test-mock-isolation"] },
21
+ { name: "test coverage", script: ["check:test-coverage"] },
21
22
  { name: "biome", script: ["lint"] },
22
23
  { name: "typescript", script: ["typecheck"] },
23
24
  ];
@@ -13,6 +13,7 @@ const dirs = [
13
13
  "src/cli",
14
14
  "src/cron",
15
15
  "src/experiments",
16
+ "src/extensions",
16
17
  "src/hooks",
17
18
  "src/lsp",
18
19
  "src/permissions",
@@ -26,6 +27,7 @@ const dirs = [
26
27
  "src/types",
27
28
  "src/updater",
28
29
  "src/utils",
30
+ "src/web",
29
31
  "src/websocket",
30
32
  // Root-level test files (not inside a subdirectory)
31
33
  "src/*.test.ts",
@@ -0,0 +1,87 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Creating Extensions
7
+
8
+ Use this skill to create or update trusted global Letta Code extensions in:
9
+
10
+ ```text
11
+ ~/.letta/extensions/
12
+ ```
13
+
14
+ Extensions are local runtime capabilities, not TUI-only plugins. Prefer portable APIs and guard optional UI with `letta.capabilities`.
15
+
16
+ ## Choose the right capability
17
+
18
+ | User wants | Build |
19
+ | --- | --- |
20
+ | Agent/model should autonomously call a local capability | Extension tool |
21
+ | User wants `/foo` to send a prompt or run local UI logic | Extension command |
22
+ | Slash command represents a reusable agent workflow | Skill + thin extension command |
23
+ | Show transient output above input | Panel, usually from a command |
24
+ | Show small persistent state | Status value |
25
+ | Change the bottom statusline appearance | Use `customizing-statusline`, not this skill |
26
+
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.
28
+
29
+ ## Workflow
30
+
31
+ 1. Inspect `~/.letta/extensions/` for related files.
32
+ 2. Preserve unrelated extension code. Prefer a focused new file if merging would be messy.
33
+ 3. Choose one capability recipe:
34
+ - tools: `references/tools.md`
35
+ - commands: `references/commands.md`
36
+ - 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`.
41
+
42
+ ## Core extension shape
43
+
44
+ ```ts
45
+ export default function activate(letta) {
46
+ const disposers = [];
47
+
48
+ if (letta.capabilities.tools) {
49
+ disposers.push(letta.tools.register(/* ... */));
50
+ }
51
+
52
+ if (letta.capabilities.commands) {
53
+ disposers.push(letta.commands.register(/* ... */));
54
+ }
55
+
56
+ return () => {
57
+ for (const dispose of disposers.reverse()) dispose();
58
+ };
59
+ }
60
+ ```
61
+
62
+ Use `letta.capabilities` for optional behavior:
63
+
64
+ ```ts
65
+ letta.capabilities.tools
66
+ letta.capabilities.commands
67
+ letta.capabilities.ui.panels
68
+ letta.capabilities.ui.statusValues
69
+ letta.capabilities.ui.customStatuslineRenderer
70
+ ```
71
+
72
+ ## Rules
73
+
74
+ - Global trusted code only for now. Do not create project extensions.
75
+ - Do not import Letta Code app internals from extension files.
76
+ - Do not assume extra npm packages are available.
77
+ - Do not do surprising side effects on startup; extensions activate on app start and `/reload`.
78
+ - Keep user-facing output short and intentional.
79
+ - Prefer Node/Bun standard APIs (`node:child_process`, `node:fs`, etc.) for local work.
80
+ - For shell execution, prefer `execFile`/`spawn` over shell strings.
81
+ - Do not use emojis for loading states; use text or spinner-like characters if the user asks for loading UI.
82
+
83
+ ## References
84
+
85
+ - `references/tools.md` - extension tools the model can call
86
+ - `references/commands.md` - slash commands, command results, and skill-backed commands
87
+ - `references/ui.md` - panels, status values, capability guards
@@ -0,0 +1,90 @@
1
+ # `/btw` side-question extension example
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.
4
+
5
+ ```ts
6
+ export default function activate(letta) {
7
+ if (!letta.capabilities.commands) return;
8
+
9
+ function appendAssistantText(chunk, parts) {
10
+ if (chunk.message_type !== "assistant_message") return;
11
+ const content = chunk.content;
12
+ if (typeof content === "string") {
13
+ parts.push(content);
14
+ return;
15
+ }
16
+ if (Array.isArray(content)) {
17
+ for (const part of content) {
18
+ if (part && typeof part === "object" && "text" in part) {
19
+ parts.push(String(part.text));
20
+ }
21
+ }
22
+ }
23
+ }
24
+
25
+ function openPanelOrNull(content) {
26
+ if (!letta.capabilities.ui.panels) return null;
27
+ return letta.ui.openPanel({ id: "btw", content });
28
+ }
29
+
30
+ return letta.commands.register({
31
+ id: "btw",
32
+ description: "Ask a side question in a forked conversation",
33
+ args: "<question>",
34
+ runWhenBusy: true,
35
+ showInTranscript: false,
36
+ run(ctx) {
37
+ const question = ctx.args.trim();
38
+ if (!question) {
39
+ const panel = openPanelOrNull(["/btw", "Usage: /btw <question>"]);
40
+ if (panel) setTimeout(() => panel.close(), 5_000);
41
+ return { type: "handled" };
42
+ }
43
+
44
+ const panel = openPanelOrNull([`/btw ${question}`, "..."]);
45
+
46
+ void (async () => {
47
+ 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,
54
+ {
55
+ agent_id: ctx.agent.id,
56
+ input: `${question}
57
+
58
+ Answer briefly in 1-3 short sentences.`,
59
+ streaming: true,
60
+ },
61
+ );
62
+
63
+ const parts = [];
64
+ for await (const chunk of stream) {
65
+ appendAssistantText(chunk, parts);
66
+ panel?.update({ content: [`/btw ${question}`, parts.join("") || "..."] });
67
+ }
68
+
69
+ panel?.update({
70
+ content: [`done /btw ${question}`, parts.join("").trim() || "No response."],
71
+ });
72
+ if (panel) setTimeout(() => panel.close(), 10_000);
73
+ } catch (error) {
74
+ panel?.update({
75
+ content: [
76
+ `error /btw ${question}`,
77
+ error instanceof Error ? error.message : String(error),
78
+ ],
79
+ });
80
+ if (panel) setTimeout(() => panel.close(), 15_000);
81
+ }
82
+ })();
83
+
84
+ return { type: "handled" };
85
+ },
86
+ });
87
+ }
88
+ ```
89
+
90
+ Add custom borders, right alignment, wrapping, or history only if the user asks for that polish.
@@ -0,0 +1,114 @@
1
+ # Extension command recipes
2
+
3
+ Use commands when the human explicitly invokes `/foo`.
4
+
5
+ ## Decide command vs skill vs tool
6
+
7
+ | Need | Use |
8
+ | --- | --- |
9
+ | `/foo` expands to a prompt | Extension command |
10
+ | `/foo` starts a complex reusable workflow | Skill + thin extension command |
11
+ | Model should call the capability by itself | Extension tool |
12
+ | Command needs transient UI while doing local work | Extension command + panel |
13
+
14
+ 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
+
16
+ ## Command IDs
17
+
18
+ - Do not include the leading slash. Use `id: "review"`, not `id: "/review"`.
19
+ - Use a lowercase slug with letters, numbers, and hyphens only.
20
+ - Built-in commands like `/reload`, `/model`, `/statusline`, etc. are reserved.
21
+ - Duplicate extension command IDs fail unless `override: true` is intentional.
22
+
23
+ ## Prompt command
24
+
25
+ ```ts
26
+ export default function activate(letta) {
27
+ if (!letta.capabilities.commands) return;
28
+
29
+ return letta.commands.register({
30
+ id: "review",
31
+ description: "Review current git changes",
32
+ args: "[focus]",
33
+ run(ctx) {
34
+ const focus = ctx.args.trim();
35
+ return {
36
+ type: "prompt",
37
+ content: focus
38
+ ? `Review current git changes. Focus on ${focus}.`
39
+ : "Review current git changes. Focus on correctness issues.",
40
+ systemReminder: true,
41
+ };
42
+ },
43
+ });
44
+ }
45
+ ```
46
+
47
+ ## Output-only command
48
+
49
+ ```ts
50
+ export default function activate(letta) {
51
+ if (!letta.capabilities.commands) return;
52
+
53
+ return letta.commands.register({
54
+ id: "whereami",
55
+ description: "Show the active extension command context",
56
+ run(ctx) {
57
+ return {
58
+ type: "output",
59
+ output: `Agent: ${ctx.agent.name ?? ctx.agent.id}\nCWD: ${ctx.cwd}`,
60
+ };
61
+ },
62
+ });
63
+ }
64
+ ```
65
+
66
+ ## Panel command
67
+
68
+ Use `{ type: "handled" }` when the command owns the UI. Guard panels because they are optional on non-TUI surfaces.
69
+
70
+ ```ts
71
+ export default function activate(letta) {
72
+ if (!letta.capabilities.commands) return;
73
+
74
+ return letta.commands.register({
75
+ id: "hello-panel",
76
+ description: "Show a short transient panel",
77
+ showInTranscript: false,
78
+ run(ctx) {
79
+ if (!letta.capabilities.ui.panels) {
80
+ return { type: "output", output: `hello ${ctx.args || "there"}` };
81
+ }
82
+
83
+ const panel = letta.ui.openPanel({
84
+ id: "hello-panel",
85
+ content: [`hello ${ctx.args || "there"}`],
86
+ });
87
+ setTimeout(() => panel.close(), 5_000);
88
+ return { type: "handled" };
89
+ },
90
+ });
91
+ }
92
+ ```
93
+
94
+ ## Busy-safe SDK command
95
+
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.
97
+
98
+ Use `letta.client` or `await letta.getClient()` instead of raw `fetch`; SDK initialization is lazy and uses the current backend/auth context.
99
+
100
+ Common calls:
101
+
102
+ ```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
+ });
112
+ ```
113
+
114
+ For a complete side-question example, see `btw-command.md`.
@@ -0,0 +1,115 @@
1
+ # Extension tool recipes
2
+
3
+ Use tools when the agent/model should call a local capability autonomously.
4
+
5
+ ## Defaults
6
+
7
+ - Name: lowercase/underscore tool name, e.g. `branch_summary`.
8
+ - Description: explain when the model should use it.
9
+ - Parameters: JSON Schema object. Use `additionalProperties: false` when possible.
10
+ - `requiresApproval: false` only for read-only, low-risk local introspection.
11
+ - `parallelSafe: true` only for read-only tools with no shared mutation or long-lived exclusive resource.
12
+ - Use `ctx.cwd` / `ctx.workingDirectory` as the workspace.
13
+ - Respect `ctx.signal` for long-running work when practical.
14
+
15
+ ## Read-only shell tool
16
+
17
+ ```ts
18
+ import { execFile } from "node:child_process";
19
+ import { promisify } from "node:util";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ export default function activate(letta) {
24
+ if (!letta.capabilities.tools) return;
25
+
26
+ return letta.tools.register({
27
+ name: "branch_summary",
28
+ description: "Summarize the current git branch, working tree status, and recent commits.",
29
+ parameters: {
30
+ type: "object",
31
+ properties: {},
32
+ additionalProperties: false,
33
+ },
34
+ requiresApproval: false,
35
+ parallelSafe: true,
36
+ async run(ctx) {
37
+ const [{ stdout: status }, { stdout: log }] = await Promise.all([
38
+ execFileAsync("git", ["status", "--short", "--branch"], { cwd: ctx.cwd }),
39
+ execFileAsync("git", ["log", "--oneline", "-5"], { cwd: ctx.cwd }),
40
+ ]);
41
+
42
+ return ["## Branch", status.trim(), "", "## Recent commits", log.trim()].join("\n");
43
+ },
44
+ });
45
+ }
46
+ ```
47
+
48
+ ## Tool with arguments
49
+
50
+ ```ts
51
+ import { execFile } from "node:child_process";
52
+ import { promisify } from "node:util";
53
+
54
+ const execFileAsync = promisify(execFile);
55
+
56
+ export default function activate(letta) {
57
+ if (!letta.capabilities.tools) return;
58
+
59
+ return letta.tools.register({
60
+ name: "repo_notes_search",
61
+ description: "Search local repo notes for a query and return matching snippets.",
62
+ parameters: {
63
+ type: "object",
64
+ properties: {
65
+ query: { type: "string", description: "Search query" },
66
+ },
67
+ required: ["query"],
68
+ additionalProperties: false,
69
+ },
70
+ requiresApproval: false,
71
+ parallelSafe: true,
72
+ async run(ctx) {
73
+ const query = String(ctx.args.query ?? "").trim();
74
+ if (!query) return { status: "error", content: "query is required" };
75
+
76
+ try {
77
+ const { stdout } = await execFileAsync(
78
+ "rg",
79
+ ["--line-number", "--max-count", "20", query, "notes"],
80
+ { cwd: ctx.cwd },
81
+ );
82
+ return stdout.trim() || "No matches.";
83
+ } catch (error) {
84
+ if (error && typeof error === "object" && "code" in error && error.code === 1) {
85
+ return "No matches.";
86
+ }
87
+ throw error;
88
+ }
89
+ },
90
+ });
91
+ }
92
+ ```
93
+
94
+ ## Mutating or risky tool
95
+
96
+ Set approval required and avoid `parallelSafe` unless it is truly safe:
97
+
98
+ ```ts
99
+ letta.tools.register({
100
+ name: "format_file",
101
+ description: "Format a specific file in the current workspace.",
102
+ parameters: {
103
+ type: "object",
104
+ properties: { path: { type: "string" } },
105
+ required: ["path"],
106
+ additionalProperties: false,
107
+ },
108
+ requiresApproval: true,
109
+ parallelSafe: false,
110
+ async run(ctx) {
111
+ // mutate only the requested file, with clear output
112
+ return "formatted";
113
+ },
114
+ });
115
+ ```
@@ -0,0 +1,65 @@
1
+ # Extension UI recipes
2
+
3
+ UI capabilities are optional. Always guard UI work with `letta.capabilities.ui.*` when writing portable extensions.
4
+
5
+ ## Capabilities
6
+
7
+ ```ts
8
+ letta.capabilities.ui.panels
9
+ letta.capabilities.ui.statusValues
10
+ letta.capabilities.ui.customStatuslineRenderer
11
+ ```
12
+
13
+ - `panels`: transient text blocks above the input bar.
14
+ - `statusValues`: small named status data that renderers or future surfaces can display.
15
+ - `customStatuslineRenderer`: TUI-only custom bottom-row renderer. Use `customizing-statusline` for statusline work.
16
+
17
+ ## Panels
18
+
19
+ ```ts
20
+ if (letta.capabilities.ui.panels) {
21
+ const panel = letta.ui.openPanel({
22
+ id: "my-extension",
23
+ content: ["Working…"],
24
+ order: 100,
25
+ });
26
+
27
+ panel.update({ content: ["Done"] });
28
+ setTimeout(() => panel.close(), 5_000);
29
+ }
30
+ ```
31
+
32
+ Panel content is plain text: a string or string array. Keep it short; use command `output` for longer text.
33
+
34
+ ## Status values
35
+
36
+ ```ts
37
+ if (letta.capabilities.ui.statusValues) {
38
+ letta.ui.setStatus("branch", "main");
39
+ }
40
+ ```
41
+
42
+ Clear status values in disposers if they are owned by timers or external state:
43
+
44
+ ```ts
45
+ return () => {
46
+ letta.ui.clearStatus("branch");
47
+ };
48
+ ```
49
+
50
+ ## Timers and cleanup
51
+
52
+ ```ts
53
+ export default function activate(letta) {
54
+ if (!letta.capabilities.ui.statusValues) return;
55
+
56
+ const update = () => letta.ui.setStatus("clock", new Date().toLocaleTimeString());
57
+ update();
58
+ const timer = setInterval(update, 30_000);
59
+
60
+ return () => {
61
+ clearInterval(timer);
62
+ letta.ui.clearStatus("clock");
63
+ };
64
+ }
65
+ ```