diff --git a/src/skills/builtin/automating-your-work/SKILL.md b/src/skills/builtin/automating-your-work/SKILL.md new file mode 100644 index 0000000000..3daeb9064c --- /dev/null +++ b/src/skills/builtin/automating-your-work/SKILL.md @@ -0,0 +1,139 @@ +--- +name: automating-your-work +description: Guides you in automating repeated work with the smallest appropriate form, from one-off scripts and reusable skills to scheduled or event-driven Agent SDK programs. +--- + +# Automating Your Work + +You can use the Letta Agent SDK to automate parts of your own work. A program can call you in another conversation, resume work later, run your tools on a selected computer, and return the result to your user. The same program can also ask other agents to help with separate parts of the work. + +## Choose the work before the machinery + +Do not begin with the examples in this skill. They illustrate mechanics, not what you should build. Choose from work that has actually repeated in your own recent history or that your user has explicitly asked to automate. + +Before designing anything: + +1. Identify the concrete repeated work and the evidence that it repeats. +2. Check whether an existing primitive already owns it. Use a background command for one completion, `Monitor` for a stream of matching events, a schedule plus an existing skill for a recurring turn, and ordinary code for deterministic transforms. +3. Name what requires an agent: judgment, persistent context, delegated work, model choice, or execution on another computer. If none of these is necessary, do not add the Agent SDK. +4. Consider several candidates from the agent's actual work before selecting one. Do not select a worked example from this document merely because it is available in context. + +Choose the smallest form that closes the loop. A native tool or short script is a successful automation decision when it makes a larger agent program unnecessary. + +## Ways to automate yourself + +An automation can take many forms: + +- A one-off helper that runs when you or your user asks for it. +- A reusable command that handles a familiar task. +- A scheduled program that checks something at an interval. +- An event-driven program that sends GitHub, Linear, file, or product events to you. +- A service that keeps conversations active across a longer process. +- A skill with instructions and scripts that you call during normal work. + +These forms can use the same agent and the same code. A one-off helper can later run from a schedule or event source without changing the agent that does the work. + +## Instructions, code, and agents + +The parts of an automation can be split in different ways: + +- **Instructions** can describe judgment, such as deciding whether documentation drift changes the meaning of a guide or whether an anomaly deserves attention. +- **Code** can handle fixed work, such as collecting type signatures, parsing events, tracking progress, or formatting results. +- **An agent turn** can interpret new information, use tools, and decide what to do next. + +For example, an API-drift check can use a script to collect exported type changes. It can then ask you whether those changes invalidate any maintained recipes, using a saved conversation to preserve earlier release decisions. + +## Use your own agent + +Your agent ID gives an Agent SDK program access to your persistent memory and identity. The program can use: + +- Your default conversation. +- A new conversation for one isolated task. +- A saved conversation that continues across several runs. +- A conversation for each long-lived resource, such as a customer, dataset, deployment, or maintained guide. + +This lets an automation reuse what you already know. The program can send fresh evidence with each turn and keep the conversation ID when it wants to continue the same thread later. + +A non-default conversation can select a different model when the program creates or resumes its session. This lets routine automation use a lower-cost model while the agent keeps the same memory and identity. Another conversation can use a different model for work that benefits from it. + +The [Agent SDK recipes](references/sdk-recipes.md) show TypeScript examples for calling an existing agent, saving conversation IDs, and reporting results back to a main conversation. + +## Use other agents + +Other agents can help when a task benefits from separate context, another model, parallel work, or an independent opinion. An automation can use: + +- **Another conversation on your agent:** the worker shares your memory and identity but has a separate thread. +- **A temporary worker agent:** the worker receives one task, uses a selected model and toolset, returns a result, and can then be deleted. +- **A persistent specialist agent:** the worker keeps its own memory and role across repeated tasks. + +A TypeScript program can hold the loop, branching, concurrency, and intermediate results. Worker agents can read, search, edit, or review within that program. + +The dynamic workflow examples in [letta-agent-sdk#261](https://github.com/letta-ai/letta-agent-sdk/pull/261) show several patterns: + +- Audit files in parallel, then ask other workers to verify each finding. +- Run a check, ask workers to fix separate failures, and run the check again. +- Ask workers on different models for plans, then ask another agent to judge and combine them. +- Search from several angles and ask other workers to verify the claims. +- Give workers separate cloud sandboxes and collect their reviewed patches. + +These examples also show per-worker models, tool lists, permissions, structured output, concurrency limits, and cleanup. + +## Agent SDK options + +An automation can use the following Agent SDK features: + +- Persistent agents and conversations. +- Managed cloud sandboxes. +- Connected computers and local execution. +- Streaming reasoning, tool calls, tool results, and final responses. +- Client-side and server-side tools. +- Tool approval and permission callbacks. +- Conversation-level model selection. +- Different models for different workers. +- Structured results for script-controlled workflows. + +## Choose where tools run + +A [computer](https://docs.letta.com/platform/computers) is the environment where an agent runs commands, accesses files, and uses local tools. The agent's identity, memory, and conversations are separate from the computer. Moving the agent does not copy project files, software, or credentials. The agent can use what exists on the selected computer. + +An Agent SDK automation can choose among: + +- **Managed cloud sandbox:** use `backend: "cloud"` without a `computer`. Letta creates an isolated environment for the session. Provisioning adds cold-start time, which can make short tasks feel slow. Sandboxes fit automations that need isolation, clean environments, or many concurrent workers. Letta expects their startup time and usability to improve. +- **Computer in your Letta organization:** use `backend: "cloud"` with a `computer` selector. The computer can be an online laptop, workstation, VM, or other connected machine. This option gives the agent access to a specific filesystem, credential, dependency, or long-running machine. A `deviceId` or computer `id` is stable across reconnects. A `connectionId` represents one live connection. +- **Current computer:** use `backend: "local"` to keep agent state and tool execution on the machine running the program. This is useful for direct access to local files and tools or for a fully local deployment. + +The program can discover online organization computers with `client.computers.list({ onlineOnly: true })`. A Cloud session can use a selected `computer` or a managed `sandbox`, but not both. A separately operated App Server is available through `backend: "remote"`. + +One program can open separate conversations on several organization computers. This can support fleets of CI machines, infrastructure nodes, platform-specific test machines, or other connected computers. + +## Questions that can help + +The following questions can help describe the automation: + +- What part of your work would the program handle? +- What starts it: a request, command, schedule, or event? +- Does it call you, another conversation on you, or another agent? +- Which information needs to continue across runs? +- Which model and tools fit each part of the work? +- Where will the tools run? +- How will the result return to you or your user? + +## State options + +An automation can use several types of state: + +- **Agent memory** for knowledge that remains useful across conversations. +- **Conversation history** for decisions and context in one thread of work. +- **Files or a database** for event cursors, queues, timestamps, retry counts, and records of external actions. + +## Execution and authority options + +The surrounding program can run from a command, scheduled task, server, or another application. Its tools run on the computer selected for each session. + +The automation can read information, draft an action, ask for approval, or perform actions that the user has authorized. Session options can give each worker its own model, tool list, permission mode, working directory, and sandbox. + +## Storage and operations + +A skill can keep the instructions and source files together. Common directories include `scripts/`, `src/`, `tests/`, `fixtures/`, and `templates/`. Runtime state and credentials can live in storage selected for the automation. + +Repeated or deployed automations can also use run history, idempotency records, cost and concurrency limits, ownership information, health checks, and stop commands. [Operations options](references/operations.md) describes these pieces. diff --git a/src/skills/builtin/automating-your-work/references/operations.md b/src/skills/builtin/automating-your-work/references/operations.md new file mode 100644 index 0000000000..1c484dc589 --- /dev/null +++ b/src/skills/builtin/automating-your-work/references/operations.md @@ -0,0 +1,89 @@ +# Operations options for automations + +An automation can include operational features when it runs repeatedly, handles events, or performs external actions. This document lists common options. A one-off, read-only program may need only a few of them. + +## Event handling + +- **Event envelope:** an object with the event ID, resource ID, source time, idempotency key, and a link to the original data. +- **Cursor:** a stored position that lets a polling program continue from its last event. +- **Reconciliation pass:** a periodic query that finds events missed by a webhook or stream. +- **Resource lock or queue:** a way to prevent two turns from changing the same resource at the same time. +- **Author filter:** a way to ignore events created by the automation itself. + +These options become more useful as event volume and external effects increase. + +## External actions + +An effects record can connect an event to the action, Agent SDK run IDs, and result. This record helps the program determine whether it already performed an action. + +A timeout can leave the result of an external action unknown. The program can query the external system before it sends the action again. For example, it can check whether a comment exists or whether a ticket was created. + +A `--dry-run` option can show matched events, planned turns, and planned actions without performing them. The same mode can run against real events during testing. + +## Limits and delegated work + +An automation can track limits such as: + +- Agent turns per hour. +- Notifications per person. +- Model cost. +- Number of delegated conversations. +- Delegation depth. + +Lineage fields such as a root ID, parent ID, and depth can connect delegated turns. They also make it easier to stop or inspect a group of related turns. + +## Automation manifest + +A manifest provides one place to inspect an automation. Possible fields include: + +- Name and purpose. +- Owner and source version. +- Trigger and execution location. +- Agent and conversation IDs. +- Credentials and allowed actions. +- State location. +- Cost and activity limits. +- Last event and last action. +- Dry-run, pause, stop, and remove commands. +- Review or expiration date. + +For example: + +```markdown +# pr-shepherd +purpose: judge PR staleness/risk for letta-code; escalate what needs humans +owner: cameron +source: github.com/…/automations@a1b2c3 sdk: @letta-ai/letta-agent-sdk@0.6.3 +trigger: poll GitHub every 30m (cron on ops-host) +agent: agent-xxx conversations: per-repo (map in automation-state.sqlite) +authority: read GitHub; draft comments; post reviewer nudges matching routing map +credentials: GitHub token (repo:read, PR:write) in ops-host keychain +budgets: ≤20 turns/hr, ≤3 nudges/person/day, ≤$2/day +state: /opt/automations/pr-shepherd/automation-state.sqlite +health: last event 2026-08-11T14:02Z; last effect run-abc123 +dry-run: bun run sweep.ts --dry-run +stop: disable the pr-shepherd cron entry +review-by: 2026-09-15 +``` + +## Inventory + +A shared inventory of manifests can show which automations watch the same resource or send messages to the same destination. It can also provide commands such as: + +```text +automations list +automations inspect +automations history +automations run --dry-run +automations pause +automations stop +automations remove +``` + +The Agent SDK does not provide this inventory. An application can build one from its own manifests and run history. + +## Review and removal + +An automation may need review when its trigger changes, its credentials expire, its assumptions stop matching the product, or its cost exceeds its value. A review can result in a code change, a different execution form, a pause, or removal. + +Removal can preserve the source and run history while it disables the schedule, process, and credentials. This keeps prior decisions available without leaving the automation active. diff --git a/src/skills/builtin/automating-your-work/references/sdk-recipes.md b/src/skills/builtin/automating-your-work/references/sdk-recipes.md new file mode 100644 index 0000000000..c7af86075c --- /dev/null +++ b/src/skills/builtin/automating-your-work/references/sdk-recipes.md @@ -0,0 +1,373 @@ +# Agent SDK recipes for automations + +These examples show ways to use `@letta-ai/letta-agent-sdk` from TypeScript. The package is 0.x, so check the installed `node_modules/@letta-ai/letta-agent-sdk/dist/*.d.ts` types when an API is version-sensitive. Docs: https://docs.letta.com/agent-sdk + +```bash +bun init -y && bun add @letta-ai/letta-agent-sdk # pin the exact version in package.json +``` + +## Choose the execution computer + +With the cloud backend, agent state lives in Letta Cloud. If no `computer` is selected, the SDK creates a managed cloud sandbox where the agent runs its tools. + +```ts +import { + LettaAgentClient, + type LettaCodeCloudSandboxOptions, +} from "@letta-ai/letta-agent-sdk"; + +const client = new LettaAgentClient({ + backend: "cloud", + apiKey: process.env.LETTA_API_KEY, +}); +``` + +The SDK offers the following execution options: + +- `backend: "cloud"` — a managed cloud sandbox runs tools for the session. +- `backend: "cloud"` with `computer: { name: "work-laptop" }` — a connected computer runs the tools. Stable selectors include `deviceId` and computer `id`. A `connectionId` identifies one live connection. +- `backend: "local"` — agent state and tools stay on the current machine. The SDK owns the App Server subprocess. +- `backend: "remote"` — tools run on a separately operated App Server computer; the App Server backend determines where agent state lives. +- `computer` and `sandbox` are mutually exclusive. + +Managed sandboxes provide clean isolation and support concurrent workers. Provisioning can add cold-start latency. A connected organization computer avoids sandbox provisioning and gives the agent access to its files, tools, and credentials. + +The SDK can list online computers before it selects one: + +```ts +const { computers } = await client.computers.list({ onlineOnly: true }); +for (const computer of computers) { + console.log(computer.name, computer.deviceId, computer.status); +} + +const computer = computers[0]; +if (!computer) throw new Error("No online computers are available"); + +await using session = client.resumeSession(AGENT_ID, { + computer: { deviceId: computer.deviceId }, + cwd: "/workspace/project", +}); +``` + +The same API can route separate conversations to a fleet of connected computers. Examples include CI machines, infrastructure nodes, and platform-specific test machines. A larger fleet can use the bounded-concurrency pattern later in this guide. + +Sandbox files last until the sandbox expires. Agent memory, conversation history, or application storage can hold state that must outlive the sandbox. A sandbox `cwd` refers to a path inside the sandbox; it does not mount a local path. Changing computers never copies files or credentials between them. See the [Computers documentation](https://docs.letta.com/platform/computers) and [Agent SDK deployment guide](https://docs.letta.com/agent-sdk/deployment). + +## Call yourself for a one-off task + +This example starts a new conversation on your existing agent, streams one turn, records the result, and exits. `AUTOMATION_AGENT_ID` can contain your agent ID. + +```ts +// release-audit.ts — invoked by a person or another program. +const AGENT_ID = process.env.AUTOMATION_AGENT_ID!; // agent-xxx + +await using session = client.createSession(AGENT_ID); // new conversation for this run + +await session.send( + [ + "Audit the latest release for doc drift.", + `Release notes:\n${releaseNotes}`, + "Report: contradictions with current docs, deprecated references, required patches.", + ].join("\n\n"), +); + +for await (const event of session.stream()) { + if (event.type === "assistant") process.stdout.write(event.content); // incremental chunks + if (event.type === "result") { + // event.result = full final text; event.success, event.stopReason, event.runIds + await writeReceipt({ runIds: event.runIds, ok: event.success }); + } +} +// `await using` disposes the session; the conversation and its history persist on the agent. +``` + +Turn anatomy: one `send()` + one pass through `stream()`; the stream terminates after the turn's `result` event. `abort()` stops a turn without closing the session; `close()`/`await using` releases session-scoped resources (client tools, MCP connections, cwd/env). A session whose connection died cannot be reused — `resumeSession(conversationId)` and continue. + +## Select a model for an automation conversation + +`createSession()` and `resumeSession()` accept a `model` option. A non-default conversation can use a lower-cost model while it keeps the same agent memory and identity. + +```ts +const AUTOMATION_MODEL = process.env.AUTOMATION_MODEL!; + +await using session = client.createSession(AGENT_ID, { + model: AUTOMATION_MODEL, +}); +``` + +The program can also apply the model when it continues a saved conversation: + +```ts +await using session = client.resumeSession(CONVERSATION_ID, { + model: AUTOMATION_MODEL, +}); +``` + +This also allows different automation conversations to use different models. For example, collection and formatting conversations can use a lower-cost model, while a review conversation can use another model. + +`resumeSession(agentId, { model })` resumes the agent's default conversation, so the model update applies to the agent. `createSession(agentId, { model })` creates a non-default conversation, and `resumeSession(conversationId, { model })` updates that conversation. + +## Ask temporary worker agents for help + +A temporary worker can use a separate context, model, and toolset for one part of your automation. This example creates a hidden worker without its own memory filesystem, runs one task, and deletes the worker. + +```ts +const READ_TOOLS = ["Read", "Grep", "Glob", "LS"]; + +async function askWorker( + task: string, + context: { sandbox?: LettaCodeCloudSandboxOptions; cwd?: string } = {}, +): Promise { + const workerId = await client.createAgent({ + model: process.env.WORKER_MODEL ?? "haiku", + hidden: true, + memfs: false, + baseTools: [], + }); + + try { + const result = await client.prompt(task, workerId, { + toolset: { base: "none", include: READ_TOOLS }, + allowedTools: READ_TOOLS, + permissionMode: "strict", + canUseTool: async () => ({ behavior: "allow" }), + sandbox: context.sandbox, + cwd: context.cwd, + }); + return result.result; + } finally { + await client.agents.delete(workerId); + } +} +``` + +`toolset` selects the bundled client tools to load. `allowedTools` filters the session to the listed tools. `permissionMode: "strict"` routes each offered call through `canUseTool`; this callback allows the read-only list above. + +### Give each worker a cloud sandbox + +The cloud client creates a managed sandbox for each worker session. `githubRepositories` clones a repository into `/root/workspace`, and `cwd` selects the cloned repository: + +```ts +const repository = { owner: "letta-ai", repo: "letta-code" }; +const workerContext = { + sandbox: { + githubRepositories: [repository], + terminateOnClose: true, + }, + cwd: `/root/workspace/${repository.repo}`, +}; +``` + +Private repositories use the GitHub integration for the organization. `terminateOnClose` requests sandbox cleanup when the worker session closes. + +### Run workers with a concurrency limit + +The script can hold intermediate results and limit how many workers run at once: + +```ts +async function mapWithConcurrency( + items: T[], + limit: number, + run: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let next = 0; + + async function takeNext(): Promise { + while (next < items.length) { + const index = next++; + results[index] = await run(items[index]); + } + } + + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, takeNext), + ); + return results; +} + +const reports = await mapWithConcurrency(files, 4, (file) => + askWorker( + `Review ${file} and return only concrete findings.`, + workerContext, + ), +); +``` + +The result from one worker can become the input to another worker. For example, a review stage can try to refute each report: + +```ts +const reviewed = await mapWithConcurrency( + reports.filter((report): report is string => Boolean(report)), + 4, + (report) => + askWorker( + `Try to refute this finding. Return the finding only if it survives review:\n\n${report}`, + workerContext, + ), +); +``` + +The script can also compare plans from different models or collect patches from separate cloud sandboxes. [letta-agent-sdk#261](https://github.com/letta-ai/letta-agent-sdk/pull/261) contains complete examples for audits, fix loops, planning panels, research, and file migrations. + +## Dedicated automation agent + +An automation can use your existing agent, temporary workers, or a dedicated agent. A dedicated agent keeps its own memory and identity across repeated work. + +```ts +const agentId = await client.createAgent({ + name: "pr-shepherd", + persona: + "You review pull-request state for one repository. You judge staleness, risk, and what deserves human attention. You report conclusions, not raw data.", +}); +// The application can store agentId and resume this agent later. +``` + +## One conversation per resource + +Conversations are addressable state: `createSession(agentId)` opens a new conversation, and `resumeSession("conv-xxx")` continues it. This example stores the resource-to-conversation map in SQLite. + +```ts +import { Database } from "bun:sqlite"; +const db = new Database("automation-state.sqlite"); +db.run(`CREATE TABLE IF NOT EXISTS resources ( + key TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, last_event_id TEXT +)`); + +async function sessionFor(resourceKey: string) { + const row = db + .query<{ conversation_id: string }, [string]>( + "SELECT conversation_id FROM resources WHERE key = ?", + ) + .get(resourceKey); + if (row) return client.resumeSession(row.conversation_id); + + const session = client.createSession(AGENT_ID); + await session.send(`You now own ${resourceKey}. Acknowledge.`); + for await (const e of session.stream()) if (e.type === "result") break; + db.run("INSERT INTO resources (key, conversation_id) VALUES (?, ?)", [ + resourceKey, + session.conversationId!, // resolved after the backend assigns it + ]); + return session; +} +``` + +Conversation granularity can follow the reasoning context. For example, one conversation per repository can compare related file changes. One conversation per pull request can keep a long review history. + +## Scheduled program + +The same program can run from `letta cron`, an operating-system scheduler, or another scheduling service. For example: + +``` +*/30 * * * * cd /opt/automations/pr-shepherd && bun run sweep.ts >> sweep.log 2>&1 +``` + +A lock file can prevent two scheduled runs from using the same state at the same time. + +## Event-driven program + +This example accepts an event, persists the agent's decision, and then delivers any escalation. Decision completion and outbound delivery are separate states: recording a decision must not cause a failed delivery to be skipped on retry. The dispatcher must serialize calls for each event ID with a database-backed lock or resource queue so two workers cannot deliver the same event concurrently. + +```ts +db.run(`CREATE TABLE IF NOT EXISTS effects ( + event_id TEXT PRIMARY KEY, + decision TEXT NOT NULL, + run_ids TEXT NOT NULL, + decided_at INTEGER NOT NULL, + delivered_at INTEGER +)`); + +// One iteration of a poll loop or one webhook delivery. +async function handleEvent(evt: { id: string; resource: string; payload: string }) { + let effect = db + .query< + { decision: string; delivered_at: number | null }, + [string] + >("SELECT decision, delivered_at FROM effects WHERE event_id = ?") + .get(evt.id); + + if (!effect) { + await using session = await sessionFor(evt.resource); + let assistantText = ""; + await session.send( + [ + `Event ${evt.id} on ${evt.resource}:`, + evt.payload, + "Decide: no action, or a one-line escalation with reason.", + ].join("\n"), + ); + + for await (const e of session.stream()) { + if (e.type === "assistant") assistantText += e.content; + if (e.type === "result") { + if (!e.success) throw new Error(`Decision failed for ${evt.id}`); + const decision = (e.result ?? assistantText).trim(); + if (!decision) throw new Error(`Decision was empty for ${evt.id}`); + db.run( + "INSERT INTO effects (event_id, decision, run_ids, decided_at) VALUES (?, ?, ?, ?)", + [evt.id, decision, JSON.stringify(e.runIds ?? []), Date.now()], + ); + effect = { decision, delivered_at: null }; + } + } + } + + if (!effect || effect.delivered_at) return; + if (effect.decision.startsWith("ESCALATE:")) { + await reportToCoordinator(evt.id, effect.decision); + } + db.run("UPDATE effects SET delivered_at = ? WHERE event_id = ?", [ + Date.now(), + evt.id, + ]); +} +``` + +A worker conversation can report a conclusion to a coordinator conversation. The event marker makes an unknown-delivery retry reconcilable: before sending, scan the durable conversation history for the same marker. + +```ts +function containsMarker(message: { message_type?: string; content?: unknown }, marker: string) { + return ( + message.message_type === "user_message" && + typeof message.content === "string" && + message.content.includes(marker) + ); +} + +async function reportToCoordinator(eventId: string, packet: string) { + await using main = client.resumeSession(MAIN_CONVERSATION_ID); + const marker = `[pr-shepherd:event:${eventId}]`; + let before: string | undefined; + + do { + const page = await main.listMessages({ + conversationId: MAIN_CONVERSATION_ID, + order: "desc", + limit: 100, + before, + }); + if (page.messages.some((message) => containsMarker(message, marker))) return; + if (page.hasMore === false || !page.nextBefore) break; + before = page.nextBefore; + } while (true); + + await main.send(`${marker} ${packet}`); + for await (const e of main.stream()) if (e.type === "result") break; +} +``` + +If the process stops after the coordinator accepts the message but before `delivered_at` is updated, the next attempt finds the marker and finishes the local record without sending a duplicate. For an external destination, use its native idempotency key or query it by a stable action ID before retrying. + +[Operations options](operations.md) describes event envelopes, cursors, reconciliation, action records, limits, and manifests for repeated programs. + +## Connection and retry behavior + +- `CloudManagedSandboxExpiredError` occurs before `send()` transmits the message. The program can resume the same conversation in a new session and retry once. +- A connection failure after `send()` succeeds has an unknown delivery state. `client.conversations.listMessages(...)` or `bootstrapState()` can show whether the message reached the conversation before the program retries it. +- The SDK does not replay stream events missed during a disconnect. Conversation history provides the durable record after the program resumes. +- The `result` event includes run IDs that can connect stream events, history, and application records. + +## Approval options + +An Agent SDK session can ask for interactive approval, approve selected tools through `canUseTool`, or deny an action and report it to the application. If a session closes with an approval pending, a new session can inspect it with `recoverPendingApprovals()`. See https://docs.letta.com/agent-sdk/permissions