Cindy Zhu.
← all free guides
Building & apps Claude

Build a Jarvis that briefs you every morning

Hey, it's Cindy ๐ŸŒฑ This is the Jarvis I actually wake up to: a dashboard that reads my whole day out loud in a British voice before I've opened a single app. Three layers, and the middle one is where most builds fall over.

โ†“ Download the starter kit

๐Ÿ—บ The three layers. The visual: hand Claude Code a reference image and it builds the front end. The backend: paste the build manual, then authorise your own connectors so it reads your real calendar, inbox and notes. The voice: Fish Audio reads the brief out loud. Layer two is the one people skip, and it's the only reason a Jarvis is useful instead of just pretty.

layer one, the visual

Get the dashboard on your screen ๐Ÿ–ฅ

A dense blue heads-up-display desktop: a large glowing central ring assembly, CPU and RAM gauges, a world map, a calendar, a weather panel and rows of small readouts, the kind of Jarvis command-center look to hand Claude as a reference image.
This is the reference I handed Claude. Grab any HUD you like off Pinterest, the prompt works the same.
  1. ๐Ÿ’ป Install Claude Code Run curl -fsSL https://claude.ai/install.sh | bash, then claude. That's the native installer the setup docs recommend. It is not on the Free plan.
  2. ๐Ÿ–ผ Give it the picture Drag your reference image onto the Claude Code terminal, paste it, or point at it with @reference.jpg.
  3. ๐Ÿ” Run the build loop Paste the prompt below. It builds the interface, screenshots its own work, compares it to your reference, and closes the gaps.
๐Ÿ–ฅ paste into Claude Code, with your reference image attached
Here's a reference image of the dashboard vibe I want (attached). Build it as ONE self-contained dashboard.html file (Canvas plus CSS, no external libraries). Near-black navy background, a glowing central core with orbital rings and rotating dashed HUD arcs, and a heads-up layout: top-left a JARVIS title, subtitle and today's date; top-right a live ticking clock and a connector status list (green dot online, amber blinking offline); a streaming activity log down the left that reveals one line at a time; a stats panel on the right with thin glowing bars; a calendar and a weather panel; big figures across the bottom; and a bottom bar with a glowing "BRIEF ME" button.

Read ALL the numbers and text from a separate jarvis_data.js that sets window.JARVIS_DATA, so I only edit that file day to day. Save brief.mp3 alongside dashboard.html, add a hidden audio element for it, and make clicking BRIEF ME play it and pulse the core while it speaks.

Make it look expensive and responsive. Then take a screenshot of what you built, compare it to my reference image, list the differences, and fix them. Repeat until it matches.
โณ Two honest things. The self-correction only works if Claude can see its own work, so give it a way to screenshot: Claude for Chrome, the Chrome DevTools MCP, Claude Code's built-in browser, or a headless browser from the terminal. Without one, you're the one checking each pass, and it takes two or three. And what you get here is a shell reading static values from jarvis_data.js. It's beautiful and it knows nothing. Layer two is what makes the numbers real.

make it yours

Tell Claude who you are ๐Ÿง 

This single file is the difference between your Jarvis and a generic bot. It's why yours knows a slipped deadline on project two matters more than a full inbox. In your project folder, ask Claude Code to create a CLAUDE.md, then make it specific. Make a priorities note while you're here, even five ranked bullets, because the brief ranks your day from it and can't invent one.

๐Ÿ“ your CLAUDE.md, fill in the brackets
# Who I am
I'm [name], I [what you do]. I care most about [your #1 goal right now].

# What I'm working on this month
- [project 1], [project 2], [project 3]

# My priorities, ranked
1. [top priority]  2. [next]  3. [next]

# My tone
[e.g. warm, direct, no corporate filler]

# Hard rules
- Always draft, never send. Never take irreversible actions without asking.
- Only use the apps I've connected.

layer two, the backend

Paste the build manual ๐Ÿ“‹

Don't read this one, just copy the whole block. It's the architecture behind my Jarvis, and it saves itself as a skill you can trigger by phrase.

๐Ÿ“‹ the build manual, paste into Claude Code
You are my personal assistant, my own Jarvis. First, read my CLAUDE.md so you know who I am, my projects, my priorities and my tone. From now on, act as a proactive chief of staff for me, not a generic chatbot.

Keep this skill fully self-contained: list every source to pull and exactly how to format the brief, because when it runs on a schedule it has no memory of our chats.

Build me a "morning brief" routine, and save it as a reusable skill I can trigger by phrase (like "run my morning brief", "brief me", or "what's on my plate"). When I trigger it, do this:

1. CALENDAR: read today's events from my Google Calendar. Flag anything that needs prep, and any conflicts or gaps.
2. INBOX: scan my Gmail from the last 24 hours. Group it into needs-a-reply / FYI / ignore. For the needs-a-reply group, draft short replies in my tone and leave them in Drafts. Never send.
3. PRIORITIES + TASKS: read my priorities and task list (from my Notion or my priorities note), and pick the 3 things that actually matter most today, ranked, with a one-line why for each.
4. ONE THING TO KNOW: surface a single important signal, a deadline, a number that moved, a follow-up I'm forgetting.

Then write it as 6 to 8 short spoken lines, in my voice, warm and direct, no filler. END with the single most important question: "What should I handle first?"

Write the brief in [your language]. Save it to brief.txt and to a Notion page. Then rewrite jarvis_data.js so the dashboard shows today's real numbers and a one-line activity log per source you read, and mark each connector online or offline based on whether the call actually succeeded. Then read the brief out loud through my Fish Audio voice.

If a source is empty or a connector is down, say so plainly in the brief and mark it offline on the dashboard. Never fill a gap with a plausible guess.

Rules, always: you draft, I approve. Never send email, post, delete, or take any irreversible action on your own. Only pull from the apps I've connected.

So you're not pasting blind, this is the shape of what comes back:

๐Ÿ“… Calendar Today's events, what needs prep, and where the gaps and clashes are.
๐Ÿ“ฅ Inbox Last 24 hours sorted into needs-a-reply, FYI and ignore, with drafts waiting.
๐ŸŽฏ Priorities The three things that actually matter today, ranked, each with a one-line why.
๐Ÿ’ก One thing to know A single signal you would otherwise have missed.

The skill saves itself to ~/.claude/skills/<name>/SKILL.md. Ask Claude Code for more the same way, and it reaches for them automatically when a request matches:

๐ŸŒ… "run my morning brief" The full routine above.
โœ๏ธ "draft my replies" Triages the inbox and drafts responses in your tone.
๐Ÿ’ฌ "answer customer queries" Reads your FAQs, drafts support replies, escalates anything not covered.
๐Ÿ“Š "how are my numbers" Pulls your revenue or content stats into a quick read.

this is the part people skip

Authorise your connectors ๐Ÿ”Œ

A connector is an authorised link between the brain and a service you already use. It's the difference between Claude talking about your calendar and Claude reading it. Nobody can do this step for you. The prompt above is portable; your authorisations are yours, and until you grant them your beautiful dashboard is a screensaver.

  1. ๐ŸŒ Authorise on the web, once Go to claude.ai/customize/connectors. In the sidebar that's Customize, then Connectors. Connect one, approve it on that service's own consent screen.
  2. ๐Ÿ” The terminal inherits it You do not set connectors up twice. If you signed into Claude Code with your claude.ai account, connectors you added on the web are automatically available in the terminal. Run /mcp to see them. Ones you've never used hide behind a "Show unused connectors" row.
  3. โœ… Test one read before you trust it Ask for one read-only fact, then check it against the source with your own eyes. "How many rows in my tasks database are marked done?", then open the database and count. If it matches, move to the next connector.
  4. โž• Then add the next one One connector, one verified read, then the next. Slower, and the only route to ever reading the 8am brief without silently re-checking it.
โš ๏ธ Do not try to add Gmail or Calendar from the terminal. Some Anthropic-hosted connectors, Gmail, Google Calendar and Microsoft 365 among them, don't support local OAuth from Claude Code, because the upstream identity provider only accepts the redirect URL claude.ai registered. claude mcp add will fail with "is Anthropic-hosted and doesn't support local OAuth". Connect those on the web and let the CLI inherit them. claude mcp add --transport http NAME https://the-mcp-url/mcp then claude mcp login NAME is for third-party servers that publish their own MCP URL.
๐Ÿ•ต๏ธ If a connector you definitely added is missing, check your auth first. Connectors are only fetched when your active login is a claude.ai subscription. They are silently not loaded when ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN or apiKeyHelper is active, when a third-party provider like Bedrock is in play, or when CLAUDE_CODE_OAUTH_TOKEN holds a long-lived token. Run /status to see which auth is actually live. An ANTHROPIC_API_KEY left in your shell profile from an old experiment will hide every connector you own, with no error.

What each one can and cannot do. Worth knowing before you design a routine around it:

๐Ÿ“… Google Calendar Reads events and shared calendars, creates and updates them, finds mutual availability. It cannot see calendars your own Google account can't see, and it has no memory between runs, it re-queries every time.
๐Ÿ“ฅ Gmail Searches and reads with natural language, drafts, and sends, replies and forwards. It cannot read attachment contents, only metadata. If your workflow needs the PDF someone sent you, this is not the tool.
๐Ÿ“ Notion Searches the workspace, fetches pages, queries databases, creates and updates pages. It cannot see anything you haven't shared with the integration, and it rate-limits per workspace.
๐Ÿ” All of them Claude inherits your permissions and can only see and do what you already can. A "the page isn't there" answer is nearly always a sharing problem, not a model problem.
๐Ÿšจ The Gmail connector can send. Read that twice. Drafts-only is a discipline you impose, not a wall the tool provides. Approval prompts are the documented default, but a scheduled cloud routine has no approval prompt at all. So the drafts-only rule has to be written into the skill, written into the routine prompt, and the send capability left out of anything unattended. You'll also find older Anthropic doc pages claiming Claude "cannot send or modify emails". Those are stale, the live tool surface exposes send, reply and forward, and assuming the read-only version is a genuinely unsafe thing to build on.
๐Ÿ”ง The failure that will cost you an evening. A connector says "needs auth". You reconnect it on the web, watch the green tick, come back to the terminal, and it still says needs auth. The connection is fine. Claude Code is reading a stale local cache at ~/.claude/mcp-needs-auth-cache.json, keyed by connector display name, and a successful web re-login doesn't always clear the entry. Back the file up, remove that connector's key (or delete the file, it rebuilds), then fully quit and reopen Claude Code, Cmd+Q rather than closing the window. This is internal client state, not a documented API, so re-check the filename if it moves. The durable lesson is the symptom: when the web says connected and the client says needs auth, suspect a stale cache before you suspect your account.

layer three, the voice

Give it the British voice ๐ŸŽ™

This is what makes it feel like Jarvis. Sign up at Fish Audio, then:

  1. ๐Ÿ‡ฌ๐Ÿ‡ง Design the voice from a text prompt Voice Design builds a voice from a description like "calm British butler, warm, measured", no recording needed. You can also clone your own from about 10 seconds of clean audio. Don't clone a real celebrity.
  2. ๐Ÿ†” Copy the reference ID Open your voice and take the 32-character ID from its URL, fish.audio/m/<id>. That's what tells the API which voice to speak in.
  3. ๐Ÿ”‘ Put your API key in a .env file Create a .env in your project with FISH_API_KEY=your-key, and add .env to your .gitignore. This keeps the key out of your code so it can't leak when you share the folder.
  4. ๐ŸŽญ Add emotion so it isn't flat Drop bracket tags into the text: [calm] Your 3pm is confirmed. [excited] The launch numbers are in.
๐Ÿ”ง paste into Claude Code, wire up the voice with a fallback chain
Write me a speak script that turns text into audio and plays it, with three tiers so it never goes silent:

1. Fish Audio. POST https://api.fish.audio/v1/tts, model sent as an HTTP header (model: s2.1-pro-free), reference_id and text in the body, API key read from FISH_API_KEY in my .env file. Generate the speech in [your language].
2. If that fails, fall back to edge-tts with the voice en-GB-RyanNeural at --rate=-4%.
3. If that fails, fall back to the built-in system voice (say -v Daniel on macOS).

Two things that matter: after the API call, check the response is ACTUALLY audio before playing it (check the bytes with `file`, not just the HTTP status), and check the exit code and that the output file exists before moving on, so a failure falls through to the next tier instead of silently producing nothing.

Print which tier spoke, like "voice: fish-audio" or "voice: edge-tts fallback", so I can see when my main voice quietly stopped working.

Save the audio as brief.mp3 in the same folder as dashboard.html so the BRIEF ME button finds it.
๐Ÿ The bug that eats an hour, and it's why the fallback chain is written that way. python3 is not one program. Homebrew's /opt/homebrew/bin/python3 usually comes first on your PATH and may not have edge_tts installed, while /usr/bin/python3 does. So a script calling bare python3 -m edge_tts fails in the script and works when you type it in the terminal. If the call is wrapped without a return-code check, it fails completely silently: no audio, no error, no clue. Find the right interpreter with for p in $(which -a python3); do echo -n "$p: "; $p -c "import edge_tts; print('ok')" 2>&1 | head -1; done, then hardcode that absolute path, or make a dedicated virtualenv and remove the ambiguity permanently.
๐Ÿ’ธ The honest cost picture. s2.1-pro-free is $0.00 per million UTF-8 bytes under Fish's fair-use policy, with no hard character cap. If that changes, the paid rate is $15 per million UTF-8 bytes, roughly 180,000 English words or 12 hours of speech, and there are no subscription fees or monthly minimums for API access. Four things worth knowing: a typo in the model header silently falls back to the paid s2.1-pro, so check that string; free-tier requests may be used to improve model quality; if your product is over $1M ARR Fish asks you to contact them first; and because billing is per UTF-8 byte, a Chinese character costs about three times an English one. It speaks 83 languages, so your Jarvis can brief you in any of them. Voice Design itself costs $0.01 per request and the web app gates it behind the Plus tier.

wiring the two together

Make the dashboard actually live ๐Ÿ”—

Right now you have a front end reading a static file, and a skill that can write that file. Connecting them is one instruction, and then one decision about how you open the page.

๐Ÿ”— paste into Claude Code, close the loop
Every time my morning brief skill runs, have it rewrite jarvis_data.js from what it actually read: today's date, the event count and next event, the unread and needs-a-reply counts, my top 3 priorities, and one activity-log line per source. Set each connector's status to online only if that call actually returned data, otherwise offline. Never write a number the run didn't actually retrieve. Then serve the folder at http://localhost:8787 with a tiny local server, and give me a one-line script that starts the server and opens the page.
๐ŸŒ Open it at localhost, not by double-clicking the file, and here's the real reason. From a file:// page, fetch('/ask') resolves to file:///ask, which isn't a route, it's a path on disk that doesn't exist. You can't fix it with an absolute URL either, because file: URLs get an opaque origin, and the browser sends Origin: null or refuses the request outright. No header on your server can fix that. Common myth worth ignoring: people say the mic fails because file:// isn't a secure context. Not true, the spec explicitly treats file: as potentially trustworthy. The fetch is the blocker, not the mic. And http://localhost is treated as secure, so everything works there.
๐Ÿ”‡ Autoplay. Browsers block script-initiated play() without a user gesture, and a blocked call rejects rather than throwing. Your BRIEF ME button satisfies the gesture, which is why it works. Handle the rejection anyway, because a scheduled brief has no gesture behind it. Check the server is alive with curl -s -o /dev/null -w "%{http_code}" http://localhost:8787/, expect 200.

before you wake up

Run it every morning โฐ

The choice hinges on one question: does your brief need local files, or does it need to speak?

๐Ÿ”• Cloud routine (laptop closed) Runs on Anthropic's cloud, no machine required. In Claude Code: /schedule daily brief at 9am, or set it at claude.ai/code/routines. It gets a fresh clone with no access to your local files and it cannot play audio, so the brief lands as text in Notion. Runs on Pro and up, with a daily cap per account.
๐Ÿ”Š Local scheduled job (laptop on) A launchd job on your Mac that runs the skill, makes the MP3 and plays it. Ask Claude Code: "Set up a launchd job that runs my Jarvis brief script every morning at 9am and plays the audio." Full access to local files, and it actually talks. Your Mac has to be awake.
โš ๏ธ Three things bite people here. One: a cloud routine runs with no permission prompts at all and can write through any connector you include, so include only what it needs and keep the prompt self-contained, because it has no memory of your chats. Combined with Gmail's ability to send, that isn't optional. Two: add a freshness guard so it refuses to speak a stale file. If regeneration fails, you want silence or an error, not yesterday's brief read confidently as though it were today's. Only mark the day done after a successful regen, so a failed run retries tomorrow instead of locking itself out. Three: a local scheduled script cannot live in your iCloud folder, scheduled jobs can't reliably run from it. Put it in ~/Scripts/. And read the first week of runs before you trust any of it.

the real secret

Change anything by just saying it ๐Ÿ”ง

Your Jarvis will not be right on day one, and that's the point rather than a problem. The whole thing is built in plain English, so you fix it by telling Claude Code what's wrong:

๐Ÿ—ฃ "The brief is too long" Say it. It trims to 5 lines.
๐Ÿ—ฃ "Add my top sponsor's status" It adds it to the routine.
๐Ÿ—ฃ "The voice sounds too formal" It re-tunes the emotion tags or swaps the voice.
๐Ÿ—ฃ "Brief me at 7am, not 9" It changes the schedule.
๐Ÿ”ง If something breaks, it's almost always one of these. Connector stuck on "needs auth" after you logged in: stale cache, see the connectors section. Connector missing entirely: check /status for a stray API key. Voice silent with no error: the python3 path problem, or a response that wasn't actually audio. Dashboard numbers frozen: the skill isn't rewriting jarvis_data.js, or you're looking at a cached page. No sound in the browser: autoplay needs a click.
Follow @cindiezhu for more AI you can actually use ๐ŸŒฑ
Want more like this? Follow @cindiezhu for daily AI you can actually use.

the waitlist is now open

The AI System Club is launching soon

A 30-day cohort to build real AI systems with me, step by step. Join the waitlist and you hear first, and lock the founding price.