flows #
flows is an experimental TypeScript DSL for orchestrating agents built with the Letta Agent SDK.
The project is a place to discover what agent workflows should feel like before proposing that any of its ideas become part of the SDK. The API is intentionally allowed to change as we learn from real workflows.
Setup #
Use Bun 1.4.2 (the verified toolchain for the checked-in version 2 lockfile). Bun 1.3.8 cannot read this lockfile. Upgrade Bun or invoke a project-local 1.4.2 binary; do not regenerate the lockfile just to bypass that error.
bun install --frozen-lockfile
bun run check
bun test
The verification toolchain is Bun 1.4.2 and Agent SDK 0.8.12 on Linux. Tests use mocked agent clients; they do not establish live provider behavior. See examples for authenticated workflows. Those examples create conversations and consume inference; the test suite does not.
Motivation #
The Agent SDK already provides the essential runtime primitives: persistent agents, conversations, tools, permissions, local and cloud execution, streaming, and managed sandboxes. Applications can combine those primitives using ordinary TypeScript.
But every multi-agent application soon rebuilds another layer:
- creating and cleaning up temporary workers;
- limiting concurrency;
- mapping work across many agents;
- passing results between stages;
- validating structured output;
- handling partial failures and retries;
- reporting progress, cost, and intermediate results;
- deciding which agents should be ephemeral and which should remember.
The Agent SDK workflow examples already hint at a small vocabulary—agents, parallel work, pipelines, and phases. flows asks how far that vocabulary can go while remaining simple, safe, and recognizably TypeScript.
This is not an attempt to design the final workflow API in advance. It is an experimental layer where we can build useful things, notice repeated patterns, discard bad abstractions, and eventually contribute only the pieces that have earned their place.
Thesis #
A Letta workflow is ordinary TypeScript running inside a managed execution scope, where agent calls are typed tasks with explicit memory, permissions, lifecycle, and observability.
More specifically, a flow begins with a persistent Letta agent and helps that agent navigate its own conversation space:
A flow is the current Letta agent opening fresh or inherited conversations, returning to them, and synthesizing what it learns under ordinary TypeScript control flow.
The script should own deterministic control flow. Agents should do the work that benefits from judgment.
TypeScript: loops, conditions, limits, validation, scheduling, failure policy
Agents: research, interpretation, planning, creation, critique, revision
Current direction #
flows resolves its root identity from an explicit agentId, then LETTA_AGENT_ID, then the AGENT_ID provided by Letta Code. It refuses to silently create a new identity.
When no SDK client is supplied, it starts a local App Server against the harness backend implied by that ID: API-backed agents use the API harness and agent-local-* identities use the local harness. Applications targeting Letta Cloud, a remote App Server, or a specific computer should construct LettaAgentClient themselves and pass it as sdkClient.
The initial primitives are:
fresh: open a new conversation with a fresh transcript on the current agent;fork: copy an existing conversation's in-context history into a new conversation, then continue from it;resume— continue a known conversation;parallel— run lazy agent operations concurrently while active agent turns share a limit;map— apply an agent operation across a collection;phase— attach structured progress context to a section of work.
import { flow } from "flows";
const answer = await flow(async ($) => {
const perspectives = await $.parallel([
() => $.fresh("Make the optimistic case"),
() => $.fresh("Make the skeptical case"),
]);
return $.fresh({
prompt: `Reconcile these perspectives:\n\n${perspectives.values().join("\n\n")}`,
});
});
Every fresh conversation stays on the same agent and follows normal Letta memory and learning behavior. Its result retains the new conversationId, so the workflow or its caller can navigate back to that line of thought later.
fresh and fork are deliberately distinct: fresh starts with an empty transcript, while fork copies the source conversation's in-context history. Both remain on the same persistent agent and share its memory.
const alternate = await flow(async ($) =>
$.fork({
sourceConversationId: process.env.CONVERSATION_ID!,
// Omit messageId to copy the full in-context history.
messageId: "message-checkpoint",
hidden: true,
model: "letta/auto-fast",
prompt: "Reconsider the plan from the checkpoint using a cheaper model.",
}),
);
console.log(alternate.run.sourceConversationId);
console.log(alternate.run.conversationId);
Forks are persistent conversations. hidden: true only removes them from normal listings; it does not archive or delete them.
examples/fork-variants.ts creates one source draft, then forks its inherited transcript into independent simplification and stress-test continuations:
bun run example:fork -- "Design a release-readiness workflow"
# Optionally choose one conversation-scoped model for all three turns.
FLOWS_MODEL=letta/auto-fast bun run example:fork -- "Design a release-readiness workflow"
Current semantic limits #
concurrencylimits activefresh,fork, andresumeturns. It does not attempt to schedule arbitrary asynchronous JavaScript inside aparallelcallback.- Agent turn failures resolve as
FlowResultfailures, so sibling agent operations can continue. A callback that throws an ordinary JavaScript exception still rejectsparalleland the enclosing flow. stateless: trueuses the Agent SDK's stateless session mode. The local session does not load or update MemFS, skills, mods, transcript, or reflection state. A conversation on an existing agent still receives that agent's server-compiled system context, which may contain retained identity or memory blocks. Stateless mode controls local state behavior; it does not create an evidence-isolated model context.forkrejectsstateless: true, because a stateless session would not load the transcript the fork copied.modelandreasoningEffortapply conversation-scoped overrides to new, forked, and explicitly resumed conversations. Targeting the default conversation changes the agent default instead.resumevalidates and initializes the conversation before sending, then verifies that it belongs to the root agent.forkretrieves and verifies source ownership before copying any history.
Flow-level defaults #
defaults sets model, reasoning effort, session options, and stateless mode for every turn in a flow; each operation overrides any field explicitly. A per-operation session object replaces the default session wholesale rather than merging, so what a turn receives is always explicit. Incompatible combinations (a stateless default under a fork) are rejected before any session or conversation is created. Run metadata keeps configuredModel (the handle the flow selected) separate from model (the model the server reported for the conversation), so a silent server-side remap is visible.
const result = await flow(
{
defaults: { model: "letta/auto-fast", reasoningEffort: "minimal" },
},
($) => $.fresh("Summarize this"), // uses the defaults
);
// result.run.configuredModel === "letta/auto-fast"
// result.run.model === whatever the server actually resolved
Time budgets #
timeBudget.limitMs requests cancellation when the flow callback exceeds its wall-clock budget. Tracked sessions receive SDK abort requests; supported runtimes can return an interrupted terminal result. After stragglerGraceMs (default 5000ms), unresolved operations are abandoned and cleanup is attempted. Late session creation/preparation cannot submit a new prompt after the deadline. Each cleanup attempt has a bounded wait and emits cleanup.failed if termination cannot be confirmed. An arbitrary user callback, custom session, or external effect cannot be forcibly cancelled by JavaScript; this is not an OS-level hard process deadline. turnDeadlineMs starts after preparation and requests abort for a slow model turn; use the overall limit as well.
try {
await flow(
{ timeBudget: { limitMs: 60_000, turnDeadlineMs: 20_000 } },
($) => $.parallel([() => $.fresh("A"), () => $.fresh("B")]),
);
} catch (error) {
if (error instanceof FlowDeadlineError) {
// in-flight turns were aborted; nothing is still running silently
}
}
Client ownership and shutdown #
When flow() creates its own SDK client (no sdkClient passed), it owns the client and its spawned local app-server, and disposes both when the flow finishes — the process exits cleanly. When you pass sdkClient, the client is borrowed: the flow closes the sessions it created but never closes your client, so a shared client survives across flows. SdkFlowClientOptions tunes the bounded retry behavior (see below).
Rate-limit retry #
The SDK client retries confirmed 429 rate-limit errors with exponential backoff and jitter, but only on the idempotent conversations.retrieve call that precedes a fork. Conversation creation and the fork itself are never retried: a timed-out create may have succeeded server-side, and retrying could duplicate a conversation, so ambiguity fails closed. Errors that look like timeouts ("Timed out waiting for req-N") are classified as ambiguous and never retried. SdkFlowClientOptions.rateLimitRetries (default 2) and rateLimitBaseDelayMs (default 750) bound the retry envelope; isConfirmedRateLimit and isAmbiguousTimeout are exported for callers building their own retry policies.
Values #
1. TypeScript is the workflow language #
No YAML graph and no bespoke expression language. A workflow should use normal functions, promises, if, for, while, and try/catch.
The DSL should make agent work easier to express without hiding the program that orchestrates it.
2. Dynamic before declarative #
The number and shape of steps may emerge at runtime. A check can produce new failures, research can surface new questions, and a reviewer can send work back for revision.
Workflows should support these patterns naturally instead of requiring every possible path to be declared as a static DAG.
3. Persistence is an explicit design choice #
The default participant is the current persistent agent, thinking in another conversation—not a disposable role-playing worker. Some future workloads may still need ephemeral, isolated agents, while others may involve distinct persistent collaborators.
The difference must be visible in the API. Opening a fresh transcript, forking inherited history, resuming in place, involving another persistent agent, and creating a temporary worker are materially different operations and should never masquerade as one another.
4. Safety is part of the type and runtime model #
Permissions, isolation, mutability, and retry behavior are workflow semantics—not incidental options.
A read-only researcher can often be retried safely. A worker that may have edited files before its connection closed cannot. The convenient path should also make these differences clear.
5. Typed boundaries, inspectable artifacts #
Agent output should cross stages through validated values or durable artifacts rather than hopeful prose interpolation.
Intermediate results should be inspectable. Large files and patches should not silently travel through truncated model responses. Validation failures should remain failures, not malformed success values.
6. Partial failure is normal #
Fan-out workflows should not collapse because one worker failed. They should preserve structured information about successes, failures, retries, and uncertainty so the application can make an explicit decision.
null is convenient, but it is not an adequate agent-failure model. Programmer exceptions remain exceptions rather than being disguised as model failures.
7. Observability is a first-class output #
A workflow should emit structured lifecycle events for phases, stages, workers, tools, retries, costs, artifacts, and completion.
Terminal output is one renderer. The same events should be usable in a web UI, a Slack thread, a dashboard, a trace viewer, or a log pipeline without scraping ANSI text.
8. Composition over framework magic #
Small primitives should compose into map/reduce, pipelines, panels, debate, verification, and iterative repair. We should resist adding a named abstraction until repeated real workflows show that it carries its weight.
The implementation should remain understandable enough that a user can predict when an agent starts, what context it receives, and what happens when it fails.
9. Backends should change placement, not meaning #
Local execution, Letta Cloud, and remote App Servers have different capabilities. A workflow may deliberately select among them, but its core semantics should not silently change with the backend.
Placement, tools, models, sandboxes, and credentials should be explicit constraints that the runtime can explain.
10. Earn the SDK surface #
flows is an experiment, not a claim that the Agent SDK needs a workflow framework.
Ideas should move upstream only after they:
- appear across several materially different workflows;
- survive real use and failure cases;
- can be explained with a small, stable contract; and
- improve the SDK independently of this particular DSL.
Some outcomes may remain recipes. Some may become a companion package. Some lifecycle, permissions, output, or event primitives may belong in the Agent SDK itself. All three are successful outcomes.
Initial questions #
The first experiments should help answer:
- What is the smallest useful execution scope?
- Should workers return values, result objects, artifacts, or some combination?
- How should structured output validation and repair work?
- How should retry safety be represented?
- What progress events are useful across terminal, web, and channel UIs?
- How should concurrency and cancellation propagate through nested work?
- When does a pipeline abstraction clarify ordinary TypeScript, and when does it obscure it?
- Which differences between ephemeral workers and persistent agents should the runtime enforce?
- How much orchestration belongs above the Agent SDK at all?
First proving grounds #
We will test the design against workflows with different shapes:
- Document map/reduce — summarize many inputs, reduce in bounded batches, and synthesize a source-grounded briefing.
- Research and verification — investigate independent angles, cross-check claims, and report only supported conclusions.
- Fix until pass — run a deterministic check, isolate repair workers, and repeat until success or an explicit stopping condition.
- Personal advisory panel — consult independent perspectives, expose disagreement, and synthesize a decision without pretending consensus.
- Security sweep — audit source files for real weaknesses with parallel fresh, tool-free conversations, then triage all reports through a security-monitor conversation (
examples/security-sweep.ts). Hunters are not told to expect a vulnerability, so a clean file is a valid result and the sweep does not manufacture findings; the monitor treats clean reports as evidence, not as missed bugs. Run./examples/security-sweep.sh <directory> --model <handle>from interactive shells; the model override is conversation-scoped and never changes the agent default. The panel shows the resolved model and writes a private Markdown report, structured JSON, and a machine-readable receipt (counts and per-file outcomes only, safe to paste anywhere) undersecurity-sweeps/. Use--out <file>to choose the report path or--printto also emit the full report in the terminal. - Public Knowledge batch — give one existing agent a frozen set of public-source packets, draft each article in a fresh tool-free conversation, review each draft in another conversation, apply at most one revision pass, and retain conversation/run receipts (
examples/public-knowledge-batch.ts). The workflow writes private candidate artifacts underpublication-runs/; it has no publication, Git, deployment, or site-checkout authority.
Public Knowledge batch #
The publication workflow uses the current agent ID, so each conversation retains that agent's server-compiled system context. Every conversation also sets stateless: true, selects an empty toolset, disables skills, and receives a frozen evidence packet. These options prevent local MemFS reads and direct tool effects; they do not erase identity-bearing or retained context already compiled by the server. The packet is the declared evidence basis, while prompts, deterministic checks, model review, and a separate integration review enforce declassification. Do not describe this mode as source-isolated.
bun examples/public-knowledge-batch.ts path/to/manifest.json --dry-run
bun examples/public-knowledge-batch.ts path/to/manifest.json --concurrency 3
The manifest defines article metadata, reader, job, one sentence to retain, declared public source URLs, and local packet files. Each packet file declares which public source IDs it represents, and every declared source must map to at least one packet. This mapping is an editorial assertion; the loader can validate its completeness but cannot prove that the packet text came from the claimed URL. Packet paths must remain inside the manifest directory after symlink resolution. Optional packet digests make a run fail if evidence changed after review. A live run has four phases:
Draftcreates one fresh conversation per article and requires a structured body plus claim inventory.Reviewcreates another conversation per successful draft and checks it against the same packet.Revisecreates a new conversation only for drafts with blocking findings.Re-reviewchecks each revised draft once. Remaining defects stay blocked from integration review.
Deterministic checks reject common private-path forms, runtime and route identifiers, unresolved wikilinks, unsafe HTML, editorial scaffolding, missing prose leads, out-of-range length, source-ID mismatches, and factual claims listed without sources. Claim-inventory items carry exact body quotations for inspection. Semantic coverage still depends on the model review and the later integration review; a regex cannot prove that an inventory exhausts the prose. Style heuristics remain warnings for the same reason.
The run directory contains reviewable Markdown drafts, per-entry review JSON, a complete result, and a content-dark receipt with agent, conversation, run, exact artifact hash, timing, cost, and integration-readiness fields. The CLI confines output beneath publication-runs/. Directory mode is 0700; files are 0600, including on reruns through existing directories. A cleanup failure blocks the entire batch from integration review. The receipt records externalEffects: false because the flow cannot publish its own output. A separate integration process still owns declassification, semantic source verification, site checks, authorization receipts, Git, deployment, and live readback.
If an abstraction only works for one of these, it probably is not yet a primitive.
Non-goals—for now #
- A production-grade distributed workflow engine
- A static DAG builder
- A replacement for queues, schedulers, or durable job systems
- Hiding the Agent SDK
- Making every agent persistent
- Guaranteeing backward compatibility while the experiment is young
- Merging a large speculative API into the Agent SDK
Status #
This repository is a personal experiment. Expect sharp edges and breaking changes. The immediate goal is to write workflows, learn from them, and keep a legible record of why the abstractions evolve.
The first prototype is under active development. See PLAN.md for its scoped implementation plan and explicit deferrals. AGENT-SDK-FEEDBACK.md records concrete SDK friction and successful primitives from live runs.
- TypeScript 100%