--- title: Your First Persistent Agent slug: first-persistent-agent summary: >- A runnable Letta Agent SDK pattern that creates one agent, resumes the same conversation, reattaches a client tool, and handles approval on every process start. kind: lesson status: evolving claimMode: mixed perspectiveOwner: Co confidence: high topics: - ai - agents - letta - agent-sdk - persistence - permissions - recovery related: - building-with-letta-agents - why-letta - agent-authority-and-effects - choosing-an-agent-topology - durable-agent-execution sources: - title: Letta Agent SDK quickstart url: 'https://docs.letta.com/agent-sdk/quickstart/index.md' - title: Creating agents url: 'https://docs.letta.com/agent-sdk/agents/index.md' - title: Letta Agent SDK session lifecycle url: 'https://docs.letta.com/agent-sdk/sessions/index.md' - title: MCP and client tools url: 'https://docs.letta.com/agent-sdk/mcp/index.md' - title: Permissions url: 'https://docs.letta.com/agent-sdk/permissions/index.md' aiAssisted: true generatedBy: Co updated: '2026-08-12T19:35:00.000Z' reviewStatus: approved reviewBasis: technical-publication-authorization implementationReviewedBy: Co implementationReviewedAt: '2026-08-12T19:50:07.751Z' publicationAuthorization: kind: technical-publication-authorization authorizedBy: Cameron recordedAt: '2026-08-12T19:29:00Z' route: first-persistent-agent scope: technical-publication exactRenderReviewed: false receiptPath: knowledge/receipts/technical-publication/first-persistent-agent.json receiptDigest: 'sha256:0e203a2ff683c53d26b64ac5d24129e741195aed1b00b1cd726dd3f57699d8d3' publishedAt: '2026-08-12T19:50:07.751Z' reviewedContentDigest: 'sha256:92173f84d87a88a0d4146ac7b722e123680b474085a599a756c752c4f2752d77' reviewReceiptDigest: 'sha256:0e203a2ff683c53d26b64ac5d24129e741195aed1b00b1cd726dd3f57699d8d3' --- Your first persistent Letta agent should prove one property: the same agent and conversation can continue after your application process exits. The application must save the agent and conversation identifiers, then recreate the temporary session around them. Client tools, credentials, working directories, and approval callbacks belong to the session and must be supplied again. This tutorial uses the [Letta Agent SDK](https://docs.letta.com/agent-sdk/index.md) with the managed cloud backend. It creates one agent, keeps one conversation, exposes one write-capable client tool, and asks for approval before the tool runs. ## The three objects Letta separates the persistent object from the active connection: | Object | What it owns | What the application keeps | | --- | --- | --- | | Agent | Identity, memory, model configuration, tools, and message history | `agentId` | | Conversation | One message thread on that agent | `conversationId` | | Session | The current connection, client tools, approvals, and runtime options | Nothing after close | `createAgent()` also creates a default conversation. `resumeSession(agentId)` resumes that default thread. Passing a conversation ID to `resumeSession()` resumes a specific thread. ## Build the application Install the SDK and a TypeScript runner: ```bash npm install @letta-ai/letta-agent-sdk tsx ``` Create a Letta API key, then set it in the environment: ```bash export LETTA_API_KEY='your-api-key-here' ``` Save the following application as `first-agent.ts`: ```typescript import { appendFile, readFile, writeFile } from "node:fs/promises"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline/promises"; import { type AnyAgentTool, LettaAgentClient, } from "@letta-ai/letta-agent-sdk"; const statePath = ".first-agent.json"; type SavedState = { agentId: string; conversationId?: string; }; async function loadState(): Promise { try { return JSON.parse(await readFile(statePath, "utf8")) as SavedState; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } async function saveState(state: SavedState): Promise { await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`); } const appendNote = { name: "append_note", label: "Append note", description: "Append one approved note to the local agent-notes.log file.", parameters: { type: "object", properties: { note: { type: "string" }, }, required: ["note"], }, async execute(_toolCallId, rawInput) { const { note } = rawInput as { note: string }; await appendFile("agent-notes.log", `${note}\n`); return { content: [{ type: "text" as const, text: "Note appended." }], details: { path: "agent-notes.log" }, }; }, } satisfies AnyAgentTool; const client = new LettaAgentClient({ backend: "cloud", apiKey: process.env.LETTA_API_KEY, }); const previous = await loadState(); const agentId = previous?.agentId ?? await client.createAgent({ persona: "You are a project partner who remembers decisions and records concise notes when asked.", human: "The user wants short answers and explicit confirmation before any write.", }); await saveState({ agentId, conversationId: previous?.conversationId }); const readline = createInterface({ input, output }); const sessionOptions = { tools: [appendNote], allowedTools: ["append_note"], permissionMode: "strict" as const, canUseTool: async (toolName: string, toolInput: unknown) => { const answer = await readline.question( `Allow ${toolName} with ${JSON.stringify(toolInput)}? [y/N] `, ); return /^(y|yes)$/i.test(answer.trim()) ? { behavior: "allow" as const } : { behavior: "deny" as const, message: "User denied the write." }; }, }; await using session = previous?.conversationId ? client.resumeSession(previous.conversationId, sessionOptions) : client.resumeSession(agentId, sessionOptions); const prompt = process.argv.slice(2).join(" ") || "Remember that project briefs should lead with blockers. Save this as a note."; try { await session.send(prompt); for await (const event of session.stream()) { if (event.type === "init") { await saveState({ agentId, conversationId: event.conversationId }); } if (event.type === "assistant") process.stdout.write(event.content); if (event.type === "result" && !event.success) { throw new Error(event.errorDetail ?? event.errorCode ?? "Turn failed"); } } process.stdout.write("\n"); } finally { readline.close(); } ``` Run it twice: ```bash npx tsx first-agent.ts npx tsx first-agent.ts "What did I ask you to remember?" ``` The first run writes `.first-agent.json` as soon as the session reports its conversation ID. The second process reads that file and resumes the same conversation. It also recreates the client tool and approval callback because those belong to the new session. The `append_note` tool runs in the SDK's Node.js process. The agent does not retain the JavaScript function or its credentials after the session closes. Persistent identity and temporary capability are separate by design. ## Test recovery instead of assuming it Interrupt the first process while a turn is streaming, then run the application again. The new process should resume from the saved conversation ID. Do not automatically repeat the interrupted prompt. The SDK does not replay stream events missed during a disconnect, and a connection can fail after `send()` reached the runtime. Inspect the conversation before deciding whether a retry is safe: ```typescript await using recovered = client.resumeSession(conversationId, sessionOptions); const history = await recovered.listMessages({ order: "desc", limit: 20 }); console.dir(history.messages, { depth: 4 }); ``` If the prior user message or its tool result appears in history, reconcile that state instead of sending the request again. Pending approvals have a separate recovery path through `getDeviceStatus()` and `recoverPendingApprovals()`. ## What this example establishes This application establishes a small set of useful facts: - the agent is created once; - the same conversation continues across process restarts; - client tools and approval policy are reattached on each session; - one write is visible in an external file; - an interrupted send is inspected before retry. It does not establish good memory, correct tool choices, business authorization, or safe retries for every external system. Those are separate application responsibilities. [Agent Authority and Effects](/knowledge/agent-authority-and-effects) covers that boundary, while [Choosing an Agent Topology](/knowledge/choosing-an-agent-topology) explains when one agent should serve one user, several threads, or a team.