Rebuild Claude Duet
Anthropic's Claude Duet is one agent that reaches out before you ask. It sends a brief each morning, checks in through the day, and messages you only about what you said you want to hear about. Everything happens in one thread, it asks before it sends, pays, signs or deletes, and some things it will not do at all. This page builds the same shape in Foundry and deploys it on Kuiper.
An independent example built from Anthropic's article and launch coverage (sources at the end). It is not affiliated with or endorsed by Anthropic, and it is not how Claude Duet is built. The code is in examples/foundry-proactive-partner, and pnpm verify runs a whole day of it offline.
What Claude Duet does
- A morning brief and check-ins through the day. You choose what you hear about and how often.
- It flags problems you didn't ask about, such as a client call that clashes with a flight.
- One ongoing thread rather than separate chats. You hand it work by text or voice.
- Its own virtual machine with a browser, so your devices can be off while it works. It tells you when a background task finishes or needs your review.
- Your memory, skills and connected apps carry over from your Claude account, with over 6,000 connectors and plugins.
- It asks before sending messages, making payments, signing documents or deleting files. Some things, such as trading securities or moving money between accounts, it will not do even with your approval.
- It never sees your passwords. A classifier always rejects card numbers and government ID numbers, and shares health or financial details only when you explicitly ask.
A day, step by step
- A schedule in the person's time zone starts a run in their one thread. Nobody opened the app.
- Gmail and Calendar are apps installed with your accounts. The brief reads both in one program,
gmail.search_threadsandcalendar.list_events, through Google's MCP servers. Your tokens stay in your store; the agent gets results. notify_personis the only way to your phone, and everything it sends passes the guard first.- A check-in that finds nothing new sends nothing. The run ends with
(nothing new)and your phone stays quiet. - A failed payment lands in your Gmail, and the app's connection passes it into the thread at once. It notifies you once, saying what and why; mail that can wait gets nothing.
- The brief, the check-ins and your reply are all one conversation, so it knows what “it” is.
pay_invoicehasrequiresPermission. Foundry records the exact payment and holds the run; it pays only after you approve.- Asked to email your card number, it is refused by the guard, a Luhn check in code around Gmail's send. Because the guard would refuse it, you are not asked to approve it either.
- The computer is a durable subagent the partner owns, with its own saved files and the web. The partner hands it the job, and its result comes back to the thread as a new turn.
Each feature, as a Foundry primitive
| Claude Duet | Foundry | Where |
|---|---|---|
| You choose what you hear and how often | preferences on the partner instance: brief time, check-in hours, what to hear about | lib/onboarding.ts |
| Morning brief and check-ins | defineSchedule crons built from those preferences, in the person's time zone | agents/partner/schedules.ts |
| Quiet unless it matters | notify_person, the one way to the phone | agents/partner/notify.ts |
| One thread | One instance and one conversation per person; schedules attach to it | lib/onboarding.ts |
| Text or voice | voiceCall over the same assembled agent | agents/partner/call.ts |
| Its own computer | A computer worker each partner owns as a durable subagent, created on the first long job | agents/computer/ |
| Asks before acting | Gmail's send, and requiresPermission on pay, sign and delete | agents/partner/send.ts, actions.ts |
| Off limits even with approval | No tool exists for it | agents/partner/actions.ts |
| Card and ID numbers never leave; health and money only when asked | A strict tier in code, a sensitive tier from glove-classifier | lib/guard.ts |
| Your connected apps | Gmail and Google Calendar apps installed with the person's accounts: Google's MCP servers for reads, the Gmail transmission for sending, a connection for new mail | agents/partner/apps/ |
| Never sees passwords | Tokens stay in your store; every app operation asks accountSessions | lib/accounts.ts |
- Your account store and OAuth callback
- Where a person's partner, computer and preferences are created
- Where notifications go
- The guard's rules
1. One partner per person, as data
Each person gets a workspace with two agents in it: their partner and the computer it works on. Their name, time zone and preferences are the partner's data, and the thread's id comes from the person, so there is never a second one.
export const DEFAULT_PREFERENCES: Preferences = {
briefAt: "07:30",
checkInHours: [10, 13, 16, 19],
hearAbout: ["payments due or failed", "documents to sign", "clients waiting on a reply", "calendar clashes"],
};
export function partnerFor(person: Person, preferences: Preferences = DEFAULT_PREFERENCES) {
return {
id: person.id,
workspaceId: person.id,
name: "Partner",
description: `${person.name}'s partner: looks after their inbox, calendar and errands.`,
context: { name: person.name, timezone: person.timezone, preferences },
};
}
export const threadFor = (person: Pick<Person, "id">) => ({
id: `${person.id}-thread`, workspaceId: person.id, title: "Partner",
context: { conversationKey: "thread" }, // new mail joins it through this key
});The example seeds Ada's account with defineAgentInstance, with sample Gmail and Calendar accounts installed on her partner; scripts/onboard.ts and scripts/connect.ts create the same records for anyone else:
const ada = defineAgentInstance(partner, {
...partnerFor(DEMO_PERSON),
installations: [installationFor(adaGmail), installationFor(adaCalendar)],
});
export const data = new FileFoundryDataAdapter({
file: dataPath("foundry.json"),
agents: [ada], // its computer is created on the first long job
conversations: [createConversation(ada, threadFor(DEMO_PERSON))],
});
export default defineApplication({
name: "Proactive partner",
data,
services, // your accounts; routes and bindings in a file
routes: [gmailRoutes(adaGmail.id).in, gmailRoutes(adaGmail.id).out],
bindings: [sendBinding(ada.id, adaGmail.id)],
});The definition only says what a partner can be. Every part it names lives beside it:
export default defineAgent({
description: "Looks after one person's inbox, calendar and errands, and reaches out before they ask",
components: composeAgent(gmailApp, calendarApp, personal), // each app installed with the person's account
model: (_agent, context) => (isCall(context) ? callModel() : partnerModel(context.agentInstance.workspaceId)),
systemPrompt: (_agent, context) => partnerPrompt(context, isCall(context)),
store: ({ conversationId }) => FileStore.open(conversationId),
memory: [personal],
tools: () => [...actionTools(), notifyTool], // pay, sign, delete; notify_person
schedules: (_agent, { agentInstance }) => partnerSchedules(agentInstance.context),
playbooks: (_agent, { installations }) => { // new mail, as it arrives
const mail = installations.find((item) => item.kind === "application" && item.id === gmailApp.id);
return mail?.accountId ? [newMailFor(mail.accountId)] : [];
},
repl: () => defineRepl({ // the apps' reads, as functions
language: "javascript",
session: JsSession.create(),
mount: { discovery: "auto" },
programmaticTools: { select: ({ apps }) => apps.flatMap((app) => app.tools.filter((tool) => tool.readOnly)), keepDirect: false },
}),
configure: async (agent, ctx) => {
useReadSkill(agent, await loadSkills());
guardSends(agent, ctx); // Gmail's send, behind the guard
},
durableSubagents: { // long jobs go to its own computer
computer: { agent: computer, description: "Builds pages and dashboards and researches across sites." },
},
accountSessions,
run: (agent, context) => (isCall(context) ? call(agent, context) : context.defaultRun()),
});2. Its own clock, from the person's preferences
Schedules are what make it proactive. They resolve per instance, from its data, so each person's brief follows their time zone and the times they chose. Foundry stores them on the first run and attaches them to the conversation that run was in: the person's one thread.
export function partnerSchedules(context: Readonly<Record<string, unknown>>) {
const timezone = String(context.timezone ?? "UTC");
const preferences = (context.preferences ?? DEFAULT_PREFERENCES) as Preferences;
const [hour = "7", minute = "30"] = preferences.briefAt.split(":");
return [
defineSchedule({
name: "morning-brief",
timing: { kind: "cron", expression: `${Number(minute)} ${Number(hour)} * * *`, timezone },
message: "Morning brief. Read the inbox and today's calendar, then send one short brief with notify_person: what needs them today, in order, and any clash you can fix. …", // one program over gmail.* and calendar.*
}),
defineSchedule({
name: "check-in",
timing: { kind: "cron", expression: `0 ${preferences.checkInHours.join(",")} * * *`, timezone },
message: "Check-in. Read the inbox. If something they want to hear about needs them before the next check-in, notify_person once … Otherwise do not notify, and reply exactly: (nothing new)",
}),
];
}When the person changes a preference, your app calls configureAgent with the new context, and the next run moves the schedule. The brief also looks for problems nobody asked about: with a 16:00 flight and a 16:30 client call in the calendar, it offers to move the call.
3. Quiet unless it matters
The partner reaches the person in one way only, and the prompt says when to use it. Every check-in still writes to the thread, but only notify_person reaches the phone:
export const notifyTool: GloveFoldArgs<any> = {
name: "notify_person",
description: "Send the person a push notification. Use it only when something needs them.",
inputSchema: z.object({ title: z.string().max(80), body: z.string().max(600) }),
async do(note) {
// It goes to the person themselves, so only the strict tier applies.
if (strictlyBlocked(`${note.title}\n${note.body}`)) {
return { status: "error", data: null, message: "Blocked: card and ID numbers never go to a phone." };
}
if (process.env.PUSH_URL) {
await fetch(process.env.PUSH_URL, { method: "POST", headers: { title: note.title }, body: note.body });
}
await record({ kind: "notification", ...note });
return { status: "success", data: { notified: true } };
},
};New mail does not wait for a check-in. The Gmail app's connection watches the person's inbox (users.watch, Pub/Sub, history.list) and passes each message into the one thread through a playbook, where the partner decides whether it needs them now: a failed payment notifies, a newsletter gets nothing. The model decides what is urgent; like Duet, it can misjudge.
4. Ask before acting, and never do some things
Email goes out through the Gmail app's transmission: Foundry delivers it from the person's address, in its own process, after they approve the exact message. Paying, signing and deleting are tools with requiresPermission. Foundry records the exact input, holds the run and resumes it when the person answers.
The partner puts its guard around Gmail's send in configure. The wrapper decides whether to ask (a message the guard will refuse is not worth asking about), and its do calls the original send, so delivery still happens in Foundry:
export function guardSends(agent: IGloveRunnable, ctx: Pick<AgentAssemblyContext, "request" | "messageText">) {
aroundTool(agent, installedApplicationTransmissionToolName(gmailApp, gmail), (send) => ({
name: send.name,
description: `${send.description} Never include card or government ID numbers.`,
jsonSchema: send.jsonSchema,
requiresPermission: (input) => !strictlyBlocked(textOf(input)),
async do(input, _display, _glove, signal) {
// The person's own words, when they started this run, tell the guard what they asked for.
const reason = await blockedBecause(textOf(input), ctx.request.source?.kind === "direct" ? ctx.messageText : undefined);
if (reason) return { status: "error", data: null, message: `Blocked: ${reason}. Tell the person you will not send it, and why.` };
return send.run(input, signal);
},
}));
}Trading securities and moving money between accounts are off limits even with approval. The simplest way to make sure an agent never does something is to give it no tool for it, and to say so in the prompt so it can tell the person plainly.
5. A guard on every email, in two tiers
The strict tier is code, so it is sure and free: card numbers (a Luhn check) and government ID numbers never leave, whoever asked. The sensitive tier needs judgment, so it uses a classifier from glove-classifier (with TYPESAFE_API_KEY): health and money details leave only when the person explicitly asked to share them in this message.
export function strictlyBlocked(text: string): boolean {
return Boolean(cardNumberIn(text) || governmentIdIn(text));
}
export async function blockedBecause(text: string, request?: string): Promise<string | undefined> {
if (cardNumberIn(text)) return "it contains a payment card number";
if (governmentIdIn(text)) return "it contains a government ID number";
if (!classifier) return undefined;
const { answers } = await classifier.classify({
state: { message: text, request: request ?? "" },
questions: {
sensitive: noul("Does the message share someone's health details or personal finances?"),
asked: noul("Did the request explicitly ask to share those details in this message?"),
},
});
return answers.sensitive.noul > 0.8 && answers.asked.noul < 0.5
? "it shares health or money details they did not ask to share"
: undefined;
}6. Its own computer, and your voice
Long jobs go to the partner's computer, a durable subagent it owns, with its own files and the web. Foundry creates it on the first job. The partner hands it the job with glove_foundry_delegate, then keeps talking. The computer's result comes back to the thread as a new turn, and the partner tells the person it is done with notify_person. Its working environment is saved per person, so what it built last week is still there.
Voice is the same agent on a call. voiceCall runs a speech-to-speech session over the assembled partner, so the call has the same memory and the same computer. Tools that need approval stay off the call. See Voice agents.
export const call = voiceCall({
room: (context) => {
const { call: room } = callRequest.parse(context.request.payload);
return webSocketRoom({ port: room.port, token: room.token, agentName: "Partner" });
},
greeting: "The person just called. Greet them by name in one short sentence and ask what they need.",
});7. Run it
pnpm --filter glove-foundry-proactive-partner verify
1. on Ada's clock: morning brief 07:30, check-ins 10:00, 13:00, 16:00, 19:00 (America/New_York); her Gmail watch is connected
2. pushed: "Morning brief", from her inbox and calendar read in code
3. check-in: (nothing new), no notification
4. new mail, pushed: "Payment failed: INV-88"
5. waiting for approval: pay_invoice
in Ada's thread: Paid INV-88: $240 to Acme Hosting.
6. in Ada's thread: Told Leo 1:30 works.
7. in Ada's thread: I won't send card numbers, even to Sam. I can send him the hotel's booking link instead.
8. in Ada's thread: Done: Built dashboard.html: October invoices by vendor, $3,410 total, one overdue (INV-88). …
9. in Ada's thread: I can't move money between accounts, even with your approval. That one is yours to do in your bank's app.
10. brief moved to 08:15 (15 8 * * *)The script fires each schedule the way its activation would: a run in the thread with source: { kind: "activation" }. With no API key the partner runs a short script instead of a model, and Ada's accounts are samples on local MCP servers with Google's tool names, so this checks the wiring, not the judgment.
Deploy on Kuiper
kuiper login
kuiper projects create partner && kuiper use partner
kuiper units create partner
kuiper providers add anthropic --key - # once per organisation
kuiper secrets set GOOGLE_CLIENT_SECRET=- GOOGLE_SERVICE_ACCOUNT_JSON=- PUSH_URL=- FOUNDRY_TOKEN=-
kuiper env set -u partner PARTNER_DATA_DIR=/data \
'FOUNDRY_TOKEN=${{secrets.FOUNDRY_TOKEN}}' \
'ANTHROPIC_API_KEY=${{models.API_KEY}}' \
'ANTHROPIC_BASE_URL=${{models.ANTHROPIC_BASE_URL}}' \
'PUSH_URL=${{secrets.PUSH_URL}}' \
GOOGLE_CLIENT_ID=… 'GOOGLE_CLIENT_SECRET=${{secrets.GOOGLE_CLIENT_SECRET}}' \
GMAIL_PUBSUB_TOPIC=… GMAIL_PUBSUB_SUBSCRIPTION=… \
'GOOGLE_SERVICE_ACCOUNT_JSON=${{secrets.GOOGLE_SERVICE_ACCOUNT_JSON}}'
kuiper deploy --source . -u partner --wait
kuiper dashboard -u partner --enable # inspector login- Schedules, accounts and tokens are stored on the
/datadisk with everything else, so the brief, the check-ins and connected apps survive restarts and redeploys. - New mail needs nothing public. The Gmail watch pulls its Pub/Sub subscription from inside the unit.
- The API and inspector are locked by
FOUNDRY_TOKEN. Set it before the first deploy: Foundry won't listen on a public address without an authorizer.PUSH_URLis a secret because anyone with the topic's URL can post to it. - The computer. Mount a Kuiper sandbox and browser per person with
glove-execution/kuiperin place ofagents/computer/workspace.ts, so a sign-in carries over. See Foundry on Kuiper. - Calls need a public port for the call room, declared in a committed
kuiper.json, andOPENAI_API_KEYas a secret, since the model gateway has no realtime route. The README has the steps. - Usage. Background runs cost tokens like any other. Kuiper's usage page shows them per unit, and a budget can cap them.
Where it differs
- Duet has over 6,000 connectors. Here there are two apps; any app with a hosted MCP server is another file like
calendar.app.ts. - The computer is a Glove working environment with the web, not a full desktop browser, until you swap in a Kuiper browser.
pay_invoice,sign_documentanddelete_fileare stand-ins: they wait for approval like the real thing, then write the ledger and change nothing. Your provider's call goes inagents/partner/actions.ts.- Notifications go to an ntfy-style endpoint. A real app would send its own push.
Sources
- Claude Duet, Anthropic
- Gmail push notifications and the Gmail MCP server, Google
- Anthropic launches Claude Duet, a proactive AI agent, MacRumors
- Claude Duet is Anthropic's answer to Muse, Grok Bot and OpenAI Dots, iPhone in Canada
- Claude Duet, OpenAI dot and Grok Bot compared, all-ai.de