My Claw: Build Your AI System with Claude Code
Background Awareness
Build a gatherer that pulls email and calendar data, interprets it with a cheap model, and caches a digest the claw reads instantly.
You're about to split the claw into two processes: one that gathers, one that answers. This forces a cost decision you'll carry through every lesson after this.
Every question hits the live API. "What's on my calendar?" takes 5 seconds and costs $0.10 because the claw has to call gws, wait for Google, think about the result, and respond. Ask twice, pay twice.
The fix: a background gatherer that pulls data on a timer, interprets it with a cheap model, and writes a digest file. When you ask the claw, it reads the file. Instant, pre-warmed, already-thought-about.
The architecture
On a schedule: gws → raw data → Haiku interprets → digest.md
When you ask: claw reads digest.md → answers instantlyTwo separate sessions. The gatherer runs Haiku ($0.02/cycle) on a continued session. It accumulates understanding and reports deltas. The claw reads the cached digest and answers from memory. You run the gatherer manually for now. In the durability lesson, launchd takes over and runs it every 15 minutes automatically.
Adapters
The gatherer needs email and calendar data. Sometimes from the live gws CLI. Sometimes from test fixtures. Same interface, different source. The adapter pattern, motivated by testing, not architecture aesthetics.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
/** Live email via gws CLI */
export function gws(opts = {}) {
const max = opts.max || 10;
const listRaw = execFileSync("gws", [
"gmail", "users", "messages", "list",
"--params", JSON.stringify({ userId: "me", maxResults: max, q: "newer_than:1d" }),
], { encoding: "utf-8" });
const list = JSON.parse(listRaw);
const ids = (list.messages || []).map((m) => m.id).slice(0, max);
if (ids.length === 0) return { messages: [] };
const messages = ids.map((id) => {
const raw = execFileSync("gws", [
"gmail", "users", "messages", "get",
"--params", JSON.stringify({ userId: "me", id, format: "full" }),
], { encoding: "utf-8" });
const msg = JSON.parse(raw);
const headers = Object.fromEntries(
(msg.payload?.headers || []).map((h) => [h.name, h.value])
);
return { from: headers.From, subject: headers.Subject, date: headers.Date };
});
return { messages };
}
/** Fixture email from a JSON file */
export function fixture(path) {
return JSON.parse(readFileSync(path, "utf-8"));
}import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
/** Live calendar via gws CLI */
export function gws(opts = {}) {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const end = new Date(start.getTime() + (opts.days || 1) * 24 * 60 * 60 * 1000);
const raw = execFileSync("gws", [
"calendar", "events", "list",
"--params", JSON.stringify({
calendarId: "primary",
timeMin: start.toISOString(),
timeMax: end.toISOString(),
singleEvents: true,
orderBy: "startTime",
}),
], { encoding: "utf-8" });
const data = JSON.parse(raw);
const events = (data.items || []).map((e) => ({
summary: e.summary,
start: e.start?.dateTime || e.start?.date,
end: e.end?.dateTime || e.end?.date,
}));
return { events };
}
/** Fixture calendar from a JSON file */
export function fixture(path) {
return JSON.parse(readFileSync(path, "utf-8"));
}Both adapters return the same shape: { messages: [...] } or { events: [...] }. The gatherer doesn't care where the data came from. The gws commands use Google API resource style with --params for JSON arguments. Run gws --help if your version differs.
Test fixtures
Create minimal fixture files so you can test the gatherer without live API calls:
{
"messages": [
{"from": "kent@example.com", "subject": "Workshop follow-up", "snippet": "Hey, wanted to circle back on the testing workshop dates..."},
{"from": "notifications@roguefit.com", "subject": "Your order shipped", "snippet": "Order #RF-4821 is on its way"},
{"from": "alerts@experian.com", "subject": "Statement ready", "snippet": "Your monthly credit report is available"}
]
}{
"events": [
{"summary": "Standup", "start": "2026-04-06T09:00:00", "end": "2026-04-06T09:30:00"},
{"summary": "CrossFit", "start": "2026-04-06T13:30:00", "end": "2026-04-06T14:30:00"},
{"summary": "1:1 with Maya", "start": "2026-04-07T10:00:00", "end": "2026-04-07T10:30:00"}
]
}These are enough to test the full pipeline. Replace with your own data when you're ready.
The gatherer
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import * as emailAdapter from "./adapters/email.mjs";
import * as calendarAdapter from "./adapters/calendar.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CACHE_DIR = join(__dirname, ".claw", "cache");
const DIGEST_FILE = join(CACHE_DIR, "digest.md");
const SESSION_FILE = join(__dirname, ".claw", "gather-session");
mkdirSync(CACHE_DIR, { recursive: true });
// Decide source: fixture files or live gws
const useFixture = process.argv.includes("--fixture");
const fixtureArgs = process.argv.slice(process.argv.indexOf("--fixture") + 1);
let emails, calendar;
if (useFixture && fixtureArgs.length >= 2) {
emails = emailAdapter.fixture(fixtureArgs[0]);
calendar = calendarAdapter.fixture(fixtureArgs[1]);
} else {
emails = emailAdapter.gws();
calendar = calendarAdapter.gws({ today: false });
}
// Read existing digest for delta awareness
const existingDigest = existsSync(DIGEST_FILE)
? readFileSync(DIGEST_FILE, "utf-8")
: "No previous digest.";
const prompt = `You are a background data gatherer. Analyze the following raw data and produce a structured digest in markdown.
If a previous digest exists, focus on what CHANGED since then. Report deltas, not the full state.
## Previous Digest
${existingDigest}
## Raw Email Data
${JSON.stringify(emails, null, 2)}
## Raw Calendar Data
${JSON.stringify(calendar, null, 2)}
Write a concise digest with sections: Email Summary, Calendar Summary, Action Items. Use bullet points. Be brief.`;
// Build args for Haiku
const args = ["-p", prompt, "--output-format", "json", "--model", "haiku"];
const sessionId = existsSync(SESSION_FILE)
? readFileSync(SESSION_FILE, "utf-8").trim()
: null;
if (sessionId) args.push("--resume", sessionId);
try {
const raw = execFileSync("claude", args, {
cwd: __dirname,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
const result = JSON.parse(raw);
if (result.session_id) writeFileSync(SESSION_FILE, result.session_id);
writeFileSync(DIGEST_FILE, result.result);
console.log("Digest updated:", DIGEST_FILE);
if (process.env.VERBOSE) {
console.log(`Cost: $${result.total_cost_usd?.toFixed(4) ?? "?"}`);
}
} catch (err) {
console.error("Gather failed:", err.message);
process.exit(1);
}The key design: it uses --resume to maintain a continued session. The gatherer remembers what it saw last time and reports what changed. "3 new emails since last check" not "here are all 25 emails again."
The digest file is the durable artifact. If the session compacts, the agent re-reads its own digest and picks up where it left off.
The gather skill
Same pattern as lesson 01: a SKILL.md that maps natural language to commands. Create a skill for the gatherer:
# Background Gatherer
The claw has a background gatherer that monitors email and calendar.
## What it does
Pulls email and calendar data, interprets with Haiku, writes a digest
to `.claw/cache/digest.md`. The digest is injected into your context
automatically before every response.
## Commands
- `./run.sh gather` — run a live gather cycle (uses gws CLI)
- `./run.sh gather --fixture fixtures/inbox-today.json fixtures/calendar-week.json` — gather from test data
## When to suggest
- If the operator asks about email or calendar and the digest feels stale
- If `.claw/cache/digest.md` doesn't exist yet
- If the operator asks "when was the last gather?"Wiring the claw
Update claw.mjs to read the digest before answering. Add the DIGEST_FILE constant near the top of the file:
const DIGEST_FILE = join(__dirname, ".claw", "cache", "digest.md");Then in your prompt-building logic:
// Read the cached digest if available
let contextPrefix = "";
if (existsSync(DIGEST_FILE)) {
const digest = readFileSync(DIGEST_FILE, "utf-8");
contextPrefix = `[CURRENT CONTEXT — from background gatherer]\n\n${digest}\n\n---\n\nOperator message: `;
}
const fullPrompt = contextPrefix + prompt;Update CLAUDE.md:
# My Claw
You are a personal assistant. Be concise and helpful.
## Skills
Check the `skills/` directory for available capabilities. Each skill is a
SKILL.md file that describes a tool and when to use it. Suggest relevant
skills when the operator's request matches.
You receive a digest from a background gatherer that monitors email and calendar.
Answer from the digest when possible. It's cheaper and faster.
When taking action (sending email, creating events), use live gws commands.Try it
# Gather context (from fixtures for testing)
node gather.mjs --fixture fixtures/inbox-today.json fixtures/calendar-week.json
# Ask the claw
node claw.mjs "Any emails worth reading?"The claw answers from the digest. One turn, ~$0.04, instant. No live API calls. It knows about your Rogue Fitness order, your Experian statement, the ByteByteGo article about Claude Code features. All pre-warmed.
Model routing earned
The gatherer uses Haiku ($0.02/cycle). The claw uses Sonnet or Opus. Background work uses the cheapest model that produces adequate output. Operator-facing conversation uses the best you can afford. This isn't an optimization. It's how you stay within your subscription budget as Anthropic keeps tightening limits.
What you have
A claw with background awareness. The gatherer pulls data, interprets it with a cheap model, and caches a digest. The claw reads the digest and answers instantly. Two sessions, two models, one coherent system.
Right now you run the gatherer manually with ./run.sh gather. In lesson 04, when the channel process is running, the gatherer gets wired to a 15-minute timer inside channel.mjs. From then on, the digest stays fresh automatically. The operator never runs the gather command again.
What's missing
The claw is still trapped on your terminal. You have to sit down at a computer and type node claw.mjs to interact with it. Next: make it reachable from your phone.