# Record
**Author:** @cameron.stream (`did:plc:gfrmhdmjvxn2sjedzboeudef`)

## `knowledge-first-persistent-agent`
**Collection:** `site.standard.document`
**AT URI:** `at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.document/knowledge-first-persistent-agent`

**Title:** Your First Persistent Agent
**Published:** Wed, 12 Aug 2026 19:50:07 GMT
**Updated:** Wed, 12 Aug 2026 19:35:00 GMT
**Description:** 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.
**Publication:** `at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.publication/3mr4py6clps2f`
**Path:** /first-persistent-agent
**Tags:** knowledge, lesson, ai, agents, letta, agent-sdk, persistence, permissions, recovery

**Content:**
````json
{
  "text": "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.\n\nThis 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.\n\n## The three objects\n\nLetta separates the persistent object from the active connection:\n\n| Object | What it owns | What the application keeps |\n| --- | --- | --- |\n| Agent | Identity, memory, model configuration, tools, and message history | `agentId` |\n| Conversation | One message thread on that agent | `conversationId` |\n| Session | The current connection, client tools, approvals, and runtime options | Nothing after close |\n\n`createAgent()` also creates a default conversation. `resumeSession(agentId)` resumes that default thread. Passing a conversation ID to `resumeSession()` resumes a specific thread.\n\n## Build the application\n\nInstall the SDK and a TypeScript runner:\n\n```bash\nnpm install @letta-ai/letta-agent-sdk tsx\n```\n\nCreate a Letta API key, then set it in the environment:\n\n```bash\nexport LETTA_API_KEY='your-api-key-here'\n```\n\nSave the following application as `first-agent.ts`:\n\n```typescript\nimport { appendFile, readFile, writeFile } from \"node:fs/promises\";\nimport { stdin as input, stdout as output } from \"node:process\";\nimport { createInterface } from \"node:readline/promises\";\nimport {\n  type AnyAgentTool,\n  LettaAgentClient,\n} from \"@letta-ai/letta-agent-sdk\";\n\nconst statePath = \".first-agent.json\";\n\ntype SavedState = {\n  agentId: string;\n  conversationId?: string;\n};\n\nasync function loadState(): Promise<SavedState | undefined> {\n  try {\n    return JSON.parse(await readFile(statePath, \"utf8\")) as SavedState;\n  } catch (error) {\n    if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return undefined;\n    throw error;\n  }\n}\n\nasync function saveState(state: SavedState): Promise<void> {\n  await writeFile(statePath, `${JSON.stringify(state, null, 2)}\\n`);\n}\n\nconst appendNote = {\n  name: \"append_note\",\n  label: \"Append note\",\n  description: \"Append one approved note to the local agent-notes.log file.\",\n  parameters: {\n    type: \"object\",\n    properties: {\n      note: { type: \"string\" },\n    },\n    required: [\"note\"],\n  },\n  async execute(_toolCallId, rawInput) {\n    const { note } = rawInput as { note: string };\n    await appendFile(\"agent-notes.log\", `${note}\\n`);\n    return {\n      content: [{ type: \"text\" as const, text: \"Note appended.\" }],\n      details: { path: \"agent-notes.log\" },\n    };\n  },\n} satisfies AnyAgentTool;\n\nconst client = new LettaAgentClient({\n  backend: \"cloud\",\n  apiKey: process.env.LETTA_API_KEY,\n});\n\nconst previous = await loadState();\nconst agentId = previous?.agentId ?? await client.createAgent({\n  persona:\n    \"You are a project partner who remembers decisions and records concise notes when asked.\",\n  human:\n    \"The user wants short answers and explicit confirmation before any write.\",\n});\n\nawait saveState({ agentId, conversationId: previous?.conversationId });\n\nconst readline = createInterface({ input, output });\nconst sessionOptions = {\n  tools: [appendNote],\n  allowedTools: [\"append_note\"],\n  permissionMode: \"strict\" as const,\n  canUseTool: async (toolName: string, toolInput: unknown) => {\n    const answer = await readline.question(\n      `Allow ${toolName} with ${JSON.stringify(toolInput)}? [y/N] `,\n    );\n    return /^(y|yes)$/i.test(answer.trim())\n      ? { behavior: \"allow\" as const }\n      : { behavior: \"deny\" as const, message: \"User denied the write.\" };\n  },\n};\n\nawait using session = previous?.conversationId\n  ? client.resumeSession(previous.conversationId, sessionOptions)\n  : client.resumeSession(agentId, sessionOptions);\n\nconst prompt = process.argv.slice(2).join(\" \") ||\n  \"Remember that project briefs should lead with blockers. Save this as a note.\";\n\ntry {\n  await session.send(prompt);\n\n  for await (const event of session.stream()) {\n    if (event.type === \"init\") {\n      await saveState({ agentId, conversationId: event.conversationId });\n    }\n    if (event.type === \"assistant\") process.stdout.write(event.content);\n    if (event.type === \"result\" && !event.success) {\n      throw new Error(event.errorDetail ?? event.errorCode ?? \"Turn failed\");\n    }\n  }\n\n  process.stdout.write(\"\\n\");\n} finally {\n  readline.close();\n}\n```\n\nRun it twice:\n\n```bash\nnpx tsx first-agent.ts\nnpx tsx first-agent.ts \"What did I ask you to remember?\"\n```\n\nThe 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.\n\nThe `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.\n\n## Test recovery instead of assuming it\n\nInterrupt the first process while a turn is streaming, then run the application again. The new process should resume from the saved conversation ID.\n\nDo 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:\n\n```typescript\nawait using recovered = client.resumeSession(conversationId, sessionOptions);\nconst history = await recovered.listMessages({ order: \"desc\", limit: 20 });\nconsole.dir(history.messages, { depth: 4 });\n```\n\nIf 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()`.\n\n## What this example establishes\n\nThis application establishes a small set of useful facts:\n\n- the agent is created once;\n- the same conversation continues across process restarts;\n- client tools and approval policy are reattached on each session;\n- one write is visible in an external file;\n- an interrupted send is inspected before retry.\n\nIt 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](https://cameron.stream/knowledge/agent-authority-and-effects) covers that boundary, while [Choosing an Agent Topology](https://cameron.stream/knowledge/choosing-an-agent-topology) explains when one agent should serve one user, several threads, or a team.\n\n## Sources\n\n- [Letta Agent SDK quickstart](<https://docs.letta.com/agent-sdk/quickstart/index.md>)\n- [Creating agents](<https://docs.letta.com/agent-sdk/agents/index.md>)\n- [Letta Agent SDK session lifecycle](<https://docs.letta.com/agent-sdk/sessions/index.md>)\n- [MCP and client tools](<https://docs.letta.com/agent-sdk/mcp/index.md>)\n- [Permissions](<https://docs.letta.com/agent-sdk/permissions/index.md>)",
  "$type": "site.standard.content.markdown",
  "version": "1.0"
}
````

---
*Fetched from https://enoki.us-east.host.bsky.network via `com.atproto.repo.getRecord`*