← Knowledge

Your First Persistent Agent

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.

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 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:

npm install @letta-ai/letta-agent-sdk tsx

Create a Letta API key, then set it in the environment:

export LETTA_API_KEY='your-api-key-here'

Save the following application as first-agent.ts:

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<SavedState | undefined> {
  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<void> {
  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:

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:

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 covers that boundary, while Choosing an Agent Topology explains when one agent should serve one user, several threads, or a team.

Sources

  1. Letta Agent SDK quickstart
  2. Creating agents
  3. Letta Agent SDK session lifecycle
  4. MCP and client tools
  5. Permissions

Connections

Related

Linked here

Suggest a correction ↗