Persistent agents used to sound like an architecture detail. In March 2026, they started looking like a product category.
On March 17, 2026, Anthropic released Dispatch. On March 23, 2026, Anthropic connected Dispatch to computer use so Claude could keep working on your desktop after you handed it a task from your phone. On March 27, 2026, OpenAI added background mode to the Responses API and explicitly framed it in terms of products like Codex, Deep Research, and Operator.
That is the shift this article is about.
Not “can an agent remember context?”
Instead:
- can it keep working after the initial prompt?
- can it run on a schedule?
- can it hand work off across devices?
- can it use direct tools first and only fall back to browser or desktop control when needed?
- can it pause for approval before doing something risky?
That is what persistent agent UX looks like in practice.
TL;DR
- Persistent agents are becoming product features, not just backend patterns. Dispatch, Cowork, and OpenAI background mode all push in that direction.
- The core primitive is a durable task, not a long HTTP request and not a giant chat transcript.
- The safest stack is usually: APIs and connectors first, deterministic automation second, computer use last.
- Computer use is a fallback capability, not a license to skip system design.
- If you want an “always-on coworker,” you need five things: task state, schedules, approvals, artifacts, and notifications.
- A good first implementation is surprisingly small: one task table, one worker loop, one polling endpoint, and one place to store results.
- The advanced version adds background execution, recurring jobs, and approval-gated desktop or browser actions.
What Changed in March 2026
Anthropic’s public product materials now describe a workflow that feels much closer to a coworker than a chatbot.
The official Cowork overview says Cowork uses the same agentic architecture as Claude Code inside Claude Desktop, works directly on your computer, and lets you describe a desired outcome and come back later to completed work. The official Dispatch + computer use announcement goes further: you can assign Claude a task from your phone, let it continue working on your computer, and pick up the finished output later.
OpenAI’s update lands from the platform side instead of the consumer-product side. The Responses API announcement says background mode exists because agentic products like Codex, Deep Research, and Operator can take minutes, and developers need an async execution model that survives timeouts and reconnects.
Those are different surfaces, but the product pattern is the same:
- a user expresses an outcome
- the system turns it into a durable task
- the task can continue after the user leaves
- the result comes back as an artifact, not only a chat reply
What a Persistent Agent Actually Is
A persistent agent is not just an LLM with memory.
It is a system with:
- durable task state such as
queued,running,waiting_approval,completed, andfailed - resumable execution so work can survive app closes, reconnects, or model latency
- artifact output such as a document, spreadsheet, pull request, or report
- optional schedules for daily or weekly work
- optional cross-device handoff so the request can start in one place and finish in another
- optional approval checkpoints before high-impact actions
That is different from the question I covered in the stateful-vs-stateless article. That article is about where agent state lives architecturally. This one is about how persistence shows up as a user-facing product behavior.
Real-World Product Examples
The nice thing about this trend is that we do not have to imagine it from scratch anymore. The public product material already shows the shape.
1. Dispatch as a cross-device handoff surface
Anthropic’s March 23, 2026 post says Dispatch lets you keep one continuous conversation with Claude across phone and desktop. It explicitly describes starting work on your phone, doing something else, and later opening the finished work on your computer.
That is a real product shift because “agent memory” is no longer just hidden backend state. It becomes a visible task surface:
- a task exists even when the app is closed
- the task has a lifecycle
- the task can create artifacts while you are away
2. Cowork as an always-on workspace
Anthropic’s Cowork docs say Cowork can work directly on local files, split tasks into parallel workstreams, and create polished outputs like spreadsheets, presentations, and formatted documents.
That means the product is not only “answer my question.” It is:
- inspect my files
- do a multi-step task
- return later with completed work
That is much closer to an async teammate model.
3. Scheduled briefings and recurring metrics
Anthropic’s Dispatch announcement gives concrete examples: have Claude check your emails every morning, pull some metrics every week, or spin up a Cowork or Claude Code session for a report or a pull request.
Those examples matter because they move persistence from a passive capability into a recurring workflow surface.
4. Computer use when integrations stop short
The same March 23 announcement says Claude will prefer precise tools first, like connectors, and use computer control when the required tool is missing. Anthropic frames computer use as a way to open files, use the browser, and run dev tools automatically, with permission and safeguards.
That is the right mental model. Computer use is powerful, but the point is not “the agent can click anything.” The point is:
- use direct systems when available
- use browser or desktop control only when necessary
- keep a human in the loop for risky steps
5. OpenAI background mode as the API primitive
OpenAI’s public docs make the product implication explicit. Background mode is there so you can build experiences like Codex, Deep Research, and Operator without long-lived frontend connections.
So if Anthropic shows the product surface and OpenAI shows the platform primitive, the shared lesson is clear:
persistent agents need durable work objects
not just long prompts
and not just bigger chat histories.
A Better Mental Model
This is the cleanest stack I know for persistent agents:
user request
-> task record
-> planner / agent worker
-> tools, APIs, files, or computer use
-> artifact output
-> notification or pickup surface
If you add schedules and approvals, it becomes:
user or scheduler
-> durable task
-> queued
-> running
-> waiting_approval (optional)
-> resumed
-> completed artifact
-> user notified on phone, desktop, or email
That is the pattern we are going to build.
Step 1: Build the Smallest Useful Persistent Agent
Start with something tiny:
- one API to create tasks
- one API to fetch task status
- one SQLite table
- one worker loop
- no model calls yet
This sounds almost too simple, but it teaches the right shape.
Install
mkdir persistent-agent-demo
cd persistent-agent-demo
npm init -y
npm install express better-sqlite3 nanoid zod
npm install -D typescript tsx @types/express @types/node
server.ts
import Database from "better-sqlite3";
import express from "express";
import { nanoid } from "nanoid";
import { z } from "zod";
const app = express();
app.use(express.json());
const db = new Database("tasks.db");
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
prompt TEXT NOT NULL,
status TEXT NOT NULL,
result TEXT,
run_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
type TaskStatus =
| "queued"
| "running"
| "completed"
| "failed";
type TaskRow = {
id: string;
title: string;
prompt: string;
status: TaskStatus;
result: string | null;
run_at: string | null;
created_at: string;
updated_at: string;
};
const createTaskSchema = z.object({
title: z.string().min(1),
prompt: z.string().min(1),
runAt: z.string().datetime().optional(),
});
function now() {
return new Date().toISOString();
}
function createTask(input: z.infer<typeof createTaskSchema>) {
const task: TaskRow = {
id: nanoid(),
title: input.title,
prompt: input.prompt,
status: "queued",
result: null,
run_at: input.runAt ?? null,
created_at: now(),
updated_at: now(),
};
db.prepare(`
INSERT INTO tasks (id, title, prompt, status, result, run_at, created_at, updated_at)
VALUES (@id, @title, @prompt, @status, @result, @run_at, @created_at, @updated_at)
`).run(task);
return task;
}
function getTask(id: string) {
return db.prepare("SELECT * FROM tasks WHERE id = ?").get(id) as TaskRow | undefined;
}
function findNextRunnableTask() {
return db.prepare(`
SELECT *
FROM tasks
WHERE status = 'queued'
AND (run_at IS NULL OR run_at <= ?)
ORDER BY created_at ASC
LIMIT 1
`).get(now()) as TaskRow | undefined;
}
async function executeTask(task: TaskRow) {
db.prepare(`
UPDATE tasks
SET status = 'running', updated_at = ?
WHERE id = ?
`).run(now(), task.id);
try {
await new Promise((resolve) => setTimeout(resolve, 1500));
const result = [
`Task: ${task.title}`,
"",
"Persistent-agent demo result:",
`- Original prompt: ${task.prompt}`,
"- Status moved from queued -> running -> completed",
"- This is where a real agent worker would write the artifact",
].join("\n");
db.prepare(`
UPDATE tasks
SET status = 'completed', result = ?, updated_at = ?
WHERE id = ?
`).run(result, now(), task.id);
} catch (error) {
db.prepare(`
UPDATE tasks
SET status = 'failed', result = ?, updated_at = ?
WHERE id = ?
`).run(String(error), now(), task.id);
}
}
let workerBusy = false;
setInterval(async () => {
if (workerBusy) return;
const task = findNextRunnableTask();
if (!task) return;
workerBusy = true;
try {
await executeTask(task);
} finally {
workerBusy = false;
}
}, 1000);
app.post("/tasks", (req, res) => {
const parsed = createTaskSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json(parsed.error.flatten());
}
const task = createTask(parsed.data);
res.status(201).json(task);
});
app.get("/tasks/:id", (req, res) => {
const task = getTask(req.params.id);
if (!task) {
return res.status(404).json({ error: "Task not found" });
}
res.json(task);
});
app.listen(3000, () => {
console.log("Persistent agent demo listening on http://localhost:3000");
});
Run it:
npx tsx server.ts
Create a task:
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{
"title": "Morning briefing",
"prompt": "Summarize sales, support issues, and today'\''s calendar."
}'
Fetch it a second later:
curl http://localhost:3000/tasks/<task-id>
This is not smart yet, but the shape is right. That matters more than intelligence at this stage.
Step 2: Add a Tiny Frontend
Once the backend works, the simplest useful UI is a task board.
TaskBoard.tsx
import { useEffect, useState } from "react";
type Task = {
id: string;
title: string;
status: "queued" | "running" | "completed" | "failed";
result: string | null;
};
export function TaskBoard() {
const [taskId, setTaskId] = useState("");
const [task, setTask] = useState<Task | null>(null);
const [title, setTitle] = useState("Weekly metrics");
const [prompt, setPrompt] = useState(
"Pull the latest revenue, signups, and churn numbers."
);
async function submit() {
const response = await fetch("http://localhost:3000/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, prompt }),
});
const created = (await response.json()) as Task;
setTaskId(created.id);
setTask(created);
}
useEffect(() => {
if (!taskId) return;
const timer = setInterval(async () => {
const response = await fetch(`http://localhost:3000/tasks/${taskId}`);
const next = (await response.json()) as Task;
setTask(next);
if (next.status === "completed" || next.status === "failed") {
clearInterval(timer);
}
}, 1000);
return () => clearInterval(timer);
}, [taskId]);
return (
<main style={{ maxWidth: 720, margin: "40px auto", fontFamily: "sans-serif" }}>
<h1>Persistent Agent Demo</h1>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<button onClick={submit}>Create task</button>
{task && (
<section>
<h2>{task.title}</h2>
<p>Status: {task.status}</p>
{task.result && <pre>{task.result}</pre>}
</section>
)}
</main>
);
}
This UI is boring, but it already captures the product behavior:
- submit work
- leave
- come back
- read artifact
That is the beginning of persistent-agent UX.
Step 3: Replace the Fake Worker with OpenAI Background Mode
Now we keep the task system, but swap the fake execution body for real async model work.
Install the SDK:
npm install openai
Then extend the schema a bit:
ALTER TABLE tasks ADD COLUMN provider TEXT;
ALTER TABLE tasks ADD COLUMN external_id TEXT;
openai-worker.ts
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
type TaskRow = {
id: string;
title: string;
prompt: string;
status: string;
result: string | null;
provider?: string | null;
external_id?: string | null;
};
function extractOutputText(response: any) {
if (typeof response.output_text === "string" && response.output_text.length > 0) {
return response.output_text;
}
return JSON.stringify(response.output ?? [], null, 2);
}
export async function startOpenAITask(task: TaskRow) {
const response = await client.responses.create({
model: "gpt-5.4",
input: task.prompt,
background: true,
});
db.prepare(`
UPDATE tasks
SET status = 'running',
provider = 'openai',
external_id = ?,
updated_at = ?
WHERE id = ?
`).run(response.id, now(), task.id);
}
export async function pollOpenAITasks() {
const runningTasks = db.prepare(`
SELECT *
FROM tasks
WHERE provider = 'openai'
AND external_id IS NOT NULL
AND status = 'running'
`).all() as TaskRow[];
for (const task of runningTasks) {
const response = await client.responses.retrieve(task.external_id!);
if (response.status === "completed") {
db.prepare(`
UPDATE tasks
SET status = 'completed',
result = ?,
updated_at = ?
WHERE id = ?
`).run(extractOutputText(response), now(), task.id);
continue;
}
if (response.status === "failed" || response.status === "cancelled") {
db.prepare(`
UPDATE tasks
SET status = 'failed',
result = ?,
updated_at = ?
WHERE id = ?
`).run(`OpenAI response ended with status: ${response.status}`, now(), task.id);
}
}
}
Now your HTTP request stays short, but the work can keep going.
That is the same product primitive behind “come back later to completed work.”
Step 4: Add Recurring Tasks
This is where the “always-on coworker” feeling starts to become real.
Install a scheduler:
npm install node-cron
Create a schedule table:
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
prompt TEXT NOT NULL,
cron TEXT NOT NULL,
timezone TEXT NOT NULL,
created_at TEXT NOT NULL
);
scheduler.ts
import cron from "node-cron";
import { nanoid } from "nanoid";
type ScheduleRow = {
id: string;
title: string;
prompt: string;
cron: string;
timezone: string;
};
export function loadSchedules() {
return db.prepare("SELECT * FROM schedules").all() as ScheduleRow[];
}
export function registerSchedules() {
for (const schedule of loadSchedules()) {
cron.schedule(
schedule.cron,
() => {
createTask({
title: schedule.title,
prompt: schedule.prompt,
});
},
{ timezone: schedule.timezone }
);
}
}
db.prepare(`
INSERT OR IGNORE INTO schedules (id, title, prompt, cron, timezone, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"weekday-briefing",
"Morning executive briefing",
"Summarize overnight support issues, revenue anomalies, and today's meetings.",
"0 7 * * 1-5",
"America/New_York",
now()
);
That gives you a real recurring workflow:
- every weekday at 7:00 AM
- create a durable task
- worker picks it up
- user checks result later
That is already close to the examples Anthropic uses publicly for Dispatch.
Step 5: Add Approval Gates Before Computer Use
This is the point where many teams get overconfident.
If the agent is going to:
- touch a legacy admin panel
- click through a finance dashboard
- edit a production setting
- submit a form in a browser
you should add an explicit pause.
Extend the task table:
ALTER TABLE tasks ADD COLUMN approval_reason TEXT;
ALTER TABLE tasks ADD COLUMN approved_at TEXT;
Now add a new state:
type TaskStatus =
| "queued"
| "running"
| "waiting_approval"
| "completed"
| "failed";
Approval planner
function requiresApproval(task: TaskRow) {
const riskyKeywords = [
"submit",
"delete",
"production",
"billing",
"bank",
"finance",
];
return riskyKeywords.some((keyword) =>
task.prompt.toLowerCase().includes(keyword)
);
}
function queueForApproval(task: TaskRow, reason: string) {
db.prepare(`
UPDATE tasks
SET status = 'waiting_approval',
approval_reason = ?,
updated_at = ?
WHERE id = ?
`).run(reason, now(), task.id);
}
app.post("/tasks/:id/approve", (req, res) => {
db.prepare(`
UPDATE tasks
SET status = 'queued',
approved_at = ?,
updated_at = ?
WHERE id = ?
AND status = 'waiting_approval'
`).run(now(), now(), req.params.id);
res.json({ ok: true });
});
Then change the worker logic:
async function executeTask(task: TaskRow) {
if (requiresApproval(task) && !task.approved_at) {
queueForApproval(
task,
"This task may open a browser or submit data to a high-impact system."
);
return;
}
// Continue with your model or automation worker here.
}
This looks small, but it is a major product boundary.
The user no longer feels like the agent is secretly doing work in the background. They feel like the system is collaborating with them.
Step 6: Add a Deterministic Browser Fallback
This is the simplest safe bridge into computer-use territory.
Instead of starting with a fully model-driven desktop loop, start with a guarded browser flow for a known system.
Install Playwright:
npm install playwright
npx playwright install
legacy-portal.ts
import { chromium } from "playwright";
export async function runLegacyPortalFlow(taskId: string) {
const browser = await chromium.launch({
headless: false,
chromiumSandbox: true,
env: {},
args: ["--disable-extensions", "--disable-file-system"],
});
const page = await browser.newPage({
viewport: { width: 1280, height: 720 },
});
try {
await page.goto(process.env.LEGACY_PORTAL_URL!);
await page.getByLabel("Username").fill(process.env.LEGACY_PORTAL_USER!);
await page.getByLabel("Password").fill(process.env.LEGACY_PORTAL_PASSWORD!);
await page.getByRole("button", { name: "Sign in" }).click();
await page.getByRole("link", { name: "Invoices" }).click();
await page.getByRole("button", { name: "Export CSV" }).click();
db.prepare(`
UPDATE tasks
SET status = 'completed',
result = ?,
updated_at = ?
WHERE id = ?
`).run("Legacy portal export completed successfully.", now(), taskId);
} catch (error) {
db.prepare(`
UPDATE tasks
SET status = 'failed',
result = ?,
updated_at = ?
WHERE id = ?
`).run(String(error), now(), taskId);
} finally {
await browser.close();
}
}
Why start here instead of jumping straight into a fully autonomous computer-use harness?
Because this teaches the correct order:
- make the workflow durable
- add approvals
- constrain the environment
- automate a known path
- only then add model-driven navigation where deterministic code breaks down
That is much easier to operate safely.
When to Use True Model-Driven Computer Use
At some point, deterministic Playwright scripts stop being enough.
That is where OpenAI’s computer use guide and Anthropic’s computer use tool come in. Both official docs recommend an isolated environment, and both position human oversight as part of the safe deployment story.
Use model-driven computer use when:
- the UI changes too often for brittle selectors
- the environment spans multiple tools or apps
- you need exploratory navigation rather than one hard-coded sequence
Still keep the same rules:
- isolated browser or VM
- allowlisted apps or domains
- approval before high-impact steps
- captured screenshots, logs, and final artifacts
The model may change. The product boundary should not.
Three Real Workflows Worth Building
If you want concrete, production-relevant starting points, these are the best three.
1. Morning briefing agent
- Trigger: every weekday at 7:00 AM
- Inputs: inbox summaries, support queue, calendar, yesterday’s KPIs
- Output: one markdown brief
- Approval: not needed
- Computer use: only if one source has no API
2. Weekly metrics agent
- Trigger: every Monday at 8:00 AM
- Inputs: warehouse queries, Stripe exports, ad spend dashboards
- Output: spreadsheet plus executive summary
- Approval: needed if it must open or export from a finance tool
- Computer use: good fallback when the finance tool is browser-only
3. PR handoff agent
- Trigger: started manually from phone or desktop
- Inputs: repo, issue, failing tests, branch rules
- Output: patch, test results, and draft PR
- Approval: required before push or merge
- Computer use: useful when the repo workflow depends on local desktop tools or IDE-only state
These are close to the examples Anthropic already describes publicly, which is why they are better starting points than sci-fi demos.
Common Mistakes
- Mistaking persistence for memory. Chat history is not a task system.
- Using computer use as the first tool. Use APIs or connectors first.
- Skipping approval states. If the agent can touch money, prod, or customer data, add a pause.
- Hiding artifacts inside chat. Persistent agents should usually return files, reports, pull requests, or structured outputs.
- Running everything in one process. Durable task state should survive restarts.
The Product Lesson
The big lesson from Dispatch, Cowork, computer use, and background mode is not that agents are becoming magical.
It is that agent products are becoming operational.
They have:
- inboxes
- schedules
- task lifecycles
- approvals
- artifacts
- pickup surfaces across phone, desktop, and browser
That is a much better design target than “let’s make the chatbot more autonomous.”
If you build your first version around durable tasks, clear states, and narrow approvals, you can grow into always-on workflows without losing control.
Sources
- Anthropic: Put Claude to work on your computer (March 23, 2026)
- Anthropic: Claude Cowork product page
- Claude docs: Cowork overview
- Claude Help Center: Assign tasks to Claude from anywhere in Cowork
- Anthropic API docs: Computer use tool
- OpenAI: New tools and features in the Responses API
- OpenAI API guide: Background mode
- OpenAI API guide: Computer use
Public documentation is much clearer about product behavior than internal implementation details, especially for Dispatch. The code examples in this article are therefore implementation patterns for building persistent-agent products, not reverse-engineered descriptions of Anthropic’s private backend.