Rebuilds / Claude Duet

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

wakes itasksreads · new mailnotify_personallowedpay_invoiceapproveddelegateresultSchedules07:30 · every 3hYouchat or callGmail · Calendarconnected appsPartnerone thread · memoryGuardyour codeApprovalsend · pay · signComputerfiles · webYour phonepushThe worldpayments, messagesmorning-brief
1 / 9
  1. A schedule in the person's time zone starts a run in their one thread. Nobody opened the app.
  2. Gmail and Calendar are apps installed with your accounts. The brief reads both in one program, gmail.search_threads and calendar.list_events, through Google's MCP servers. Your tokens stay in your store; the agent gets results.
  3. notify_person is the only way to your phone, and everything it sends passes the guard first.
  4. A check-in that finds nothing new sends nothing. The run ends with (nothing new) and your phone stays quiet.
  5. 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.
  6. The brief, the check-ins and your reply are all one conversation, so it knows what “it” is.
  7. pay_invoice has requiresPermission. Foundry records the exact payment and holds the run; it pays only after you approve.
  8. 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.
  9. 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 DuetFoundryWhere
You choose what you hear and how oftenpreferences on the partner instance: brief time, check-in hours, what to hear aboutlib/onboarding.ts
Morning brief and check-insdefineSchedule crons built from those preferences, in the person's time zoneagents/partner/schedules.ts
Quiet unless it mattersnotify_person, the one way to the phoneagents/partner/notify.ts
One threadOne instance and one conversation per person; schedules attach to itlib/onboarding.ts
Text or voicevoiceCall over the same assembled agentagents/partner/call.ts
Its own computerA computer worker each partner owns as a durable subagent, created on the first long jobagents/computer/
Asks before actingGmail's send, and requiresPermission on pay, sign and deleteagents/partner/send.ts, actions.ts
Off limits even with approvalNo tool exists for itagents/partner/actions.ts
Card and ID numbers never leave; health and money only when askedA strict tier in code, a sensitive tier from glove-classifierlib/guard.ts
Your connected appsGmail 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 mailagents/partner/apps/
Never sees passwordsTokens stay in your store; every app operation asks accountSessionslib/accounts.ts
This harness uses 21 primitives across 13 parts. Everything listed is already built; the dashed box is what you write.

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.

lib/onboarding.tstypescript
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:

foundry.application.ts (excerpt)typescript
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:

agents/partner/agent.tstypescript
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.

agents/partner/schedules.tstypescript
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:

agents/partner/notify.tstypescript
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:

agents/partner/send.tstypescript
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.

lib/guard.tstypescript
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.

agents/partner/call.tstypescript
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

terminalbash
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

terminalbash
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 /data disk 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_URL is 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/kuiper in place of agents/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, and OPENAI_API_KEY as 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_document and delete_file are stand-ins: they wait for approval like the real thing, then write the ledger and change nothing. Your provider's call goes in agents/partner/actions.ts.
  • Notifications go to an ntfy-style endpoint. A real app would send its own push.

Sources