Initial release of Letta Code SDK

Programmatic control of Letta Code CLI with persistent agent memory.

Features:
- createSession() / resumeSession() / prompt() API
- resumeConversation() for multi-threaded conversations
- Multi-turn conversations with memory
- Tool execution (Bash, Read, Edit, etc.)
- System prompt and memory configuration
- Permission callbacks (canUseTool)
- Message streaming with typed events

👾 Generated with [Letta Code](https://letta.com)
This commit is contained in:
cpacker
2026-01-27 00:30:09 -08:00
commit ee7cc92724
15 changed files with 2899 additions and 0 deletions

63
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
check:
name: Lint & Type Check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.0
- name: Install dependencies
run: bun install
- name: Lint & Type Check
run: bun run check
build:
needs: check
name: Build & Test
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.0
- name: Install dependencies
run: bun install
- name: Build
run: bun run build
- name: SDK smoke test
# Only run with API key on push or same-repo PRs
if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}
env:
LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }}
run: bun examples/v2-examples.ts basic
- name: SDK full test
# Only run with API key on push or same-repo PRs
if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}
env:
LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }}
run: bun examples/v2-examples.ts all

149
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,149 @@
name: Bump version and release
on:
workflow_dispatch:
inputs:
version_type:
description: "Version bump type (patch, minor, major)"
required: false
default: "patch"
prerelease:
description: "Publish as prerelease? (leave empty for stable, or enter tag like 'next')"
required: false
default: ""
jobs:
publish:
runs-on: ubuntu-latest
environment: npm-publish
permissions:
contents: write
id-token: write # Required for npm OIDC trusted publishing
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.0
- name: Setup Node (for npm OIDC publishing)
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Upgrade npm for OIDC support
run: npm install -g npm@latest
- name: Bump version
id: version
run: |
VERSION_TYPE="${{ github.event.inputs.version_type || 'patch' }}"
PRERELEASE_TAG="${{ github.event.inputs.prerelease }}"
OLD_VERSION=$(jq -r '.version' package.json)
# Check if old version is a prerelease (contains -)
if [[ "$OLD_VERSION" == *-* ]]; then
OLD_IS_PRERELEASE=true
BASE_VERSION=$(echo "$OLD_VERSION" | sed 's/-.*//')
else
OLD_IS_PRERELEASE=false
BASE_VERSION="$OLD_VERSION"
fi
# Split base version into parts
IFS='.' read -ra VERSION_PARTS <<< "$BASE_VERSION"
MAJOR=${VERSION_PARTS[0]}
MINOR=${VERSION_PARTS[1]}
PATCH=${VERSION_PARTS[2]}
# Always bump based on version_type
if [ "$VERSION_TYPE" = "major" ]; then
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif [ "$VERSION_TYPE" = "minor" ]; then
MINOR=$((MINOR + 1))
PATCH=0
else
# patch - only bump if not coming from prerelease
# (prerelease → stable with patch just drops the suffix)
if [ "$OLD_IS_PRERELEASE" = "false" ]; then
PATCH=$((PATCH + 1))
fi
fi
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
# Handle prerelease suffix
if [ -n "$PRERELEASE_TAG" ]; then
# Making a prerelease
if [[ "$OLD_VERSION" == "${NEW_VERSION}-${PRERELEASE_TAG}."* ]]; then
# Same base + same tag: increment prerelease number
PRERELEASE_NUM=$(echo "$OLD_VERSION" | sed "s/${NEW_VERSION}-${PRERELEASE_TAG}\.\([0-9]*\)/\1/")
PRERELEASE_NUM=$((PRERELEASE_NUM + 1))
else
# Different base or different tag: start at 1
PRERELEASE_NUM=1
fi
NEW_VERSION="${NEW_VERSION}-${PRERELEASE_TAG}.${PRERELEASE_NUM}"
echo "npm_tag=$PRERELEASE_TAG" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
# Making a stable release - NEW_VERSION is already just the base
echo "npm_tag=latest" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
# Update package.json
jq --arg version "$NEW_VERSION" '.version = $version' package.json > package.json.tmp
mv package.json.tmp package.json
echo "old_version=$OLD_VERSION" >> $GITHUB_OUTPUT
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Commit and push version bump
run: |
git add package.json
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} [skip ci]"
git tag "${{ steps.version.outputs.tag }}"
git push origin main
git push origin "${{ steps.version.outputs.tag }}"
- name: Install dependencies
run: bun install
- name: Lint & Type Check
run: bun run check
- name: Build
run: bun run build
- name: SDK smoke test
env:
LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }}
run: bun examples/v2-examples.ts basic
- name: Create GitHub Release
if: steps.version.outputs.is_prerelease == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Release ${{ steps.version.outputs.tag }}
body: |
Release ${{ steps.version.outputs.tag }}
Changes from ${{ steps.version.outputs.old_version }} to ${{ steps.version.outputs.new_version }}
- name: Publish to npm
run: npm publish --access public --tag ${{ steps.version.outputs.npm_tag }}

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
bun.lockb
bun.lock
*.log
.DS_Store
.letta/

190
LICENSE Normal file
View File

@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2025, Letta authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

328
README.md Normal file
View File

@@ -0,0 +1,328 @@
# Letta Code SDK
[![npm](https://img.shields.io/npm/v/@letta-ai/letta-code-sdk.svg?style=flat-square)](https://www.npmjs.com/package/@letta-ai/letta-code-sdk)
The SDK interface to [Letta Code](https://github.com/letta-ai/letta-code). Build agents with persistent memory that learn over time.
```typescript
import { prompt } from '@letta-ai/letta-code-sdk';
const result = await prompt('Find and fix the bug in auth.py', {
allowedTools: ['Read', 'Edit', 'Bash'],
permissionMode: 'bypassPermissions'
});
console.log(result.result);
```
## Installation
```bash
npm install @letta-ai/letta-code-sdk
```
## Quick Start
### One-shot prompt
```typescript
import { prompt } from '@letta-ai/letta-code-sdk';
const result = await prompt('Run: echo hello', {
allowedTools: ['Bash'],
permissionMode: 'bypassPermissions'
});
console.log(result.result); // "hello"
```
### Multi-turn session
```typescript
import { createSession } from '@letta-ai/letta-code-sdk';
await using session = createSession();
await session.send('What is 5 + 3?');
for await (const msg of session.stream()) {
if (msg.type === 'assistant') console.log(msg.content);
}
await session.send('Multiply that by 2');
for await (const msg of session.stream()) {
if (msg.type === 'assistant') console.log(msg.content);
}
```
### Persistent memory
Agents persist across sessions and remember context:
```typescript
import { createSession, resumeSession } from '@letta-ai/letta-code-sdk';
// First session
const session1 = createSession();
await session1.send('Remember: the secret word is "banana"');
for await (const msg of session1.stream()) { /* ... */ }
const agentId = session1.agentId;
session1.close();
// Later...
await using session2 = resumeSession(agentId);
await session2.send('What is the secret word?');
for await (const msg of session2.stream()) {
if (msg.type === 'assistant') console.log(msg.content); // "banana"
}
```
### Multi-threaded Conversations
Run multiple concurrent conversations with the same agent. Each conversation has its own message history while sharing the agent's persistent memory.
```typescript
import { createSession, resumeSession, resumeConversation } from '@letta-ai/letta-code-sdk';
// Create an agent
const session = createSession();
await session.send('Hello!');
for await (const msg of session.stream()) { /* ... */ }
const agentId = session.agentId;
const conversationId = session.conversationId; // Save this!
session.close();
// Resume a specific conversation
await using session2 = resumeConversation(conversationId);
await session2.send('Continue our discussion...');
for await (const msg of session2.stream()) { /* ... */ }
// Create a NEW conversation on the same agent
await using session3 = resumeSession(agentId, { newConversation: true });
await session3.send('Start a fresh thread...');
// session3.conversationId is different from conversationId
// Resume with agent's default conversation
await using session4 = resumeSession(agentId, { defaultConversation: true });
// Resume last used session (agent + conversation)
await using session5 = createSession({ continue: true });
```
**Key concepts:**
- **Agent** (`agentId`): Persistent entity with memory that survives across sessions
- **Conversation** (`conversationId`): A message thread within an agent
- **Session** (`sessionId`): A single execution/connection
Agents remember across conversations (via memory blocks), but each conversation has its own message history.
## Agent Configuration
### System Prompt
Choose from built-in presets or provide a custom prompt:
```typescript
// Use a preset
createSession({
systemPrompt: { type: 'preset', preset: 'letta-claude' }
});
// Use a preset with additional instructions
createSession({
systemPrompt: {
type: 'preset',
preset: 'letta-claude',
append: 'Always respond in Spanish.'
}
});
// Use a completely custom prompt
createSession({
systemPrompt: 'You are a helpful Python expert.'
});
```
**Available presets:**
- `default` / `letta-claude` - Full Letta Code prompt (Claude-optimized)
- `letta-codex` - Full Letta Code prompt (Codex-optimized)
- `letta-gemini` - Full Letta Code prompt (Gemini-optimized)
- `claude` - Basic Claude (no skills/memory instructions)
- `codex` - Basic Codex
- `gemini` - Basic Gemini
### Memory Blocks
Configure which memory blocks the agent uses:
```typescript
// Use default blocks (persona, human, project)
createSession({});
// Use specific preset blocks
createSession({
memory: ['project', 'persona'] // Only these blocks
});
// Use custom blocks
createSession({
memory: [
{ label: 'context', value: 'API documentation for Acme Corp...' },
{ label: 'rules', value: 'Always use TypeScript. Prefer functional patterns.' }
]
});
// Mix presets and custom blocks
createSession({
memory: [
'project', // Use default project block
{ label: 'custom', value: 'Additional context...' }
]
});
// No optional blocks (only core skills blocks)
createSession({
memory: []
});
```
### Convenience Props
Quickly customize common memory blocks:
```typescript
createSession({
persona: 'You are a senior Python developer who writes clean, tested code.',
human: 'Name: Alice. Prefers concise responses.',
project: 'FastAPI backend for a todo app using PostgreSQL.'
});
// Combine with memory config
createSession({
memory: ['persona', 'project'], // Only include these blocks
persona: 'You are a Go expert.',
project: 'CLI tool for managing Docker containers.'
});
```
### Tool Execution
Execute tools with automatic permission handling:
```typescript
import { prompt } from '@letta-ai/letta-code-sdk';
// Run shell commands
const result = await prompt('List all TypeScript files', {
allowedTools: ['Glob', 'Bash'],
permissionMode: 'bypassPermissions',
cwd: '/path/to/project'
});
// Read and analyze code
const analysis = await prompt('Explain what auth.ts does', {
allowedTools: ['Read', 'Grep'],
permissionMode: 'bypassPermissions'
});
```
## API Reference
### Functions
| Function | Description |
|----------|-------------|
| `prompt(message, options?)` | One-shot query, returns result directly |
| `createSession(options?)` | Create new agent session |
| `resumeSession(agentId, options?)` | Resume existing agent by ID |
| `resumeConversation(conversationId, options?)` | Resume specific conversation (derives agent automatically) |
### Session
| Property/Method | Description |
|-----------------|-------------|
| `send(message)` | Send user message |
| `stream()` | AsyncGenerator yielding messages |
| `close()` | Close the session |
| `agentId` | Agent ID (for resuming later) |
| `sessionId` | Current session ID |
| `conversationId` | Conversation ID (for resuming specific thread) |
### Options
```typescript
interface SessionOptions {
// Model selection
model?: string;
// Conversation options
conversationId?: string; // Resume specific conversation
newConversation?: boolean; // Create new conversation on agent
continue?: boolean; // Resume last session (agent + conversation)
defaultConversation?: boolean; // Use agent's default conversation
// System prompt: string or preset config
systemPrompt?: string | {
type: 'preset';
preset: 'default' | 'letta-claude' | 'letta-codex' | 'letta-gemini' | 'claude' | 'codex' | 'gemini';
append?: string;
};
// Memory blocks: preset names, custom blocks, or mixed
memory?: Array<string | CreateBlock | { blockId: string }>;
// Convenience: set block values directly
persona?: string;
human?: string;
project?: string;
// Tool configuration
allowedTools?: string[];
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions';
// Working directory
cwd?: string;
}
```
### Message Types
```typescript
// Streamed during receive()
interface SDKAssistantMessage {
type: 'assistant';
content: string;
uuid: string;
}
// Final message
interface SDKResultMessage {
type: 'result';
success: boolean;
result?: string;
error?: string;
durationMs: number;
conversationId: string;
}
```
## Examples
See [`examples/`](./examples/) for comprehensive examples including:
- Basic session usage
- Multi-turn conversations
- Session resume with persistent memory
- **Multi-threaded conversations** (resumeConversation, newConversation)
- System prompt configuration
- Memory block customization
- Tool execution (Bash, Glob, Read, etc.)
Run examples:
```bash
bun examples/v2-examples.ts all
# Run just conversation tests
bun examples/v2-examples.ts conversations
```
## License
Apache-2.0

40
build.ts Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bun
/**
* Build script for Letta Code SDK
* Bundles TypeScript source and generates declarations
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Read version from package.json
const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
const version = pkg.version;
console.log(`📦 Building Letta Code SDK v${version}...`);
// Bundle with Bun
await Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./dist",
target: "node",
format: "esm",
minify: false,
sourcemap: "external",
});
// Generate type declarations
console.log("📝 Generating type declarations...");
const tscResult = Bun.spawnSync(["bunx", "tsc", "-p", "tsconfig.build.json"]);
if (tscResult.exitCode !== 0) {
console.error("Type generation failed:", tscResult.stderr.toString());
process.exit(1);
}
console.log("✅ Build complete!");
console.log(` Output: dist/`);

822
examples/v2-examples.ts Normal file
View File

@@ -0,0 +1,822 @@
#!/usr/bin/env bun
/**
* Letta Code SDK V2 Examples
*
* Comprehensive tests for all SDK features.
* Parallel to Claude Agent SDK V2 examples.
*
* Run with: LETTA_CLI_PATH=path/to/letta.js bun v2-examples.ts [example]
*/
import { createSession, resumeSession, resumeConversation, prompt } from '../src/index.js';
const CLI_PATH = process.env.LETTA_CLI_PATH;
if (!CLI_PATH) {
console.error('Set LETTA_CLI_PATH environment variable');
process.exit(1);
}
async function main() {
const example = process.argv[2] || 'basic';
switch (example) {
case 'basic':
await basicSession();
break;
case 'multi-turn':
await multiTurn();
break;
case 'one-shot':
await oneShot();
break;
case 'resume':
await sessionResume();
break;
case 'options':
await testOptions();
break;
case 'message-types':
await testMessageTypes();
break;
case 'session-properties':
await testSessionProperties();
break;
case 'tool-execution':
await testToolExecution();
break;
case 'permission-callback':
await testPermissionCallback();
break;
case 'system-prompt':
await testSystemPrompt();
break;
case 'memory-config':
await testMemoryConfig();
break;
case 'convenience-props':
await testConvenienceProps();
break;
case 'conversations':
await testConversations();
break;
case 'all':
await basicSession();
await multiTurn();
await oneShot();
await sessionResume();
await testOptions();
await testMessageTypes();
await testSessionProperties();
await testToolExecution();
await testPermissionCallback();
await testSystemPrompt();
await testMemoryConfig();
await testConvenienceProps();
await testConversations();
console.log('\n✅ All examples passed');
break;
default:
console.log('Usage: bun v2-examples.ts [basic|multi-turn|one-shot|resume|options|message-types|session-properties|tool-execution|permission-callback|system-prompt|memory-config|convenience-props|conversations|all]');
}
}
// ═══════════════════════════════════════════════════════════════
// BASIC EXAMPLES
// ═══════════════════════════════════════════════════════════════
// Basic session with send/receive pattern
async function basicSession() {
console.log('=== Basic Session ===\n');
await using session = createSession({
model: 'haiku',
permissionMode: 'bypassPermissions',
});
await session.send('Hello! Introduce yourself in one sentence.');
let response = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') {
response += msg.content;
}
if (msg.type === 'result') {
console.log(`Letta: ${response.trim()}`);
console.log(`[Result: ${msg.success ? 'success' : 'failed'}, ${msg.durationMs}ms]`);
}
}
console.log();
}
// Multi-turn conversation
async function multiTurn() {
console.log('=== Multi-Turn Conversation ===\n');
await using session = createSession({
model: 'haiku',
permissionMode: 'bypassPermissions',
});
// Turn 1
await session.send('What is 5 + 3? Just the number.');
let turn1Result = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') turn1Result += msg.content;
}
console.log(`Turn 1: ${turn1Result.trim()}`);
// Turn 2 - agent remembers context
await session.send('Multiply that by 2. Just the number.');
let turn2Result = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') turn2Result += msg.content;
}
console.log(`Turn 2: ${turn2Result.trim()}`);
console.log();
}
// One-shot convenience function
async function oneShot() {
console.log('=== One-Shot Prompt ===\n');
const result = await prompt('What is the capital of France? One word.', {
model: 'haiku',
permissionMode: 'bypassPermissions',
});
if (result.success) {
console.log(`Answer: ${result.result}`);
console.log(`Duration: ${result.durationMs}ms`);
} else {
console.log(`Error: ${result.error}`);
}
console.log();
}
// Session resume - with PERSISTENT MEMORY
async function sessionResume() {
console.log('=== Session Resume (Persistent Memory) ===\n');
let agentId: string | null = null;
// First session - establish a memory
{
const session = createSession({
model: 'haiku',
permissionMode: 'bypassPermissions',
});
console.log('[Session 1] Teaching agent a secret word...');
await session.send('Remember this secret word: "pineapple". Store it in your memory.');
let response = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') response += msg.content;
if (msg.type === 'result') {
console.log(`[Session 1] Agent: ${response.trim()}`);
}
}
agentId = session.agentId;
console.log(`[Session 1] Agent ID: ${agentId}\n`);
session.close();
}
console.log('--- Session closed. Agent persists on server. ---\n');
// Resume and verify agent remembers
{
await using session = resumeSession(agentId!, {
permissionMode: 'bypassPermissions',
});
console.log('[Session 2] Asking agent for the secret word...');
await session.send('What is the secret word I told you to remember?');
let response = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') response += msg.content;
if (msg.type === 'result') {
console.log(`[Session 2] Agent: ${response.trim()}`);
}
}
}
console.log();
}
// ═══════════════════════════════════════════════════════════════
// OPTIONS TESTS
// ═══════════════════════════════════════════════════════════════
async function testOptions() {
console.log('=== Testing Options ===\n');
// Test model option
console.log('Testing model option...');
const modelResult = await prompt('Say "model test ok"', {
model: 'haiku',
permissionMode: 'bypassPermissions',
});
console.log(` model: ${modelResult.success ? 'PASS' : 'FAIL'} - ${modelResult.result?.slice(0, 50)}`);
// Test systemPrompt option
console.log('Testing systemPrompt option...');
const sysPromptResult = await prompt('Tell me a fun fact about penguins in one sentence.', {
model: 'haiku',
systemPrompt: 'You love penguins and always try to work penguin facts into conversations.',
permissionMode: 'bypassPermissions',
});
const hasPenguin = sysPromptResult.result?.toLowerCase().includes('penguin');
console.log(` systemPrompt: ${hasPenguin ? 'PASS' : 'PARTIAL'} - ${sysPromptResult.result?.slice(0, 80)}`);
// Test cwd option
console.log('Testing cwd option...');
const cwdResult = await prompt('Run pwd to show current directory', {
model: 'haiku',
cwd: '/tmp',
allowedTools: ['Bash'],
permissionMode: 'bypassPermissions',
});
const hasTmp = cwdResult.result?.includes('/tmp');
console.log(` cwd: ${hasTmp ? 'PASS' : 'CHECK'} - ${cwdResult.result?.slice(0, 60)}`);
// Test allowedTools option with tool execution
console.log('Testing allowedTools option...');
const toolsResult = await prompt('Run: echo tool-test-ok', {
model: 'haiku',
allowedTools: ['Bash'],
permissionMode: 'bypassPermissions',
});
const hasToolOutput = toolsResult.result?.includes('tool-test-ok');
console.log(` allowedTools: ${hasToolOutput ? 'PASS' : 'CHECK'} - ${toolsResult.result?.slice(0, 60)}`);
// Test permissionMode: bypassPermissions
console.log('Testing permissionMode: bypassPermissions...');
const bypassResult = await prompt('Run: echo bypass-test', {
model: 'haiku',
allowedTools: ['Bash'],
permissionMode: 'bypassPermissions',
});
const hasBypassOutput = bypassResult.result?.includes('bypass-test');
console.log(` permissionMode: ${hasBypassOutput ? 'PASS' : 'CHECK'}`);
console.log();
}
// ═══════════════════════════════════════════════════════════════
// MESSAGE TYPES TESTS
// ═══════════════════════════════════════════════════════════════
async function testMessageTypes() {
console.log('=== Testing Message Types ===\n');
const session = createSession({
model: 'haiku',
permissionMode: 'bypassPermissions',
});
await session.send('Say "hello" exactly');
let sawAssistant = false;
let sawResult = false;
let assistantContent = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') {
sawAssistant = true;
assistantContent += msg.content;
// Verify assistant message has uuid
if (!msg.uuid) {
console.log(' assistant.uuid: FAIL - missing uuid');
}
}
if (msg.type === 'result') {
sawResult = true;
// Verify result message structure
const hasSuccess = typeof msg.success === 'boolean';
const hasDuration = typeof msg.durationMs === 'number';
console.log(` result.success: ${hasSuccess ? 'PASS' : 'FAIL'}`);
console.log(` result.durationMs: ${hasDuration ? 'PASS' : 'FAIL'}`);
console.log(` result.result: ${msg.result ? 'PASS' : 'FAIL (empty)'}`);
}
}
console.log(` assistant message received: ${sawAssistant ? 'PASS' : 'FAIL'}`);
console.log(` result message received: ${sawResult ? 'PASS' : 'FAIL'}`);
session.close();
console.log();
}
// ═══════════════════════════════════════════════════════════════
// SESSION PROPERTIES TESTS
// ═══════════════════════════════════════════════════════════════
async function testSessionProperties() {
console.log('=== Testing Session Properties ===\n');
const session = createSession({
model: 'haiku',
permissionMode: 'bypassPermissions',
});
// Before send - properties should be null
console.log(` agentId before send: ${session.agentId === null ? 'PASS (null)' : 'FAIL'}`);
console.log(` sessionId before send: ${session.sessionId === null ? 'PASS (null)' : 'FAIL'}`);
await session.send('Hi');
for await (const _ of session.stream()) {
// drain
}
// After send - properties should be set
const hasAgentId = session.agentId !== null && session.agentId.startsWith('agent-');
const hasSessionId = session.sessionId !== null;
console.log(` agentId after send: ${hasAgentId ? 'PASS' : 'FAIL'} - ${session.agentId}`);
console.log(` sessionId after send: ${hasSessionId ? 'PASS' : 'FAIL'} - ${session.sessionId}`);
// Test close()
session.close();
console.log(` close(): PASS (no error)`);
console.log();
}
// ═══════════════════════════════════════════════════════════════
// TOOL EXECUTION TESTS
// ═══════════════════════════════════════════════════════════════
async function testToolExecution() {
console.log('=== Testing Tool Execution ===\n');
// Test 1: Basic command execution
console.log('Testing basic command execution...');
const echoResult = await prompt('Run: echo hello-world', {
model: 'haiku',
allowedTools: ['Bash'],
permissionMode: 'bypassPermissions',
});
const hasHello = echoResult.result?.includes('hello-world');
console.log(` echo command: ${hasHello ? 'PASS' : 'FAIL'}`);
// Test 2: Command with arguments
console.log('Testing command with arguments...');
const argsResult = await prompt('Run: echo "arg1 arg2 arg3"', {
model: 'haiku',
allowedTools: ['Bash'],
permissionMode: 'bypassPermissions',
});
const hasArgs = argsResult.result?.includes('arg1') && argsResult.result?.includes('arg3');
console.log(` echo with args: ${hasArgs ? 'PASS' : 'FAIL'}`);
// Test 3: File reading with Glob
console.log('Testing Glob tool...');
const globResult = await prompt('List all .ts files in the current directory using Glob', {
model: 'haiku',
allowedTools: ['Glob'],
permissionMode: 'bypassPermissions',
});
console.log(` Glob tool: ${globResult.success ? 'PASS' : 'FAIL'}`);
// Test 4: Multi-step tool usage (agent decides which tools to use)
console.log('Testing multi-step tool usage...');
const multiResult = await prompt('First run "echo step1", then run "echo step2". Show me both outputs.', {
model: 'haiku',
allowedTools: ['Bash'],
permissionMode: 'bypassPermissions',
});
const hasStep1 = multiResult.result?.includes('step1');
const hasStep2 = multiResult.result?.includes('step2');
console.log(` multi-step: ${hasStep1 && hasStep2 ? 'PASS' : 'PARTIAL'} (step1: ${hasStep1}, step2: ${hasStep2})`);
console.log();
}
// ═══════════════════════════════════════════════════════════════
// PERMISSION CALLBACK TESTS
// ═══════════════════════════════════════════════════════════════
async function testPermissionCallback() {
console.log('=== Testing Permission Callback ===\n');
// Note: permissionMode 'default' with NO allowedTools triggers callback
// allowedTools auto-allows tools, bypassing the callback
// Test 1: Allow specific commands via callback
console.log('Testing canUseTool callback (allow)...');
const allowResult = await prompt('Run: echo callback-allowed', {
model: 'haiku',
// NO allowedTools - this ensures callback is invoked
permissionMode: 'default',
canUseTool: async (toolName, toolInput) => {
console.error('CALLBACK:', toolName, toolInput);
const command = (toolInput as { command?: string }).command || '';
if (command.includes('callback-allowed')) {
return { allow: true, reason: 'Command whitelisted' };
}
return { allow: false, reason: 'Command not whitelisted' };
},
});
const hasAllowed = allowResult.result?.includes('callback-allowed');
console.log(` allow via callback: ${hasAllowed ? 'PASS' : 'FAIL'}`);
// Test 2: Deny specific commands via callback
console.log('Testing canUseTool callback (deny)...');
const denyResult = await prompt('Run: echo dangerous-command', {
model: 'haiku',
permissionMode: 'default',
canUseTool: async (toolName, toolInput) => {
const command = (toolInput as { command?: string }).command || '';
if (command.includes('dangerous')) {
return { allow: false, reason: 'Dangerous command blocked' };
}
return { allow: true };
},
});
// Agent should report that it couldn't execute the command
const wasDenied = !denyResult.result?.includes('dangerous-command');
console.log(` deny via callback: ${wasDenied ? 'PASS' : 'CHECK'}`);
console.log();
}
// ═══════════════════════════════════════════════════════════════
// SYSTEM PROMPT TESTS
// ═══════════════════════════════════════════════════════════════
async function testSystemPrompt() {
console.log('=== Testing System Prompt Configuration ===\n');
// Test 1: Preset system prompt
console.log('Testing preset system prompt...');
const presetResult = await prompt('What kind of agent are you? One sentence.', {
model: 'haiku',
systemPrompt: { type: 'preset', preset: 'letta-claude' },
permissionMode: 'bypassPermissions',
});
console.log(` preset (letta-claude): ${presetResult.success ? 'PASS' : 'FAIL'}`);
console.log(` Response: ${presetResult.result?.slice(0, 80)}...`);
// Test 2: Preset with append
console.log('Testing preset with append...');
const appendResult = await prompt('Say hello', {
model: 'haiku',
systemPrompt: {
type: 'preset',
preset: 'letta-claude',
append: 'Always end your responses with "🎉"'
},
permissionMode: 'bypassPermissions',
});
const hasEmoji = appendResult.result?.includes('🎉');
console.log(` preset with append: ${hasEmoji ? 'PASS' : 'CHECK'}`);
console.log(` Response: ${appendResult.result?.slice(0, 80)}...`);
// Test 3: Custom string system prompt
console.log('Testing custom string system prompt...');
const customResult = await prompt('What is your specialty?', {
model: 'haiku',
systemPrompt: 'You are a pirate captain. Always speak like a pirate.',
permissionMode: 'bypassPermissions',
});
const hasPirateSpeak = customResult.result?.toLowerCase().includes('arr') ||
customResult.result?.toLowerCase().includes('matey') ||
customResult.result?.toLowerCase().includes('ship');
console.log(` custom string: ${customResult.success ? 'PASS' : 'FAIL'}`);
console.log(` Response: ${customResult.result?.slice(0, 80)}...`);
// Test 4: Basic preset (claude - no skills/memory)
console.log('Testing basic preset (claude)...');
const basicResult = await prompt('Hello, just say hi back', {
model: 'haiku',
systemPrompt: { type: 'preset', preset: 'claude' },
permissionMode: 'bypassPermissions',
});
console.log(` basic preset (claude): ${basicResult.success ? 'PASS' : 'FAIL'}`);
console.log();
}
// ═══════════════════════════════════════════════════════════════
// MEMORY CONFIGURATION TESTS
// ═══════════════════════════════════════════════════════════════
async function testMemoryConfig() {
console.log('=== Testing Memory Configuration ===\n');
// Test 1: Default memory (persona, human, project)
console.log('Testing default memory blocks...');
const defaultResult = await prompt('What memory blocks do you have? List their labels.', {
model: 'haiku',
permissionMode: 'bypassPermissions',
});
const hasDefaultBlocks = defaultResult.result?.includes('persona') ||
defaultResult.result?.includes('project');
console.log(` default blocks: ${defaultResult.success ? 'PASS' : 'FAIL'}`);
console.log(` Response mentions blocks: ${hasDefaultBlocks ? 'yes' : 'check manually'}`);
// Test 2: Specific preset blocks only
console.log('Testing specific preset blocks...');
const specificResult = await prompt('List your memory block labels', {
model: 'haiku',
memory: ['project'],
permissionMode: 'bypassPermissions',
});
console.log(` specific blocks [project]: ${specificResult.success ? 'PASS' : 'FAIL'}`);
// Test 3: Custom blocks
console.log('Testing custom memory blocks...');
const customResult = await prompt('What does your "rules" memory block say?', {
model: 'haiku',
memory: [
{ label: 'rules', value: 'Always be concise. Never use more than 10 words.' }
],
permissionMode: 'bypassPermissions',
});
const isConcise = (customResult.result?.split(' ').length || 0) < 20;
console.log(` custom blocks: ${customResult.success ? 'PASS' : 'FAIL'}`);
console.log(` Response is concise: ${isConcise ? 'yes' : 'check'}`);
// Test 4: Mixed preset and custom blocks
console.log('Testing mixed blocks (preset + custom)...');
const mixedResult = await prompt('List your memory blocks', {
model: 'haiku',
memory: [
'project',
{ label: 'custom-context', value: 'This is a test context block.' }
],
permissionMode: 'bypassPermissions',
});
console.log(` mixed blocks: ${mixedResult.success ? 'PASS' : 'FAIL'}`);
// Test 5: Empty memory (core blocks only)
console.log('Testing empty memory (core only)...');
const emptyResult = await prompt('Hello', {
model: 'haiku',
memory: [],
permissionMode: 'bypassPermissions',
});
console.log(` empty memory: ${emptyResult.success ? 'PASS' : 'FAIL'}`);
console.log();
}
// ═══════════════════════════════════════════════════════════════
// CONVENIENCE PROPS TESTS
// ═══════════════════════════════════════════════════════════════
async function testConvenienceProps() {
console.log('=== Testing Convenience Props ===\n');
// Test 1: persona prop
console.log('Testing persona prop...');
const personaResult = await prompt('Describe your personality in one sentence', {
model: 'haiku',
persona: 'You are an enthusiastic cooking assistant who loves Italian food.',
permissionMode: 'bypassPermissions',
});
const hasItalian = personaResult.result?.toLowerCase().includes('italian') ||
personaResult.result?.toLowerCase().includes('cook');
console.log(` persona: ${personaResult.success ? 'PASS' : 'FAIL'}`);
console.log(` Response mentions cooking/Italian: ${hasItalian ? 'yes' : 'check'}`);
// Test 2: project prop
console.log('Testing project prop...');
const projectResult = await prompt('What project are you helping with?', {
model: 'haiku',
project: 'A React Native mobile app for tracking daily habits.',
permissionMode: 'bypassPermissions',
});
const hasProject = projectResult.result?.toLowerCase().includes('react') ||
projectResult.result?.toLowerCase().includes('habit') ||
projectResult.result?.toLowerCase().includes('mobile');
console.log(` project: ${projectResult.success ? 'PASS' : 'FAIL'}`);
console.log(` Response mentions project: ${hasProject ? 'yes' : 'check'}`);
// Test 3: human prop
console.log('Testing human prop...');
const humanResult = await prompt('What do you know about me?', {
model: 'haiku',
human: 'Name: Bob. Senior developer. Prefers TypeScript over JavaScript.',
permissionMode: 'bypassPermissions',
});
const hasHuman = humanResult.result?.toLowerCase().includes('bob') ||
humanResult.result?.toLowerCase().includes('typescript');
console.log(` human: ${humanResult.success ? 'PASS' : 'FAIL'}`);
console.log(` Response mentions user info: ${hasHuman ? 'yes' : 'check'}`);
// Test 4: Multiple convenience props together
console.log('Testing multiple convenience props...');
const multiResult = await prompt('Introduce yourself and the project briefly', {
model: 'haiku',
persona: 'You are a friendly code reviewer.',
project: 'FastAPI backend service.',
human: 'Name: Alice.',
permissionMode: 'bypassPermissions',
});
console.log(` multiple props: ${multiResult.success ? 'PASS' : 'FAIL'}`);
console.log(` Response: ${multiResult.result?.slice(0, 100)}...`);
// Test 5: Convenience props with specific memory blocks
console.log('Testing convenience props with memory config...');
const combinedResult = await prompt('What is in your persona block?', {
model: 'haiku',
memory: ['persona', 'project'],
persona: 'You are a database expert specializing in PostgreSQL.',
permissionMode: 'bypassPermissions',
});
const hasDB = combinedResult.result?.toLowerCase().includes('database') ||
combinedResult.result?.toLowerCase().includes('postgresql');
console.log(` props with memory: ${combinedResult.success ? 'PASS' : 'FAIL'}`);
console.log(` Response mentions DB: ${hasDB ? 'yes' : 'check'}`);
console.log();
}
// ═══════════════════════════════════════════════════════════════
// CONVERSATION TESTS
// ═══════════════════════════════════════════════════════════════
async function testConversations() {
console.log('=== Testing Conversation Support ===\n');
let agentId: string | null = null;
let conversationId1: string | null = null;
let conversationId2: string | null = null;
// Test 1: Create session and get conversationId (default)
console.log('Test 1: Create session and get conversationId...');
{
const session = createSession({
model: 'haiku',
permissionMode: 'bypassPermissions',
});
await session.send('Remember: the secret code is ALPHA. Store this in memory.');
for await (const msg of session.stream()) {
// drain
}
agentId = session.agentId;
conversationId1 = session.conversationId;
const hasAgentId = agentId !== null && agentId.startsWith('agent-');
const hasConvId = conversationId1 !== null;
console.log(` agentId: ${hasAgentId ? 'PASS' : 'FAIL'} - ${agentId}`);
console.log(` conversationId: ${hasConvId ? 'PASS' : 'FAIL'} - ${conversationId1}`);
// Note: "default" is a sentinel meaning the agent's primary message history
if (conversationId1 === 'default') {
console.log(' (conversationId "default" = agent\'s primary history, not a real conversation ID)');
}
session.close();
}
// Test 2: Create NEW conversation to get a real conversation ID
console.log('\nTest 2: Create new conversation (newConversation: true)...');
{
const session = resumeSession(agentId!, {
newConversation: true,
permissionMode: 'bypassPermissions',
});
await session.send('Remember: the secret code for THIS conversation is BETA.');
for await (const msg of session.stream()) {
// drain
}
conversationId1 = session.conversationId;
const isRealConvId = conversationId1 !== null && conversationId1 !== 'default';
console.log(` newConversation created: ${isRealConvId ? 'PASS' : 'FAIL'}`);
console.log(` conversationId: ${conversationId1}`);
session.close();
}
// Test 3: Resume conversation by conversationId (only works with real conv IDs)
console.log('\nTest 3: Resume conversation by conversationId...');
if (conversationId1 === 'default') {
console.log(' SKIP - "default" is not a real conversation ID');
console.log(' Use resumeSession(agentId) to resume default conversation');
} else {
await using session = resumeConversation(conversationId1!, {
permissionMode: 'bypassPermissions',
});
await session.send('What is the secret code for this conversation?');
let response = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') response += msg.content;
}
const remembers = response.toLowerCase().includes('beta');
console.log(` resumeConversation: ${remembers ? 'PASS' : 'FAIL'}`);
console.log(` Response: ${response.slice(0, 80)}...`);
// Verify same conversationId
const sameConv = session.conversationId === conversationId1;
console.log(` same conversationId: ${sameConv ? 'PASS' : 'FAIL'}`);
}
// Test 4: Create another new conversation (verify different IDs)
console.log('\nTest 4: Create another new conversation...');
{
await using session = resumeSession(agentId!, {
newConversation: true,
permissionMode: 'bypassPermissions',
});
await session.send('Say "third conversation"');
let response = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') response += msg.content;
}
conversationId2 = session.conversationId;
// New conversation should have different ID
const differentConv = conversationId2 !== conversationId1;
console.log(` different from conversationId1: ${differentConv ? 'PASS' : 'FAIL'}`);
console.log(` conversationId1: ${conversationId1}`);
console.log(` conversationId2: ${conversationId2}`);
}
// Test 5: defaultConversation option
console.log('\nTest 5: defaultConversation option...');
{
await using session = resumeSession(agentId!, {
defaultConversation: true,
permissionMode: 'bypassPermissions',
});
await session.send('Say "default conversation test ok"');
let response = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') response += msg.content;
}
const hasDefaultConv = session.conversationId === 'default' || session.conversationId !== null;
console.log(` defaultConversation: ${hasDefaultConv ? 'PASS' : 'CHECK'}`);
console.log(` conversationId: ${session.conversationId}`);
}
// Test 6: conversationId in result message
console.log('\nTest 6: conversationId in result message...');
{
await using session = resumeConversation(conversationId1!, {
permissionMode: 'bypassPermissions',
});
await session.send('Hi');
let resultConvId: string | null = null;
for await (const msg of session.stream()) {
if (msg.type === 'result') {
resultConvId = msg.conversationId;
}
}
const hasResultConvId = resultConvId !== null;
const matchesSession = resultConvId === session.conversationId;
console.log(` result.conversationId: ${hasResultConvId ? 'PASS' : 'FAIL'}`);
console.log(` matches session.conversationId: ${matchesSession ? 'PASS' : 'FAIL'}`);
}
// Test 7: continue option (resume last session)
console.log('\nTest 7: continue option...');
{
// Note: This test may behave differently depending on local state
// The --continue flag resumes the last used agent + conversation
try {
await using session = createSession({
continue: true,
permissionMode: 'bypassPermissions',
});
await session.send('Say "continue test ok"');
for await (const msg of session.stream()) {
// drain
}
const hasIds = session.agentId !== null && session.conversationId !== null;
console.log(` continue: ${hasIds ? 'PASS' : 'CHECK'}`);
console.log(` agentId: ${session.agentId}`);
console.log(` conversationId: ${session.conversationId}`);
} catch (err) {
// --continue may fail if no previous session exists
console.log(` continue: SKIP (no previous session)`);
}
}
console.log();
}
main().catch(console.error);

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "@letta-ai/letta-code-sdk",
"version": "0.0.1",
"description": "SDK for programmatic control of Letta Code CLI",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "bun run build.ts",
"dev": "bun run build.ts --watch",
"check": "tsc --noEmit",
"test": "bun test"
},
"repository": {
"type": "git",
"url": "https://github.com/letta-ai/letta-code-sdk"
},
"dependencies": {
"@letta-ai/letta-code": "latest"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
},
"publishConfig": {
"access": "public"
},
"license": "Apache-2.0"
}

159
src/index.ts Normal file
View File

@@ -0,0 +1,159 @@
/**
* Letta Code SDK
*
* Programmatic control of Letta Code CLI with persistent agent memory.
*
* @example
* ```typescript
* import { createSession, prompt } from '@letta-ai/letta-code-sdk';
*
* // One-shot
* const result = await prompt('What is 2+2?', { model: 'claude-sonnet-4-20250514' });
*
* // Multi-turn session
* await using session = createSession({ model: 'claude-sonnet-4-20250514' });
* await session.send('Hello!');
* for await (const msg of session.stream()) {
* if (msg.type === 'assistant') console.log(msg.content);
* }
*
* // Resume with persistent memory
* await using resumed = resumeSession(agentId, { model: 'claude-sonnet-4-20250514' });
* ```
*/
import { Session } from "./session.js";
import type { SessionOptions, SDKMessage, SDKResultMessage } from "./types.js";
// Re-export types
export type {
SessionOptions,
SDKMessage,
SDKInitMessage,
SDKAssistantMessage,
SDKToolCallMessage,
SDKToolResultMessage,
SDKReasoningMessage,
SDKResultMessage,
SDKStreamEventMessage,
PermissionMode,
PermissionResult,
CanUseToolCallback,
} from "./types.js";
export { Session } from "./session.js";
/**
* Create a new session with a fresh Letta agent.
*
* The agent will have persistent memory that survives across sessions.
* Use `resumeSession` to continue a conversation with an existing agent.
*
* @example
* ```typescript
* await using session = createSession({ model: 'claude-sonnet-4-20250514' });
* await session.send('My name is Alice');
* for await (const msg of session.stream()) {
* console.log(msg);
* }
* console.log(`Agent ID: ${session.agentId}`); // Save this to resume later
* ```
*/
export function createSession(options: SessionOptions = {}): Session {
return new Session(options);
}
/**
* Resume an existing session with a Letta agent.
*
* Unlike Claude Agent SDK (ephemeral sessions), Letta agents have persistent
* memory. You can resume a conversation days later and the agent will remember.
*
* @example
* ```typescript
* // Days later...
* await using session = resumeSession(agentId, { model: 'claude-sonnet-4-20250514' });
* await session.send('What is my name?');
* for await (const msg of session.stream()) {
* // Agent remembers: "Your name is Alice"
* }
* ```
*/
export function resumeSession(
agentId: string,
options: SessionOptions = {}
): Session {
return new Session({ ...options, agentId });
}
/**
* Resume an existing conversation.
*
* Conversations are threads within an agent. The agent is derived automatically
* from the conversation ID. Use this to continue a specific conversation thread.
*
* @example
* ```typescript
* // Resume a specific conversation
* await using session = resumeConversation(conversationId);
* await session.send('Continue our discussion...');
* for await (const msg of session.stream()) {
* console.log(msg);
* }
* ```
*/
export function resumeConversation(
conversationId: string,
options: SessionOptions = {}
): Session {
return new Session({ ...options, conversationId });
}
/**
* One-shot prompt convenience function.
*
* Creates a session, sends the prompt, collects the response, and closes.
* Returns the final result message.
*
* @example
* ```typescript
* const result = await prompt('What is the capital of France?', {
* model: 'claude-sonnet-4-20250514'
* });
* if (result.success) {
* console.log(result.result);
* }
* ```
*/
export async function prompt(
message: string,
options: SessionOptions = {}
): Promise<SDKResultMessage> {
const session = createSession(options);
try {
await session.send(message);
let result: SDKResultMessage | null = null;
for await (const msg of session.stream()) {
if (msg.type === "result") {
result = msg;
break;
}
}
if (!result) {
return {
type: "result",
success: false,
error: "No result received",
durationMs: 0,
conversationId: session.conversationId,
};
}
return result;
} finally {
session.close();
}
}

358
src/session.ts Normal file
View File

@@ -0,0 +1,358 @@
/**
* Session
*
* Represents a conversation session with a Letta agent.
* Implements the V2 API pattern: send() / receive()
*/
import { SubprocessTransport } from "./transport.js";
import type {
SessionOptions,
SDKMessage,
SDKInitMessage,
SDKAssistantMessage,
SDKResultMessage,
WireMessage,
ControlRequest,
CanUseToolControlRequest,
CanUseToolResponse,
CanUseToolResponseAllow,
CanUseToolResponseDeny,
} from "./types.js";
import { validateSessionOptions } from "./validation.js";
export class Session implements AsyncDisposable {
private transport: SubprocessTransport;
private _agentId: string | null = null;
private _sessionId: string | null = null;
private _conversationId: string | null = null;
private initialized = false;
constructor(
private options: SessionOptions & { agentId?: string } = {}
) {
// Validate options before creating transport
validateSessionOptions(options);
this.transport = new SubprocessTransport(options);
}
/**
* Initialize the session (called automatically on first send)
*/
async initialize(): Promise<SDKInitMessage> {
if (this.initialized) {
throw new Error("Session already initialized");
}
await this.transport.connect();
// Send initialize control request
await this.transport.write({
type: "control_request",
request_id: "init_1",
request: { subtype: "initialize" },
});
// Wait for init message
for await (const msg of this.transport.messages()) {
if (msg.type === "system" && "subtype" in msg && msg.subtype === "init") {
const initMsg = msg as WireMessage & {
agent_id: string;
session_id: string;
conversation_id: string;
model: string;
tools: string[];
};
this._agentId = initMsg.agent_id;
this._sessionId = initMsg.session_id;
this._conversationId = initMsg.conversation_id;
this.initialized = true;
return {
type: "init",
agentId: initMsg.agent_id,
sessionId: initMsg.session_id,
conversationId: initMsg.conversation_id,
model: initMsg.model,
tools: initMsg.tools,
};
}
}
throw new Error("Failed to initialize session - no init message received");
}
/**
* Send a message to the agent
*/
async send(message: string): Promise<void> {
if (!this.initialized) {
await this.initialize();
}
await this.transport.write({
type: "user",
message: { role: "user", content: message },
});
}
/**
* Stream messages from the agent
*/
async *stream(): AsyncGenerator<SDKMessage> {
for await (const wireMsg of this.transport.messages()) {
// Handle CLI → SDK control requests (e.g., can_use_tool)
if (wireMsg.type === "control_request") {
const controlReq = wireMsg as ControlRequest;
if (controlReq.request.subtype === "can_use_tool") {
await this.handleCanUseTool(
controlReq.request_id,
controlReq.request as CanUseToolControlRequest
);
continue;
}
}
const sdkMsg = this.transformMessage(wireMsg);
if (sdkMsg) {
yield sdkMsg;
// Stop on result message
if (sdkMsg.type === "result") {
break;
}
}
}
}
/**
* Handle can_use_tool control request from CLI (Claude SDK compatible format)
*/
private async handleCanUseTool(
requestId: string,
req: CanUseToolControlRequest
): Promise<void> {
let response: CanUseToolResponse;
if (this.options.canUseTool) {
try {
const result = await this.options.canUseTool(req.tool_name, req.input);
if (result.allow) {
response = {
behavior: "allow",
updatedInput: null, // TODO: not supported
updatedPermissions: [], // TODO: not implemented
} satisfies CanUseToolResponseAllow;
} else {
response = {
behavior: "deny",
message: result.reason ?? "Denied by canUseTool callback",
interrupt: false, // TODO: not wired up yet
} satisfies CanUseToolResponseDeny;
}
} catch (err) {
response = {
behavior: "deny",
message: err instanceof Error ? err.message : "Callback error",
interrupt: false,
};
}
} else {
// No callback registered - deny by default
response = {
behavior: "deny",
message: "No canUseTool callback registered",
interrupt: false,
};
}
// Send control_response (Claude SDK compatible format)
await this.transport.write({
type: "control_response",
response: {
subtype: "success",
request_id: requestId,
response,
},
});
}
/**
* Abort the current operation (interrupt without closing the session)
*/
async abort(): Promise<void> {
await this.transport.write({
type: "control_request",
request_id: `interrupt-${Date.now()}`,
request: { subtype: "interrupt" },
});
}
/**
* Close the session
*/
close(): void {
this.transport.close();
}
/**
* Get the agent ID (available after initialization)
*/
get agentId(): string | null {
return this._agentId;
}
/**
* Get the session ID (available after initialization)
*/
get sessionId(): string | null {
return this._sessionId;
}
/**
* Get the conversation ID (available after initialization)
*/
get conversationId(): string | null {
return this._conversationId;
}
/**
* AsyncDisposable implementation for `await using`
*/
async [Symbol.asyncDispose](): Promise<void> {
this.close();
}
/**
* Transform wire message to SDK message
*/
private transformMessage(wireMsg: WireMessage): SDKMessage | null {
// Init message
if (wireMsg.type === "system" && "subtype" in wireMsg && wireMsg.subtype === "init") {
const msg = wireMsg as WireMessage & {
agent_id: string;
session_id: string;
conversation_id: string;
model: string;
tools: string[];
};
return {
type: "init",
agentId: msg.agent_id,
sessionId: msg.session_id,
conversationId: msg.conversation_id,
model: msg.model,
tools: msg.tools,
};
}
// Handle message types (all have type: "message" with message_type field)
if (wireMsg.type === "message" && "message_type" in wireMsg) {
const msg = wireMsg as WireMessage & {
message_type: string;
uuid: string;
// assistant_message fields
content?: string;
// tool_call_message fields
tool_call?: { name: string; arguments: string; tool_call_id: string };
tool_calls?: Array<{ name: string; arguments: string; tool_call_id: string }>;
// tool_return_message fields
tool_call_id?: string;
tool_return?: string;
status?: "success" | "error";
// reasoning_message fields
reasoning?: string;
};
// Assistant message
if (msg.message_type === "assistant_message" && msg.content) {
return {
type: "assistant",
content: msg.content,
uuid: msg.uuid,
};
}
// Tool call message
if (msg.message_type === "tool_call_message") {
const toolCall = msg.tool_calls?.[0] || msg.tool_call;
if (toolCall) {
let toolInput: Record<string, unknown> = {};
try {
toolInput = JSON.parse(toolCall.arguments);
} catch {
toolInput = { raw: toolCall.arguments };
}
return {
type: "tool_call",
toolCallId: toolCall.tool_call_id,
toolName: toolCall.name,
toolInput,
uuid: msg.uuid,
};
}
}
// Tool return message
if (msg.message_type === "tool_return_message" && msg.tool_call_id) {
return {
type: "tool_result",
toolCallId: msg.tool_call_id,
content: msg.tool_return || "",
isError: msg.status === "error",
uuid: msg.uuid,
};
}
// Reasoning message
if (msg.message_type === "reasoning_message" && msg.reasoning) {
return {
type: "reasoning",
content: msg.reasoning,
uuid: msg.uuid,
};
}
}
// Stream event (partial message updates)
if (wireMsg.type === "stream_event") {
const msg = wireMsg as WireMessage & {
event: {
type: string;
index?: number;
delta?: { type?: string; text?: string; reasoning?: string };
content_block?: { type?: string; text?: string };
};
uuid: string;
};
return {
type: "stream_event",
event: msg.event,
uuid: msg.uuid,
};
}
// Result message
if (wireMsg.type === "result") {
const msg = wireMsg as WireMessage & {
subtype: string;
result?: string;
duration_ms: number;
total_cost_usd?: number;
conversation_id: string;
};
return {
type: "result",
success: msg.subtype === "success",
result: msg.result,
error: msg.subtype !== "success" ? msg.subtype : undefined,
durationMs: msg.duration_ms,
totalCostUsd: msg.total_cost_usd,
conversationId: msg.conversation_id,
};
}
// Skip other message types (system_message, user_message, etc.)
return null;
}
}

342
src/transport.ts Normal file
View File

@@ -0,0 +1,342 @@
/**
* SubprocessTransport
*
* Spawns the Letta Code CLI and communicates via stdin/stdout JSON streams.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { createInterface, type Interface } from "node:readline";
import type { SessionOptions, WireMessage } from "./types.js";
export class SubprocessTransport {
private process: ChildProcess | null = null;
private stdout: Interface | null = null;
private messageQueue: WireMessage[] = [];
private messageResolvers: Array<(msg: WireMessage) => void> = [];
private closed = false;
private agentId?: string;
constructor(
private options: SessionOptions & { agentId?: string } = {}
) {}
/**
* Start the CLI subprocess
*/
async connect(): Promise<void> {
const args = this.buildArgs();
// Find the CLI - use the installed letta-code package
const cliPath = await this.findCli();
this.process = spawn("node", [cliPath, ...args], {
cwd: this.options.cwd || process.cwd(),
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env },
});
if (!this.process.stdout || !this.process.stdin) {
throw new Error("Failed to create subprocess pipes");
}
// Set up stdout reading
this.stdout = createInterface({
input: this.process.stdout,
crlfDelay: Infinity,
});
this.stdout.on("line", (line) => {
if (!line.trim()) return;
try {
const msg = JSON.parse(line) as WireMessage;
this.handleMessage(msg);
} catch {
// Ignore non-JSON lines (stderr leakage, etc.)
}
});
// Handle process exit
this.process.on("close", () => {
this.closed = true;
});
this.process.on("error", (err) => {
console.error("CLI process error:", err);
this.closed = true;
});
}
/**
* Send a message to the CLI via stdin
*/
async write(data: object): Promise<void> {
if (!this.process?.stdin || this.closed) {
throw new Error("Transport not connected");
}
this.process.stdin.write(JSON.stringify(data) + "\n");
}
/**
* Read the next message from the CLI
*/
async read(): Promise<WireMessage | null> {
// Return queued message if available
if (this.messageQueue.length > 0) {
return this.messageQueue.shift()!;
}
// If closed, no more messages
if (this.closed) {
return null;
}
// Wait for next message
return new Promise((resolve) => {
this.messageResolvers.push(resolve);
});
}
/**
* Async iterator for messages
*/
async *messages(): AsyncGenerator<WireMessage> {
while (true) {
const msg = await this.read();
if (msg === null) break;
yield msg;
}
}
/**
* Close the transport
*/
close(): void {
if (this.process) {
this.process.stdin?.end();
this.process.kill();
this.process = null;
}
this.closed = true;
// Resolve any pending readers with null
for (const resolve of this.messageResolvers) {
resolve(null as unknown as WireMessage);
}
this.messageResolvers = [];
}
get isClosed(): boolean {
return this.closed;
}
private handleMessage(msg: WireMessage): void {
// Track agent_id from init message
if (msg.type === "system" && "subtype" in msg && msg.subtype === "init") {
this.agentId = (msg as unknown as { agent_id: string }).agent_id;
}
// If someone is waiting for a message, give it to them
if (this.messageResolvers.length > 0) {
const resolve = this.messageResolvers.shift()!;
resolve(msg);
} else {
// Otherwise queue it
this.messageQueue.push(msg);
}
}
private buildArgs(): string[] {
const args: string[] = [
"--output-format",
"stream-json",
"--input-format",
"stream-json",
];
// Validate conversation + agent combinations
// (These require agentId context, so can't be in validateSessionOptions)
// conversationId (non-default) cannot be used with agentId
if (this.options.conversationId &&
this.options.conversationId !== "default" &&
this.options.agentId) {
throw new Error(
"Cannot use both 'conversationId' and 'agentId'. " +
"When resuming a conversation, the agent is derived automatically."
);
}
// conversationId: "default" requires agentId
if (this.options.conversationId === "default" && !this.options.agentId) {
throw new Error(
"conversationId 'default' requires agentId. " +
"Use resumeSession(agentId, { defaultConversation: true }) instead."
);
}
// defaultConversation requires agentId
if (this.options.defaultConversation && !this.options.agentId) {
throw new Error(
"'defaultConversation' requires agentId. " +
"Use resumeSession(agentId, { defaultConversation: true })."
);
}
// newConversation requires agentId
if (this.options.newConversation && !this.options.agentId) {
throw new Error(
"'newConversation' requires agentId. " +
"Use resumeSession(agentId, { newConversation: true })."
);
}
// Conversation and agent handling
if (this.options.continue) {
// Resume last session (agent + conversation)
args.push("--continue");
} else if (this.options.conversationId) {
// Resume specific conversation (derives agent automatically)
args.push("--conversation", this.options.conversationId);
} else if (this.options.agentId) {
// Resume existing agent
args.push("--agent", this.options.agentId);
if (this.options.newConversation) {
// Create new conversation on this agent
args.push("--new");
} else if (this.options.defaultConversation) {
// Use agent's default conversation explicitly
args.push("--default");
}
} else {
// Create new agent
args.push("--new-agent");
}
// Model
if (this.options.model) {
args.push("-m", this.options.model);
}
// System prompt configuration
if (this.options.systemPrompt !== undefined) {
if (typeof this.options.systemPrompt === "string") {
// Raw string → --system-custom
args.push("--system-custom", this.options.systemPrompt);
} else {
// Preset object → --system (+ optional --system-append)
args.push("--system", this.options.systemPrompt.preset);
if (this.options.systemPrompt.append) {
args.push("--system-append", this.options.systemPrompt.append);
}
}
}
// Memory blocks (only for new agents)
if (this.options.memory !== undefined && !this.options.agentId) {
if (this.options.memory.length === 0) {
// Empty array → no memory blocks (just core)
args.push("--init-blocks", "");
} else {
// Separate preset names from custom/reference blocks
const presetNames: string[] = [];
const memoryBlocksJson: Array<
| { label: string; value: string }
| { blockId: string }
> = [];
for (const item of this.options.memory) {
if (typeof item === "string") {
// Preset name
presetNames.push(item);
} else if ("blockId" in item) {
// Block reference - pass to --memory-blocks
memoryBlocksJson.push(item as { blockId: string });
} else {
// CreateBlock
memoryBlocksJson.push(item as { label: string; value: string });
}
}
// Add preset names via --init-blocks
if (presetNames.length > 0) {
args.push("--init-blocks", presetNames.join(","));
}
// Add custom blocks and block references via --memory-blocks
if (memoryBlocksJson.length > 0) {
args.push("--memory-blocks", JSON.stringify(memoryBlocksJson));
}
}
}
// Convenience props for block values (only for new agents)
if (!this.options.agentId) {
if (this.options.persona !== undefined) {
args.push("--block-value", `persona=${this.options.persona}`);
}
if (this.options.human !== undefined) {
args.push("--block-value", `human=${this.options.human}`);
}
if (this.options.project !== undefined) {
args.push("--block-value", `project=${this.options.project}`);
}
}
// Permission mode
if (this.options.permissionMode === "bypassPermissions") {
args.push("--yolo");
} else if (this.options.permissionMode === "acceptEdits") {
args.push("--accept-edits");
}
// Allowed tools
if (this.options.allowedTools) {
args.push("--allowedTools", this.options.allowedTools.join(","));
}
return args;
}
private async findCli(): Promise<string> {
// Try multiple resolution strategies
const { existsSync } = await import("node:fs");
const { dirname, join } = await import("node:path");
const { fileURLToPath } = await import("node:url");
// Strategy 1: Check LETTA_CLI_PATH env var
if (process.env.LETTA_CLI_PATH && existsSync(process.env.LETTA_CLI_PATH)) {
return process.env.LETTA_CLI_PATH;
}
// Strategy 2: Try to resolve from node_modules
try {
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const resolved = require.resolve("@letta-ai/letta-code/letta.js");
if (existsSync(resolved)) {
return resolved;
}
} catch {
// Continue to next strategy
}
// Strategy 3: Check relative to this file (for local file: deps)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const localPaths = [
join(__dirname, "../../@letta-ai/letta-code/letta.js"),
join(__dirname, "../../../letta-code-prod/letta.js"),
join(__dirname, "../../../letta-code/letta.js"),
];
for (const p of localPaths) {
if (existsSync(p)) {
return p;
}
}
throw new Error(
"Letta Code CLI not found. Set LETTA_CLI_PATH or install @letta-ai/letta-code."
);
}
}

252
src/types.ts Normal file
View File

@@ -0,0 +1,252 @@
/**
* SDK Types
*
* These are the public-facing types for SDK consumers.
* Protocol types are imported from @letta-ai/letta-code/protocol.
*/
// Re-export protocol types for internal use
export type {
WireMessage,
SystemInitMessage,
MessageWire,
ResultMessage,
ErrorMessage,
StreamEvent,
ControlRequest,
ControlResponse,
CanUseToolControlRequest,
CanUseToolResponse,
CanUseToolResponseAllow,
CanUseToolResponseDeny,
// Configuration types
SystemPromptPresetConfig,
CreateBlock,
} from "@letta-ai/letta-code/protocol";
// Import types for use in SessionOptions
import type { CreateBlock } from "@letta-ai/letta-code/protocol";
// ═══════════════════════════════════════════════════════════════
// SYSTEM PROMPT TYPES
// ═══════════════════════════════════════════════════════════════
/**
* Available system prompt presets.
*/
export type SystemPromptPreset =
| "default" // Alias for letta-claude
| "letta-claude" // Full Letta Code prompt (Claude-optimized)
| "letta-codex" // Full Letta Code prompt (Codex-optimized)
| "letta-gemini" // Full Letta Code prompt (Gemini-optimized)
| "claude" // Basic Claude (no skills/memory instructions)
| "codex" // Basic Codex
| "gemini"; // Basic Gemini
/**
* System prompt preset configuration.
*/
export interface SystemPromptPresetConfigSDK {
type: "preset";
preset: SystemPromptPreset;
append?: string;
}
/**
* System prompt configuration - either a raw string or preset config.
*/
export type SystemPromptConfig = string | SystemPromptPresetConfigSDK;
// ═══════════════════════════════════════════════════════════════
// MEMORY TYPES
// ═══════════════════════════════════════════════════════════════
/**
* Reference to an existing shared block by ID.
*/
export interface BlockReference {
blockId: string;
}
/**
* Memory item - can be a preset name, custom block, or block reference.
*/
export type MemoryItem =
| string // Preset name: "project", "persona", "human"
| CreateBlock // Custom block: { label, value, description? }
| BlockReference; // Shared block reference: { blockId }
/**
* Default memory block preset names.
*/
export type MemoryPreset = "persona" | "human" | "project";
// ═══════════════════════════════════════════════════════════════
// SESSION OPTIONS
// ═══════════════════════════════════════════════════════════════
/**
* Result of a canUseTool callback
*/
export interface PermissionResult {
allow: boolean;
reason?: string;
}
/**
* Callback for custom permission handling
*/
export type CanUseToolCallback = (
toolName: string,
toolInput: Record<string, unknown>,
) => Promise<PermissionResult> | PermissionResult;
/**
* Options for creating a session
*/
export interface SessionOptions {
/** Model to use (e.g., "claude-sonnet-4-20250514") */
model?: string;
/** Resume a specific conversation by ID (derives agent automatically) */
conversationId?: string;
/** Create a new conversation for concurrent sessions (requires agentId) */
newConversation?: boolean;
/** Resume the last session (agent + conversation from previous run) */
continue?: boolean;
/** Use agent's default conversation (requires agentId) */
defaultConversation?: boolean;
/**
* System prompt configuration.
* - string: Use as the complete system prompt
* - { type: 'preset', preset, append? }: Use a preset with optional appended text
*
* Available presets: 'default', 'letta-claude', 'letta-codex', 'letta-gemini',
* 'claude', 'codex', 'gemini'
*/
systemPrompt?: SystemPromptConfig;
/**
* Memory block configuration. Each item can be:
* - string: Preset block name ("project", "persona", "human")
* - CreateBlock: Custom block definition
* - { blockId: string }: Reference to existing shared block
*
* If not specified, defaults to ["persona", "human", "project"].
* Core blocks (skills, loaded_skills) are always included automatically.
*/
memory?: MemoryItem[];
/**
* Convenience: Set persona block value directly.
* Uses default block description/limit, just overrides the value.
* Error if persona not included in memory config.
*/
persona?: string;
/**
* Convenience: Set human block value directly.
*/
human?: string;
/**
* Convenience: Set project block value directly.
*/
project?: string;
/** List of allowed tool names */
allowedTools?: string[];
/** Permission mode */
permissionMode?: PermissionMode;
/** Working directory */
cwd?: string;
/** Maximum conversation turns */
maxTurns?: number;
/** Custom permission callback - called when tool needs approval */
canUseTool?: CanUseToolCallback;
}
export type PermissionMode = "default" | "acceptEdits" | "bypassPermissions";
// ═══════════════════════════════════════════════════════════════
// SDK MESSAGE TYPES
// ═══════════════════════════════════════════════════════════════
/**
* SDK message types - clean wrappers around wire types
*/
export interface SDKInitMessage {
type: "init";
agentId: string;
sessionId: string;
conversationId: string;
model: string;
tools: string[];
}
export interface SDKAssistantMessage {
type: "assistant";
content: string;
uuid: string;
}
export interface SDKToolCallMessage {
type: "tool_call";
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
uuid: string;
}
export interface SDKToolResultMessage {
type: "tool_result";
toolCallId: string;
content: string;
isError: boolean;
uuid: string;
}
export interface SDKReasoningMessage {
type: "reasoning";
content: string;
uuid: string;
}
export interface SDKResultMessage {
type: "result";
success: boolean;
result?: string;
error?: string;
durationMs: number;
totalCostUsd?: number;
conversationId: string | null;
}
export interface SDKStreamEventMessage {
type: "stream_event";
event: {
type: string; // "content_block_start" | "content_block_delta" | "content_block_stop"
index?: number;
delta?: { type?: string; text?: string; reasoning?: string };
content_block?: { type?: string; text?: string };
};
uuid: string;
}
/** Union of all SDK message types */
export type SDKMessage =
| SDKInitMessage
| SDKAssistantMessage
| SDKToolCallMessage
| SDKToolResultMessage
| SDKReasoningMessage
| SDKResultMessage
| SDKStreamEventMessage;

112
src/validation.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* SDK Validation
*
* Validates SessionOptions before spawning the CLI.
*/
import type { SessionOptions, MemoryItem, CreateBlock } from "./types.js";
/**
* Extract block labels from memory items.
*/
function getBlockLabels(memory: MemoryItem[]): string[] {
return memory
.map((item) => {
if (typeof item === "string") return item; // preset name
if ("label" in item) return (item as CreateBlock).label; // CreateBlock
return null; // blockId - no label to check
})
.filter((label): label is string => label !== null);
}
/**
* Validate SessionOptions before spawning CLI.
* Throws an error if validation fails.
*/
export function validateSessionOptions(options: SessionOptions): void {
// If memory is specified, validate that convenience props match included blocks
if (options.memory !== undefined) {
const blockLabels = getBlockLabels(options.memory);
if (options.persona !== undefined && !blockLabels.includes("persona")) {
throw new Error(
"Cannot set 'persona' value - block not included in 'memory'. " +
"Either add 'persona' to memory array or remove the persona option."
);
}
if (options.human !== undefined && !blockLabels.includes("human")) {
throw new Error(
"Cannot set 'human' value - block not included in 'memory'. " +
"Either add 'human' to memory array or remove the human option."
);
}
if (options.project !== undefined && !blockLabels.includes("project")) {
throw new Error(
"Cannot set 'project' value - block not included in 'memory'. " +
"Either add 'project' to memory array or remove the project option."
);
}
}
// Validate systemPrompt preset if provided
if (
options.systemPrompt !== undefined &&
typeof options.systemPrompt === "object"
) {
const validPresets = [
"default",
"letta-claude",
"letta-codex",
"letta-gemini",
"claude",
"codex",
"gemini",
];
if (!validPresets.includes(options.systemPrompt.preset)) {
throw new Error(
`Invalid system prompt preset '${options.systemPrompt.preset}'. ` +
`Valid presets: ${validPresets.join(", ")}`
);
}
}
// Validate conversation options
if (options.conversationId && options.newConversation) {
throw new Error(
"Cannot use both 'conversationId' and 'newConversation'. " +
"Use conversationId to resume a specific conversation, or newConversation to create a new one."
);
}
if (options.continue && options.conversationId) {
throw new Error(
"Cannot use both 'continue' and 'conversationId'. " +
"Use continue to resume the last session, or conversationId to resume a specific conversation."
);
}
if (options.continue && options.newConversation) {
throw new Error(
"Cannot use both 'continue' and 'newConversation'. " +
"Use continue to resume the last session, or newConversation to create a new one."
);
}
if (options.defaultConversation && options.conversationId) {
throw new Error(
"Cannot use both 'defaultConversation' and 'conversationId'. " +
"Use defaultConversation with agentId, or conversationId alone."
);
}
if (options.defaultConversation && options.newConversation) {
throw new Error(
"Cannot use both 'defaultConversation' and 'newConversation'."
);
}
// Note: Validations that require agentId context happen in transport.ts buildArgs()
// because agentId is passed separately to resumeSession(), not in SessionOptions
}

12
tsconfig.build.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts"]
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"allowJs": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src/**/*", "build.ts"]
}