Rebuild Meta Muse
Meta's Muse is a personal agent that does the work rather than answering: it sends the email, books the table, fills the form and pays, on a computer of its own, and keeps going after you close the app. You talk to it in its app or on WhatsApp. A separate Sentinel decides what may leave its computer. This page builds the same shape in Foundry, with a real browser for each person on Kuiper, and deploys it there.
Meta Superintelligence Labs announced Muse on 8 September 2026; it runs on Meta's Muse Spark model. Here Muse runs on Claude Sonnet 5.5 and the Sentinel on Claude Haiku 5.5, through Kuiper's model gateway once deployed.
An independent example built from Meta's announcement and a hands-on review (sources at the end). It is not affiliated with or endorsed by Meta, and it is not how Muse is built. The code is in examples/foundry-personal-agent, and pnpm verify runs a day of it offline, against local servers, a local stand-in for Kuiper's browser API and sample sites.
What Muse does
- You message it like a person, in the Muse app or directly in WhatsApp. You set its name, avatar and communication style.
- It connects to your email, calendar and banks (through Plaid), and to partners such as Shopify, Walmart and Instacart. Read and write access are granted separately for each app.
- It has a computer of its own, Muse Secure VM: a dedicated, isolated cloud VM with a browser you can watch. It fills forms and can negotiate for you.
- A Sentinel guards that computer. It runs on the same machine, isolated from Muse, and nothing Muse does reaches the internet unless the Sentinel approves it. The Sentinel asks you when needed.
- It can't see your passwords or payment methods. Credentials for the services you connect are stored in its VM; reusing your logins through 1Password is “coming soon”. It checks out with Stripe's Link, whose agent wallet issues single-use cards, so your real card number stays hidden.
- It checks with you before sending an email or buying something.
- It plans. It turns a goal into a personal plan, and keeps working after you close the app, coming back when something changes or it needs your OK.
- It remembers what matters and suggests things unprompted, forgets what you tell it to, and keeps a complete log of everything it has done and plans to do.
How work moves
- Rosa's Muse is one instance with two conversations,
appandwhatsapp, and one memory. What she says on WhatsApp it knows in the app. WhatsApp is a Foundry app: her messages come in on Muse's number, and answers go out on a route that only reaches her. - Gmail and Calendar are Foundry apps installed with her accounts. Muse reads them in code, one program for both. Gmail is read only, so the app hides every write and Muse has no send tool at all, until she gives it send access: one write to the installation, no deploy.
- To book dinner it drives its own browser, a real Chrome on Kuiper that lasts between runs, in one script: open the page, read it, fill the form, click, check the result.
- The browser's guard calls the Sentinel for every request the browser makes, outside the page and outside Muse's model. Reading the restaurant's page plainly serves what Rosa asked, so it loads.
- The page hides an instruction to send Rosa's email address to another site. Even if Muse followed it, the Sentinel never read the page: it sees a request carrying an email address to a site she never allowed, and blocks it.
- Booking posts a form to a site she hasn't allowed, so the Sentinel says ask. Muse calls
request_access, which waits for Rosa only because the Sentinel asked. Her yes lets Muse send to that site; nothing else can. Reading a site and sending to it are separate grants. - The site wants her signed in. Muse calls
browser.handOff, and host code sends Rosa Kuiper's live view of the browser on WhatsApp. She signs in there; Muse never sees the link or her password, and her profile keeps the login for next time. - Gmail's send asks for each email;
buyasks for each purchase. Then host code types a one-time card into the checkout, in a tab Muse never sees. Muse sees the last four digits. - A subscriber writes every tool call from Foundry's side of the run, the Sentinel writes every decision, and the browser's host code writes each hand-off. What Muse plans comes from Foundry too: its schedules, like the fare it checks every six hours, the open steps of its plans, and the approvals waiting on Rosa.
Each feature, as a Foundry primitive
| Meta Muse | Foundry | Where |
|---|---|---|
| One Muse, in the app and on WhatsApp | One instance per person; conversations app and whatsapp; one memory | lib/people.ts |
| Its name, avatar and style | The instance's name and context.persona, from the person; the prompt reads them | lib/people.ts, agents/muse/prompt.ts |
| A Foundry app with a transmission each way; a playbook per person filtered to their number | agents/muse/apps/whatsapp.app.ts | |
| Read and write access per app | The installation's config.access: the app's tool policy hides writes, the send binding is off | lib/connect.ts, lib/mcp-servers.ts |
| Its own computer, with a browser you can watch | A real Chrome per person on Kuiper, mounted with glove-execution/kuiper, kept between runs, recorded; its view-only live view kept for her app | agents/muse/browser.ts |
| The Sentinel | The browser's guard, on every request it makes, with rules in code and its own model from glove-classifier; reading a site and sending to it are separate grants | agents/muse/sentinel.ts |
| The Sentinel asks you | request_access, which waits for approval only when the Sentinel said ask | agents/muse/sentinel.ts |
| It can't see your passwords | browser.handOff sends her Kuiper's live view; she signs in, and her browser profile keeps the login | agents/muse/browser.ts |
| Asks before sending or buying | Gmail's send and buy have requiresPermission | agents/muse/tools.ts |
| Single-use cards from Link | buy gets a one-time card, and host code types it into the checkout in a tab Muse never sees | lib/payments.ts |
| Turns a goal into a plan | Goals at instance scope, one goal per plan, one item per step | lib/memory.ts |
| Keeps working, comes back when something changes | glove_foundry_schedule and glove_foundry_sleep; new mail through the Gmail watch | agents/muse/playbook.ts |
| Remembers, suggests, and forgets when told | defineMemory; a daily suggest schedule; glove_context_set and glove_context_unset | agents/muse/agent.ts, agents/muse/muse.memory.ts |
| A log of what it did and plans | A subscriber, the Sentinel and the browser's host code write what it did; schedules, open plan steps and pending approvals are its plans | lib/activity.ts |
- Your account store and OAuth callback
- The Sentinel's rules
- The card issuer and the WhatsApp webhook
- The scripted model, and a stand-in for Kuiper's browser API with sample sites, for offline runs
1. One Muse per person, in two places
Each person gets one Muse in their own workspace. The name they give it, who they are, their WhatsApp number, how they want it to talk and the sites they have allowed are its data. The prompt says You are ${name} and adds their style, the way the Dots rebuild names each dot. It has two conversations and one memory, so what Rosa says on WhatsApp it knows in the app:
export function museFor(person: Person): CreateAgentInstanceOptions {
return {
id: museId(person.id),
workspaceId: person.id,
name: person.agentName ?? "Muse",
description: `${person.name}'s personal agent`,
context: {
name: person.name,
phone: person.phone,
persona: { avatar: person.avatar, style: person.style }, // your app shows the avatar
sentinel: { read: [], write: [], blocked: [] },
},
};
}
export function channel(museId: string, key: "app" | "whatsapp"): CreateConversationOptions {
return { id: `${museId}-${key}`, title: key === "app" ? "Muse" : "WhatsApp", context: { conversationKey: key } };
}WhatsApp is a Foundry app with a transmission each way. Everyone messages Muse's one number, so every message arrives on one shared route, and each person's playbook only takes messages from their own number. Answers go out on a route made for that person: its config fixes the recipient, so Muse can answer its person on WhatsApp but cannot message anyone else there.
export function whatsappFrom(agentId: string, person: { id: string; phone: string }) {
return composePlaybook({
name: "whatsapp",
transmission: whatsapp,
match: { event: message, routes: [whatsappIn], predicate: { definition: fromPerson, parameters: { phone: person.phone } } },
directives: [{ action: answer, instruction: "Answer on WhatsApp with the WhatsApp send tool. Keep it short, like a text." }],
applications: [whatsappApp],
outbound: [{ route: whatsappFor(agentId, person).out, application: whatsappApp }],
});
}Meta posts messages to a webhook and wants a verify-token handshake and a signature on every delivery. The web app's /webhooks/whatsapp route does both (lib/whatsapp-webhook.ts) and forwards each text message to Foundry'swhatsapp-in route on loopback, in the same unit.
2. Apps at the access she chooses
Gmail and Calendar are Foundry apps installed with her accounts, as in the other rebuilds: Google's MCP servers for reads, which Muse calls in code, the Gmail transmission for sending, and a watch for new mail. What Muse adds is the access she picks for each app, kept on the installation:
export function byAccess(policy: { reads: string[]; free?: string[] }) {
return (tool, { config }) => {
if (tool.readOnly || policy.reads.includes(tool.name)) return "allow";
if (config.access === "read") return "hide"; // Muse never sees it
return policy.free?.includes(tool.name) || config.alwaysAllow.includes(tool.name) ? "allow" : "ask";
};
}Gmail's send tool comes from its transmission, not the MCP server, so read access removes it in configure too, and the send binding is off, so Foundry would refuse the delivery anyway. Changing access is a write, and so is disconnecting:
export async function setAccess(foundry, agentId, account, access: "read" | "act") {
await foundry.install(agentId, installationFor(account, access)); // the tools she allows
if (account.app === "gmail") await foundry.putBinding(sendBinding(agentId, account.id, access === "act")); // the right to send
}3. Its own browser on Kuiper
Muse's browser is a real Chrome from Kuiper, mounted in configure with kuiperBrowser from glove-execution/kuiper and kuiper.browsers from @kuiper-sh/sdk. Muse drives it with execute_browser, one script for a whole errand. A Kuiper session belongs to the project, not the process, so the instance keeps two IDs as data, her profile and the session in use, and the next run reconnects to the same browser. It also keeps the session's view-only live view for her app's Watch link; the prompt never reads it. Muse's tabs add MuseRebuild/1.0 (+…) to the browser's own user agent, so sites can tell an agent is there; put your agent's name in AGENT_TOKEN:
export function mountMuseBrowser(agent: IGloveRunnable, ctx: FoundryExecutionContext) {
const gateway = kuiper(); // @kuiper-sh/sdk; no key, no browser
if (!gateway) return;
const saved = browserOf(ctx.agentInstance.context); // { profileId, sessionId }
const adapter = kuiperBrowser({
browsers: gateway.browsers,
sessionIds: saved.sessionId ? [saved.sessionId] : [], // the browser it kept
session: {
profileId: saved.profileId, persistProfile: true, // her logins, saved when it closes
inactivityTimeout: 10 * 60_000,
kuiper: { onInactive: "hibernate", record: true }, // sleeps when idle; recorded for a replay
},
cleanup: "keep",
guard: sentinelFor(ctx).guard, // every request it makes
connect: async (url) => { // its tabs say they are an agent
const browser = await chromium.connectOverCDP(url);
await identify(browser); // the real user agent + AGENT_TOKEN
return browser;
},
onHandOff: (handOff) => sendLiveView(ctx, handOff), // to her on WhatsApp, never to Muse
onOpen: (session) => remember(ctx, {
sessionId: session.id, profileId: session.profileId,
watchUrl: session.debugUrl, // view-only, for her app's Watch link
}),
});
let close = () => adapter.close();
ctx.onCleanup(() => close());
const mounted = mountBrowser(agent, { adapter });
close = () => mounted.close();
}A script Muse writes for the booking. The browser's requests go through the Sentinel as it runs:
const b = browser.sessions()[0] ?? browser.open({});
browser.navigate({ sessionId: b.id, url: "https://bistro-luna.example/reserve" });
browser.interact({ sessionId: b.id, command: { op: "fill", target: { by: "label", value: "Party size" }, value: "4" } });
browser.interact({ sessionId: b.id, command: { op: "fill", target: { by: "label", value: "Time" }, value: "Friday 19:00" } });
const sent = browser.interact({ sessionId: b.id, command: { op: "click", target: { by: "role", role: "button", name: "Book" } } });
const page = browser.observe({ sessionId: b.id, mode: "text" });
({ url: sent.url, blocked: sent.blocked, page: page.content })4. The Sentinel
glove-execution/kuiper calls the guard for every request the browser makes while Muse is connected, outside the page and outside Muse's model, before it leaves. A page loading, and anything that could write, gets a decision; a page's images and scripts only get the quick checks. The Sentinel behind it is kept apart from Muse on purpose. It never sees Muse's conversation, its reasoning or the pages it read, so a page that tells Muse what to do cannot talk the Sentinel round. It sees the request, what the person asked for in this run, and the sites they allowed:
readonly guard = async ({ url, method, resourceType, navigation }: KuiperBrowserRequest) => {
const writes = method !== "GET" && method !== "HEAD" || resourceType === "websocket";
if (navigation || writes) return (await this.decide(url, resourceType === "websocket" ? "WEBSOCKET" : method)).verdict === "allow";
return this.quickCheck(url, method); // blocked sites, personal data in the URL
};
async decide(url: URL, method: string): Promise<Decision> {
const rules = await this.rules(); // { read, write, blocked }, by host
const personal = personalDataIn(url);
const decided = rules.blocked.includes(url.host) ? { verdict: "block", reason: "you blocked this site" }
: personal && !rules.write.includes(url.host) ? { verdict: "block", reason: `it would send ${personal} to a site you haven't allowed` }
: granted(rules, url.host, method) ? { verdict: "allow", reason: "you allowed this" } // GET and HEAD need read; the rest need write
: await judge(method, url, this.asked(), rules); // its own model: allow, ask or block
await logActivity({ person: this.ctx.workspaceId, kind: "sentinel", what: `${method} ${url.host}${url.pathname}`, outcome: decided.verdict });
return decided;
}judge is a separate small model answering one typed question through llmClassifier from glove-classifier. A doubtful “allow” becomes “ask”. Without a model, reads pass and writes ask.
A blocked page load fails with blocked, and a blocked form post shows in the click's result. The script learns that something was blocked, never why. So Muse calls request_access. It reads what the Sentinel decided in this run: a blocked request stays blocked and nobody is asked; an “ask” waits for Rosa's approval, and her yes is the only thing that adds a site. The approval says what it adds, reading the site or sending to it, and that must match what was stopped. Letting Muse read a site doesn't let it post there, and the Sentinel's model never lets a send through to a site without a write grant: that asks Rosa.
requiresPermission: (input) => sentinel.decisionFor(hostOf(input.site))?.verdict === "ask",5. She signs in herself
When a site wants Rosa signed in, or shows a CAPTCHA she chooses to do, Muse calls browser.handOff and stops. Host code sends her Kuiper's live view of that browser on WhatsApp, a link that takes clicks and keys, so the link never enters Muse's context. She signs in there; Muse never sees her password. The cookies stay in the browser, which waits (and hibernates if she takes a while). When she says she's done, the next run reconnects to the same session and books. When Muse closes it, her profile keeps the login for next time.
Amazon barred Meta's Muse, saying it didn't identify itself. Here, when a site turns agents away, Muse stops and tells the person. Its prompt says: “If a site says it doesn't allow automated agents, stop and tell Rosa; don't look for another way.”
async function sendLiveView(ctx, handOff: KuiperHandOff) {
const name = ctx.agentInstance.name ?? "Muse";
const text = `${name} needs you for one step: ${handOff.reason}. This link opens ${name}'s browser for you (it is yours only): ${handOff.url}`;
await sendText(process.env.WHATSAPP_TOKEN, { phoneNumberId, to: ctx.agentInstance.context.phone, text });
}6. Sending and paying wait for her
Gmail's send asks for each email. buy asks for each purchase, with the checkout open in Muse's browser. Then it gets a one-time card for that merchant and amount, and host code types it into the checkout in a new tab and submits it. The tab closes, so no page Muse can look at, now or by going back, holds the number. The Sentinel lets that checkout through for this run only. Muse checks out with Stripe's Link, whose agent wallet issues single-use cards; here any issuer of single-use virtual cards fits:
{
name: "buy",
requiresPermission: true,
async do({ sessionId, ...purchase }) {
const card = await issuer().issue(ctx.workspaceId, purchase); // one charge, one merchant, one amount
sentinelFor(ctx).purchaseAt(purchase.site); // she approved paying here
const answered = await payAtCheckout(kuiper().browsers, sessionId, purchase, card);
return { status: "success", data: { submitted: true, card: `one-time card ending ${card.number.slice(-4)}`, page: answered.page } };
},
}7. Plans, long work, suggestions and forgetting
- A goal becomes a plan. Plans are Foundry goals at instance scope, so a plan made on WhatsApp is the same plan in the app: one goal per plan, one item per step. Asked to get Rosa ready for a half marathon, Muse starts a plan with
glove_goal_start; it marks steps done withglove_goal_updateand revises the plan when things change. - It keeps going. Asked to watch a train fare, Muse sets its own check with
glove_foundry_scheduleevery six hours and messages Rosa on WhatsApp when the price drops. The schedule is Foundry's, so it survives restarts and shows up in the log as a plan. - News comes to her. The Gmail watch passes new mail into the app conversation. When Leo asks to move dinner, Muse tells Rosa on WhatsApp and offers to change the booking.
- It suggests. Its prompt asks it to suggest something it remembers when that would help, and a daily
suggestschedule on the instance runs a short pass over what it knows and its plans. - It forgets when told. Facts live in Glove memory as context entries.
glove_context_setkeeps one;glove_context_unsetremoves it, and the next answer no longer knows. Forgetting removes the fact from memory, and the log never held it: for memory tools it records only that memory changed. The messages where it came up stay: in the conversation, and what Muse sent in the log.
8. A log it can't leave anything out of
If the model wrote its own log, it could leave an entry out. Here the model writes none of it. A subscriber records every tool call from Foundry's side of the run, the Sentinel records every decision, and the browser's host code records each browser it opens and each hand-off. What Muse plans is Foundry's own data: its schedules and wake-ups, the open steps of its plans, and the approvals waiting on her.
export default defineSubscriber({
description: "Muse's log of everything it does",
create: ({ workspaceId }) => {
const inputs = new Map();
return {
async record(type, data) {
if (type === "tool_use") return void inputs.set(data.id, data.input);
if (type !== "tool_use_result") return;
const memory = data.tool_name.startsWith("glove_context_"); // a fact she has it forget stays out
await logActivity({ person: workspaceId, kind: "tool", what: data.tool_name, outcome: data.result.status,
detail: memory ? "memory changed" : JSON.stringify(inputs.get(data.call_id)) });
},
};
},
});9. Run it
pnpm --filter glove-foundry-personal-agent verify
1. Rosa's Muse has gmail (read), calendar (read), whatsapp; the Gmail watch is connected
2. in code over Gmail and Calendar: Friday: dentist at 10, design review at 2, dinner at 7 with Leo, Priya and Sam. …
3. read only: "I can read your mail but not send it. Give me send access for Gmail in the app and I'll send it."
With send access, after approval: Sent Leo a note: still on for Friday at 7.
4. told on WhatsApp ("Noted: Priya is vegetarian. I'll plan around it."), asked in the app: Priya is vegetarian.
5. the Sentinel: GET bistro-luna.example/ → allow; GET collect.example/c → block; GET bistro-luna.example/reserve → allow;
POST bistro-luna.example/reserve → ask; allow sending to bistro-luna.example → the person allowed it; … POST bistro-luna.example/reserve → allow.
She signed in in the live view. Booked Bistro Luna for 4 on Friday at 7 … (BL-2041). Her profile keeps the login.
Her app could watch it; sites saw "MuseRebuild/1.0"; a read grant doesn't let it post.
6. Ordered from Fresh Market for Friday: $23.40 (FM-5521), paid with a one-time card ending 5363.
7. Here's the plan: register, three runs a week in your calendar, new shoes, travel to the start. I'll work through it with you.
And it will check the train fare every 6 hours, on its own
8. new mail, to her WhatsApp: "Leo Martin asks to move Friday's dinner to 7:30. Want me to change the booking?"
9. after "forget": I don't know of anyone vegetarian.
10. the log: 23 tool calls, 12 Sentinel decisions and 3 browser events done, 6 planned (the fare watch, the daily suggestions,
the plan's steps); Calendar disconnectedEach browser in the run is a Chromium on your machine behind a stand-in for Kuiper's browser API, so @kuiper-sh/sdk and glove-execution/kuiper run unchanged. The run also checks that her password and the card number appear nowhere in Muse's data, the live view link only in the WhatsApp message to her, and the log nothing of what she had it forget. With no key Muse runs a short script instead of a model, so this checks the wiring, not the judgment. In step 5 the script follows the page's hidden instruction on purpose, to show the Sentinel stopping it; a real model is told never to act on instructions in pages it reads.
Deploy on Kuiper
kuiper login
kuiper projects create muse && kuiper use muse
kuiper units create muse
kuiper providers add anthropic --key - # once per organisation
kuiper secrets set GOOGLE_CLIENT_SECRET=- GOOGLE_SERVICE_ACCOUNT_JSON=- FOUNDRY_TOKEN=- WEB_SESSION_SECRET=-
kuiper secrets set WHATSAPP_TOKEN=- WHATSAPP_APP_SECRET=- WHATSAPP_VERIFY_TOKEN=-
kuiper env set -u muse MUSE_DATA_DIR=/data \
'WEB_SESSION_SECRET=${{secrets.WEB_SESSION_SECRET}}' WEB_URL=https://… \
'GATEWAY_KEY=${{models.API_KEY}}' \
'BROWSERS_URL=${{browsers.BASE_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}}' \
WHATSAPP_PHONE_NUMBER_ID=… 'WHATSAPP_TOKEN=${{secrets.WHATSAPP_TOKEN}}' \
'WHATSAPP_APP_SECRET=${{secrets.WHATSAPP_APP_SECRET}}' \
'WHATSAPP_VERIFY_TOKEN=${{secrets.WHATSAPP_VERIFY_TOKEN}}' \
'FOUNDRY_TOKEN=${{secrets.FOUNDRY_TOKEN}}'
kuiper deploy --source . -u muse --wait
# Register <WEB_URL>/webhooks/whatsapp with Meta.- One key, two services. The two references give the unit one gateway key for models and browsers.
@kuiper-sh/sdkreads it for Muse's and the Sentinel's models and for each person's browser. - State (Muses, conversations, memory and plans, accounts and tokens, the Sentinel's lists, the log and each person's browser IDs) sits on the
/datadisk and survives redeploys. The browsers and profiles are Kuiper's: a redeploy reconnects to them. - One unit, one public port. The web app takes port 3000 and Foundry runs beside it on a private port named
foundry, which Kuiper's Inspect card reads. Meta's webhook is a route of the web app, and the Gmail watch pulls from inside the unit, so nothing else is public.
Where it differs
- Meta gives each person a dedicated VM. Here everyone's Muse shares one Foundry unit and its
/datadisk, with memory and the log kept per person; only the browser and its profile are each person's own. - Muse's VM is more than a browser. Here the browser is the whole computer; to run code too, add a Kuiper sandbox with
kuiperSandboxfromglove-execution/kuiper. - In Muse, nothing reaches the internet unless the Sentinel approves. Here the Sentinel guards the browser only; app reads and sends go through Foundry's apps, held to the access she chose and to approvals.
- The Sentinel sees what the browser sends while Muse is connected. Redirects follow the request it allowed, and while Rosa has the live view the browser is hers.
- Meta's Sentinel is an agent on the same machine that asks you when needed. Here it judges one request at a time, and its question reaches you through Muse's
request_access. Only your approval changes what is allowed. - You can change an app's access or disconnect it at any time here (
setAccess,disconnect); both are writes, not deploys. - The checkout's card fields are found by their standard autocomplete names; a checkout that keeps them in its payment provider's frame needs that frame here.
- This rebuild doesn't have Muse Charm, Realtime Avatar, phone calling, bank access through Plaid or an agent email address. Meta's glasses support is still rolling out.
- Phone calls: Muse calls businesses for you (in testing). Foundry's
voiceCallover a phone provider could, opening by saying it is an AI agent calling for the person. - Its own email address: a Kuiper inbox with a tagged address per person could carry it as a Foundry app.
- Phone calls: Muse calls businesses for you (in testing). Foundry's
Sources
- Introducing Muse, Meta
- Meta Muse review, eesel
- WhatsApp Cloud API: get started, Meta for Developers