A full-stack agent framework designed for long-running, multi-session jobs that require persistent memory, scheduled execution, and resumable state across process boundaries. Built for work like outreach campaigns, research projects, and maintenance tasks that span hours or months rather than single conversations.
An opinionated full-stack agent harness with native memories, long running tasks, and multi-agent relay
At a glance
A full-stack agent harness that enables long-running multi-turn jobs with persistent memory, durable state resumption, and multi-agent relay workflows that can pause and resume across processes.
npm install -g @duetso/agent
README

duet-agent
The agent harness for jobs that outlive the chat.
How do you keep an agent working until the job is done? Most harnesses don't have an answer — the chat ends, the process dies, the context goes with it. duet-agent has three. A session can pause for minutes or months and pick up in a fresh sandbox without losing the thread.
npm install -g @duetso/agent
duet login
duet "prospect the VP Eng at Acme, send a first-touch email, and book a meeting if they reply"
One duet login writes DUET_API_KEY to ~/.duet/.env and unlocks frontier language models (Claude, GPT, Gemini), image and video models, and Firecrawl-powered web skills through the Duet AI Gateway. No separate provider billing. (CLI Env Setup if you'd rather bring your own keys.)
Jobs that don't fit in one chat
- Long-running outreach and lifecycle work. Prospect → email → wait 14 days → book → handoff. The relay sits in
wait_for_replyfor a week without a process alive. - Coding sessions that don't forget. Resume a refactor a month later and the agent still knows what it tried, what worked, and what you told it never to do again.
- Multi-step research with receipts. Each step is an observable state — agent, script, poll — not a black-box chain. You can start in the middle: "I already did the research, just draft the email."
- Serverless and sandboxed turns.
TurnStateis the only thing that needs to survive between turns. Cron wakes it up, runs one turn, persists, exits. - Slow, scheduled, recurring work. Cold outbound, weekly digests, review cycles, scheduled retries, long-running migrations — anything where the next step is hours or days away.
If the work fits in one chat, you probably don't need this. If it doesn't, most harnesses make you bring your own memory layer, your own queue, your own resume logic. That's the gap.
Why another harness?
Most agent frameworks are good at one turn. They're not built for the job. Memory is a plugin, durable processes are "wire up Temporal," and resume-from-disk is something you bolt on at the end and regret. The interesting work — remembering across turns, picking up where you left off, knowing when to ask vs. when to keep going — is left to you.
Both Claude Code and Codex are excellent at the one-turn job: edit this file, run this command, answer this question. duet-agent is built for the layer above that — work that has to remember, wait, resume in a different process, and route itself.
| Claude Code | Codex CLI | duet-agent | |
|---|---|---|---|
| Cross-session memory | CLAUDE.md (you write it) + auto-memory (Claude writes it, per-repo, capped at the first 200 lines / 25 KB loaded per session) |
AGENTS.md (you write it; hierarchical AGENTS.override.md in subdirs) |
Observational memory in PGlite with hybrid retrieval (pgvector + tsvector, fused via RRF). The agent observes its own transcripts and recalls them across sessions. |
| Memory of images | Images stay in the active transcript only | Images stay in the active transcript only | Vision-capable models observe screenshots and UI captures into text observations the agent can recall on later turns |
| Resume across a fresh process | claude --resume <id> / --bg background sessions — replays a saved transcript |
codex resume <id> — replays a saved transcript from ~/.codex/sessions/ |
TurnState snapshot on disk; any fresh process — new sandbox, serverless invocation, different machine — resumes with runner.start({ state }) |
| Long-running, multi-step workflows | Background sessions; no native poll/wait/state-machine primitives | Transcript-driven; no native poll/wait/state-machine primitives | Relays: agent-routed state machines with five state kinds — agent, script, poll, timer, terminal. Wait days for a reply between turns. |
| Model routing | Manual — /model picks the model |
Manual — -m/config picks the model |
Routed — a classifier picks model + effort per turn and reroutes mid-turn; an ask_advisor tool escalates the hardest decisions to a stronger model |
| Models you can use | Anthropic | OpenAI (Sign in with ChatGPT or API key) | Anthropic, OpenAI, OpenRouter, Vercel AI Gateway, Duet Gateway |
| License | Proprietary (closed-source CLI) | Apache-2.0 | Apache-2.0 |
None of this makes Claude Code or Codex worse at what they do. It makes duet-agent the right thing to reach for when the job is too long for one chat — when memory has to survive the session, when a process has to wait on an external signal, when a turn has to resume in a container that didn't exist when the work started.
Two things that don't get a section of their own below, but that follow from the same shape:
- One login, every frontier model.
duet loginis the whole setup. Claude, GPT, Gemini, image, video, web — all behind one key. - Open source. No black box. If we made a wrong call about how memory ranks or how relays route, you can read the code and tell us we're wrong.
What we do differently
The model router + advisor
You don't pick a model; you pick a tier. Bare duet runs the frontier virtual model: a cheap classifier reads each prompt and picks the concrete model + effort for that kind of work — visual, planning, implementation, writing — and keeps re-checking as the turn progresses, so a session that starts by reading screenshots on a vision model can finish the backend work on a coding one. Cache economics are part of the decision, not a hysteresis hack: the classifier is told that switching discards the provider prompt cache and to prefer the current model while the kind of work stays the same, and compaction and rerouting are mutual milestones — reroute checks run when the cache is already broken, and a model switch hands the new model a compact prefix. Step outputs feed back in too: reading an image on a text-only route can reroute mid-turn to a vision-capable model.
The advisor is the escalation valve. Tiers that enable it inject a no-parameter ask_advisor tool that ships a curated slice of the transcript — the executor's system prompt, the first user message, live memory observations as the elided middle — to a stronger model. Consulting is mandatory for consequential architecture calls, conflicting constraints, and important unknowns, and forbidden for routine local work; routing changes nudge the advisor, and advisor consults trigger reclassification.
All of the judgment lives in prose, not code: route descriptions, the cache-switching preference, and advisor timing are editable text in the routing table, so tuning the router is an edit to .duet/models.json, not a rebuild. duet route "<prompt>" shows exactly what the classifier would decide; a concrete --model name pins that model and bypasses routing entirely. Design rationale and invariants: specs/done/model-router/.
Memory, woven in
Memory and compaction are the same primitive: observational memory is how context survives between turns. The runner observes its own transcript, reflects when observations grow, and writes durable rows to PGlite — text observations for messages and images alike, so screenshots and UI captures stay recallable on later turns without re-attaching bytes. Each turn's prompt prefix is frozen between deliberate refresh points: curated memory files load from the project ancestry at session start or explicit skills reload, while long-term and current-session observations rebuild at compaction events. That separation keeps trained knowledge deterministic, prevents it from displacing observational memory, and preserves the provider's prompt cache turn-over-turn. Database observations outside the frozen prefix stay reachable through recall_memory — hybrid retrieval over pgvector cosine similarity and tsvector keyword search, fused via Reciprocal Rank Fusion, with optional paraphrased query expansion. Embeddings run in a background worker so foreground turns never block.
Relays: keep the agent working until the job is done
A relay is how duet-agent stays on task across hours, days, or months. Under the hood it's a state machine — but the runner agent, not a config file, picks the next transition every turn. Enough structure to route long work; not so much that the agent is locked into a path. Each state is one of five kinds:
- agent — a sub-agent with a prompt, optional system prompt, and optional skill allowlist.
- script — shell out to
bash,curl, a CLI. Anything with an API is a script state. - poll — recurring check on an external signal (inbox, build status, webhook).
- timer — a pure delay until a wall-clock time, no script attached.
- terminal — record a business outcome:
completed,cancelled,failed.
flowchart LR
A([agent]) --> S([script]) --> P([poll]) --> Tm([timer]) --> T([terminal])
That's the whole vocabulary. Email, GitHub, Calendly, CRM — none of them need first-class engine concepts; they're shell scripts. Relays can start in the middle ("I already emailed them, just wait for a reply") because the runner agent reads context, not a workflow ID.
[!NOTE] This is not Temporal. Not a deterministic DAG, not an exact-once runtime, not a workflow service. It's enough structure for an agent to make good process decisions, and hand operational guarantees off to external systems when they matter.
State on disk, serverless by default
A turn is a pure function of TurnState. The runner owns it in process while a turn runs; persistence keeps it on disk between turns. Any process — the same shell tomorrow, a fresh sandbox, a serverless invocation, a different machine — can pick up where the last one left off by calling runner.start({ state }). There are no sticky workers, no warm pools, no session affinity to lose. Cron wakes the container, hands it the snapshot, runs one turn, persists, exits. That's the whole deployment model when you want one.
Three modes: agent, relay, auto
The same runner handles both shapes of work. agent mode treats the prompt as a normal session — coding, research, review, one-off tasks. state_machine mode (surfaced as relays in the UI) routes the prompt into an agent-routed business process with durable waits and terminal outcomes. auto lets the runner classify the prompt and pick. One CLI, one SDK; the long jobs and the short ones use the same code path.
Architecture
A realistic agent-routed relay: outbound conference outreach. The user prompt enters the TurnRunner, the runner agent picks the next state from prompt + history + available states, and the relay drives the business process until it hits a terminal state. The same definition can start in the middle — for example, the runner can skip straight to wait_for_reply if the user says "I already emailed them."
stateDiagram-v2
direction TB
[*] --> Classify : user prompt
Classify --> AgentMode : one-off task
Classify --> Outreach : matches a relay
AgentMode --> [*] : answer / edits
state Outreach {
direction TB
[*] --> research_prospect
research_prospect : research_prospect (agent)\nweb + notes lookup
draft_email : draft_email (agent)\nwrite first-touch email
send_email : send_email (script)\nbash: gmail send
wait_for_reply : wait_for_reply (poll)\nevery 6h: check inbox
schedule_meeting : schedule_meeting (script)\nbash: calendly create
meeting_booked : meeting_booked (terminal: completed)
not_interested : not_interested (terminal: completed)
no_response : no_response (terminal: cancelled)
research_prospect --> draft_email : enough signal
research_prospect --> not_interested : disqualified
draft_email --> send_email
send_email --> wait_for_reply
wait_for_reply --> schedule_meeting : positive reply
wait_for_reply --> not_interested : declined
wait_for_reply --> no_response : 14d timeout
schedule_meeting --> meeting_booked
}
Outreach --> [*]
Observational memory, pi coding tools, and guardrails sit underneath every state transition; they are not states themselves.

One prompt, a job that can run for weeks, months, or even years. The power of state machines a.k.a. relays in duet.
Other capabilities
Pattern-based (fast, regex) and semantic (LLM-evaluated) guardrails compose into a firewall. Every bash command and file write can be checked before execution.
const turnRunner = new TurnRunner({
guardrails: [
{
kind: "pattern",
rules: [
{ pattern: /production-db/i, action: "warn", reason: "Production database mentioned" },
],
},
{
kind: "semantic",
model: getModel("anthropic", "claude-haiku-4-5"),
policy: "Never delete production data. Never expose secrets in output.",
},
],
});
TurnRunner can attach to remote Model Context Protocol servers over the streamable-HTTP transport. Pass mcpServers on start and the runner connects, lists each server's tools, and exposes them to the parent and state agents alongside the built-in coding tools. Tool names are namespaced as {server}__{tool} so multiple servers can coexist without collisions.
await turnRunner.start({
mcpServers: {
docs: {
type: "http",
url: "https://mcp.example.com/docs",
headers: { "x-api-key": process.env.DOCS_KEY! },
},
},
});
Only HTTP MCP is supported today; authentication is intentionally out of scope, so any credentials a server expects must travel in headers. Connection failures are logged and skipped so a single broken server cannot block session setup.
Interrupt behavior comes from the underlying pi agent runtime. A user can send a message while a pi session is running, and the runtime can handle it as an interruption or as a follow-up. duet-agent does not add a second interrupt bus on top.
Sub-agents use the default tools from @earendil-works/pi-coding-agent: read, bash, edit, and write. The turn runner supplies a working directory and can restrict which skills are injected into a state-machine agent state; it does not wrap those tools in a second sandbox abstraction.
Install
# CLI — npm
npm install -g @duetso/agent
# CLI — Bun (the CLI is Bun-native)
curl -fsSL https://bun.sh/install | bash
bun add --global @duetso/agent
# SDK
npm install @duetso/agent
npm install --global @duetso/agent
pnpm add --global @duetso/agent
yarn global add @duetso/agent
# Upgrade an existing global install
duet upgrade
The CLI runs on Bun because OpenTUI is Bun-native.
This repo uses Bun for package management, Husky for pre-commit checks, and Docker for functional tests.
bun install
bun run setup # install/start Docker on macOS or Linux if needed
bun run check-types
bun run lint
bun run eval # runs live evals inside Docker
bun run test # runs the test suite inside Docker
Use bun run test and bun run eval, not raw bun test, as the source of truth. File-writing tests and evals run in Docker so focused host runs cannot create .duet, PGlite databases, or home-directory skill fixtures in the checkout.
Paid evaluation infrastructure and its separate verification commands live in the SWE-bench benchmark.
The pre-commit hook runs format, check-types, and lint.
CLI Quick Start
The recommended path is duet login. One sign-in writes DUET_API_KEY to ~/.duet/.env and gives you access to every frontier language, image, and video model on the Duet AI Gateway plus the bundled web-scraping skills — no other API keys required.
duet login
duet "prospect the VP Eng at Acme, send a first-touch email about our conf talk, wait up to 14 days for a reply, and book a meeting on Calendly if they're interested"
Bring your own subscription. duet connect chatgpt (or copilot) links a
ChatGPT or GitHub Copilot plan by OAuth device code. Connected subscriptions
are transports, not extra models: the catalog stays the same, and any model
your plan covers automatically runs on it at $0 · plan instead of metered
credits, falling back to the gateways with a session notice when the plan is
exhausted. duet connect --status and --disconnect <provider> manage the
link; tokens live only in ~/.duet/connected-providers.json (0600).
Design rationale and invariants: the duet repo's
specs/connected-providers/.
Duet routes every model through one of three gateways — the Duet gateway (DUET_API_KEY), the Vercel AI Gateway (AI_GATEWAY_API_KEY), or OpenRouter (OPENROUTER_API_KEY). If you would rather manage keys yourself, use duet env (see CLI Env Setup below) or set one of those keys in the environment, <workdir>/.env, or ~/.duet/.env. Gateway inference (preferring those credentials in that order) decides which gateway serves each concrete model.
When --model is omitted, duet runs the routed frontier virtual model — see The model router + advisor for how routing and the advisor work. The flags and config:
--model frontier|balanced|economyselects a routing tier explicitly; any concrete model name pins that model and bypasses routing.duet config exportwrites the routing table to.duet/models.json(routes, efforts, advisor policy,stepTriggerskeywords, per-routevisionFallbackModelName);/routeinspects a live session.- Override discovery mirrors skills: the nearest
.duet/models.jsonwalking up from the working directory wins, with~/.duet/models.jsonas the global fallback. Each file is a complete replacement, never a merge. - It stays out of the way: tiers whose routes all resolve to one identical model+effort never call the classifier.
Use --provider <name> to pin a gateway without picking a model:
duet --provider duet "build a todo app" # Duet gateway
duet --provider vercel "summarize" # Vercel AI Gateway
duet --provider openrouter "explain this repo" # OpenRouter
--provider is mutually exclusive with --model / --memory-model. Accepted shorthands: duet, vercel (alias ai-gateway), and openrouter.
export DUET_API_KEY=...
duet "build a REST API with Express"
duet # interactive TUI
duet -m opus-4.8 --workdir ./my-project "refactor the auth module"
duet --memory-model sonnet-4.6 "summarize this repo"
duet --system-prompt "Prefer concise answers." "review this repo"
duet --system-prompt-file TEAM.md "review this repo"
duet --no-system-prompt-files "review this repo"
duet --resume session_abc123 --workdir ./my-project
duet skills # list installed skills
duet memory # browse durable memory
# Through Vercel AI Gateway
export AI_GATEWAY_API_KEY=...
duet -m opus-4.8 "review this repo"
Model names can be a virtual tier (frontier, balanced, economy — routed per prompt), a versionless family (opus, sonnet, haiku, sol, and others), full provider:modelId syntax, or a versioned shorthand such as opus-4.8 or gpt-5.6-sol. Family names resolve to the latest curated version, while concrete shorthands resolve to the first configured gateway; use full provider:modelId syntax — or --provider <name> — to pin a specific gateway.
In a TTY, duet opens an interactive TUI: live transcript on the left, a right-hand sidebar with four panels, and a textarea at the bottom.
- todos — the runner's current todo list.
- follow-ups — prompts queued behind the active turn (the working-status line also shows the count).
- relays — when a relay (state machine) is active, lists every state with
▶marking the current one and the terminal status if reached. - context — token-usage progress bar against the active model's window.
In the input box:
@<query>opens a file picker; ↑/↓ navigate, Enter / Tab inserts a markdown link like[app.ts](https://github.com/dzhng/duet-agent/blob/main/src/tui/app.ts)(basename label, repo-relative target). The model can then read the file via thereadtool./<query>opens a skill picker that inserts/skill-nameso the model receives the full SKILL.md inline with its instructions for that turn.- Enter sends; Shift+Enter queues the message as a follow-up while the agent is running, instead of steering the active turn.
Esccancels the current pickers; on its own it interrupts the active turn, or no-ops when idle.Ctrl+Cis contextual rather than an instant quit: it interrupts the active turn while one is running, clears the composer when it has text, and on an idle empty composer asks for confirmation first ("Press Ctrl+C again or Enter to exit"). Confirming — or closing the terminal — drains through the SessionManager so the local memory database (PGlite) flushes cleanly.
Tool calls render with custom per-tool headers (e.g. $ <command>, read <path> (lines a–b), edit <path> (N edits), [question]). Resumed sessions render history through the same formatters so live and replay match.
Type / in the composer to open the command picker, or send any of these as a message:
/model <name>— switch the model used for subsequent turns. Accepts virtual tiers and the same family, versioned shorthand, andprovider:modelIdforms as the--modelflag (e.g./model frontier,/model sonnet,/model duet:openai/gpt-5.6-sol). A concrete name pins the model and suspends routing; a tier resumes it. Unknown shorthands or missing provider credentials surface an error and leave the current model in place. The in-flight turn (if any) keeps the model it started with./routeinspects the live routing state./thinking <level>— switch the thinking level for the next turn. One ofminimal,low,medium,high,xhigh. The runner clamps to the active model's supported range at use-time. The in-flight turn (if any) keeps its level./feedback <message>— send free-form feedback to the Duet team./clear— dispose the current session and start a fresh one./copy [last|all|<N>]— copy transcript text to the system clipboard (default: last agent reply)./paste,/image <path>,/clear-images— image-attachment helpers, covered in the next section./diag— toggle key and selection event logging when triaging terminal-specific issues.
/model, /thinking, /clear, /paste, /clear-images, and /diag also work anywhere inside a longer prompt. hey can you review this /model sol swaps the model for the very turn that delivers the message; the slash form is stripped from what the agent sees, so the model receives hey can you review this. When the whole prompt is just slash commands, no agent turn runs at all. Commands that take rest-of-line arguments (/feedback, /copy, /image) only fire when they own the whole message.
The inline form also works for the non-TUI one-shot CLI: duet "hey can you review this /model sol" swaps the model and dispatches the stripped prompt to the agent; duet "/model sol" applies the swap and exits without dispatching a turn.
Slash commands are intercepted locally; they never reach the model unless the picker inserts a /skill-name skill reference, in which case the runner expands it into the matching skill's full SKILL.md inline for that turn.
The interactive TUI accepts image attachments (PNG, JPEG, GIF, WebP):
- Cmd+V / Ctrl+V — paste from the clipboard. Requires a kitty-keyboard-aware terminal; falls back to
/pasteon others. /paste— manual clipboard probe. Use this when your terminal swallows Cmd+V (Warp, macOS Terminal.app)./image <path>— attach an image from disk by absolute or relative path. Tilde (~/) andfile://URLs are accepted.
/clear-images removes all pending attachments. Attachments cache under ~/.duet/cache/paste/<session-id>/ and cap at 20 MB each. On macOS, clipboard reads use a small Swift program (via swift -e) so promise-backed pasteboards from Chromium apps deliver bytes — install Xcode Command Line Tools with xcode-select --install if you see "clipboard had no readable image."
The same ~/.duet/memory.db the runner reads is scriptable from the CLI. Bare duet memory opens the interactive TUI; adding --json or any filter turns the same invocation into a non-interactive query. Three subcommands cover the write and search paths:
# Filter/query mode — bare array of canonical memory objects
duet memory --json --type reflection --from 2026-06-26
duet memory --json --source user --session sess_abc123 # rows authored by one session
# Recall — hybrid semantic + keyword search, same pipeline as the recall_memory tool
duet memory recall wire byte budget cap # positional query
duet memory recall --query "how does the gateway race resolve" --expand
duet memory recall --scope global --session sess_abc123 --json qwiklabs
# Add — write a single user-authored note
duet memory add --priority high --tag billing "Enterprise discounts cap at 20%"
echo "note from a pipe" | duet memory add --source import --session sess_abc123 --json
All three share one canonical memory JSON object (--json), so a consumer parses one shape everywhere:
- Timestamps are ISO 8601 strings (e.g.
2026-07-06T00:00:00.000Z); date-only fields (observedDate,referencedDate) stayYYYY-MM-DD. sourceis a flat string — one ofuser | agent | system | api | import(systemis the training-origin value).sessionIdis the session that authored an observational row: writable on DB-backedadd, filterable onmemory/recall, and echoed on DB output (absent on legacy rows and file memories).- Observational JSON names both ranking axes explicitly:
packScore(recency × priority × kind bias) andrelevanceScore(query relevance, present only inmemory recall). File-store JSON reports its slug and store path instead because those records are inherited context, not ranked recall rows.
Command-specific notes:
duet memory(query):--jsonemits a bare array ordered by pack score;--sourceand--sessionnarrow the set alongside--type,--priority,--from, and--to.duet memory recall: query text comes from--query <q>or positional args.--scope session|globalrequires--session <id>to compare against;all(default) ignores it.--jsonemits a bare array ordered best-first, each row carrying bothpackScoreandrelevanceScore.duet memory add: added memories are alwaysnotekind. Flagless writes go to the nearest ancestor.agents/memories; pass--dbto retain observational-row ranking, embedding, and session attribution. Content comes from positional args or stdin, and empty input is a usage error.
DB-backed memory commands accept --db <absolute-path> and --wait <seconds>. memory add also accepts a single --store; memory recall deliberately rejects stores because file memories are already loaded into agent context. Exit codes are stable for scripting: 0 success, 64 usage or validation error, 75 memory-DB lock-wait budget exhausted, and 1 for anything else.
duet train <folder> launches a sub-agent inside the folder, lets it read whatever is there with its native tools (markdown, CSVs, PDFs, spreadsheets, screenshots, source — anything the agent can open), and produces two artifacts:
- One inherited markdown memory in the nearest ancestor
.agents/memoriesstore. Explicit--storeselects a different file store; explicit--dbretains the legacy high-priority manual-row behavior. ~/.duet/train/<memory-id>/— a hidden archive containing source copies and private provenance. Shareable memory frontmatter references the archive id but never embeds the absolute source folder.
duet train ./snowflake-notes # writes to the nearest .agents/memories
duet train ./snowflake-notes --slug snow # custom slug (default: folder basename)
duet train ./snowflake-notes --model opus-4.8
duet train ./snowflake-notes --db /tmp/scratch.db # write to a throwaway DB instead
Re-running train on the same slug replaces the prior entry and its archive in the selected backend.
Three things to know:
- The current session won't watch the new memory file. It appears in a fresh
duetsession, or after a programmaticreloadSkills()refreshes disk-backed context. - Synthesis needs an API key.
DUET_API_KEY(recommended), or any of the provider keys covered above. - The corpus folder's
.envis loaded (so a project folder can supply its own provider credentials). Check folders you didn't author before training them.
The CLI also prints the observation content to stdout, so it's pipeable: duet train ./docs | tee notes.txt.
duet login is the recommended setup path. It opens a browser, where you choose
the workspace during approval, then writes that workspace's DUET_API_KEY to
~/.duet/.env.
That single DUET_API_KEY is your access token to the Duet AI Gateway: frontier language models (Claude, GPT, Gemini), image generation (GPT Image 2), video generation (Seedance), and the Firecrawl-powered web scraping and search skills, all behind one key.
duet login
duet login --no-browser # print the auth URL instead of opening a browser
duet env is the manual alternative for users who want direct control over which provider API keys land in the shared env file. Without an action it just prints help. Add an explicit action to create or update the shared env file at ~/.duet/.env:
duet env --import # import .env from cwd
duet env --import ./path/to/.env # import a specific env file
duet env --keys # paste provider API keys interactively
duet env --env-file ~/.config/duet/env --keys # use a custom shared env file
duet --env-file ~/.config/duet/env "review this repo"
The CLI loads <workdir>/.env first, then the shared env file, so project-specific values override shared defaults. Supported keys are DUET_API_KEY, AI_GATEWAY_API_KEY, and OPENROUTER_API_KEY — one per model router.
For local development from a checkout:
bun run cli -- "build a REST API with Express"
SDK Quick Start
import { TurnRunner } from "@duetso/agent";
const turnRunner = new TurnRunner({
model: "opus-4.8",
cwd: process.cwd(),
mode: "auto",
});
// `start` is setup-only: loads skills and memory, emits `turn_started`, runs no agent work.
await turnRunner.start();
const terminal = await turnRunner.turn({
type: "prompt",
message:
"Prospect the VP Eng at Acme: research them, send a first-touch email, " +
"wait up to 14 days for a reply, and book a Calendly slot if interested.",
behavior: "follow_up",
});
start() prepares a session, but it does not run agent work. It hydrates durable memory, loads skills and agent files, connects MCP servers, creates or resumes TurnState, initializes the parent pi agent, and emits turn_started. After that, every turn() call accepts one command: prompt, answer, or wake.
flowchart TD
Start["start(command)"] --> Setup["hydrate memory\nload skills + agent files\nconnect MCP servers"]
Setup --> State["create or resume TurnState"]
State --> Parent["initialize parent pi agent"]
Parent --> Started["emit turn_started"]
Started --> Command["turn(prompt | answer | wake)"]
Command --> Active{"active turn\nalready running?"}
Active -- yes --> Fold["fold prompt/answer into parent agent\nor queue work behind active chain"]
Fold --> ActiveTerminal["return activeTurnPromise"]
Active -- no --> Chain["run turn chain"]
Chain --> Dispatch{"command type"}
Dispatch -- prompt/answer --> Mode{"state.mode"}
Dispatch -- wake --> Wake["resume scheduled poll/timer state"]
Mode -- agent --> ParentRun["run parent pi agent"]
Mode -- auto or explicit state machine --> Router["parent agent selects next state"]
Router --> StateRun["run agent, script, poll, timer,\nor terminal state"]
StateRun --> StateResult{"state result"}
StateResult -- completed --> Router
StateResult -- ask --> Terminal
StateResult -- sleep --> Terminal
StateResult -- terminal/interrupted --> Terminal
Wake --> StateRun
ParentRun --> Observe["observe transcript suffix\nreflect large observation logs\nflush durable memory"]
Router --> Observe
Observe --> Queue{"queued commands?"}
Queue -- yes --> Dispatch
Queue -- no --> Terminal["emit one terminal event\ncomplete | ask | sleep | interrupted"]
Terminal --> Snapshot["store latest TurnState in runner"]
Snapshot --> Next["caller persists snapshot\nand may call turn() again later"]
TurnRunner.turn() is the concurrency boundary. Callers may invoke it repeatedly while work is active; the runner folds active prompt and answer commands back into the active pi agent as steer or follow_up, queues wakes and work it cannot absorb immediately, and emits one terminal event when the whole active work chain is done.
The parent runner transcript stays linear across the lifecycle. State-machine continuations, script results, poll results, and user follow-ups all rejoin the parent agent instead of creating separate conversation branches. Terminal events carry the next TurnState; callers that need process-level durability persist that snapshot and pass it back to a later start({ state }).
TurnRunner owns memory at runtime. It holds a MemoryStore in process, hydrates durable observations from PGlite before the first turn, and subscribes to memory-store events to write future observation changes. After each pi-agent run, the runner observes the latest unobserved transcript suffix, reflects oversized observation logs, emits memory events with generated observation payloads, and waits for durable writes before continuing. Raw conversation messages stay in TurnState.agent.messages; memory persistence stores only derived observations/reflections.
The rendered memory section above the message tail is a frozen pack with two independent sources. Curated memory files discovered from the working directory's project ancestry are pinned first and refreshed only at session start or explicit skills reload. Database-backed memory follows as ranked cross-session observations and the current session's chronological compaction summary; those layers rebuild only at compaction events so ordinary observer writes do not invalidate the prompt cache. File memory never enters database ranking, reflection, usage-bump, or recall paths. The model can still pull non-pinned database facts on demand through the default recall_memory tool, which runs hybrid vector + keyword search with optional query expansion.
import { TurnRunner } from "@duetso/agent";
const turnRunner = new TurnRunner({
model: "opus-4.8",
memoryDbPath: false, // Disables observational memory and compaction.
});
By default, both duet (TUI/run) and duet --rpc store durable observations in ~/.duet/memory.db. Override the path with --db <path> (the flag is accepted in both modes and is ignored when --incognito is also set), or run with --incognito (or -i) to disable both observational databas
Comments (0)
Sign in to join the discussion.
No comments yet
Be the first to share your take.