An event-driven agent on Cloudflare Workers. Claude API as the reasoning core. D1 for memory. Your stack, your rules, one-tenth the cost.
Build and test each component independently before wiring them together. Start with the Agent Core — everything else plugs into it.
Single Worker entry point. Receives cron events, webhook payloads, and chat messages — routes each to the right handler with a normalized payload shape.
Before every Claude call, reads D1 for open tasks, recent decisions, and entity memory. Assembles the full prompt so Claude always has real context.
The main loop. Calls Claude with context + tools, parses tool_use blocks, executes them in sequence, logs every decision with reasoning back to D1.
A map of tool names to handler functions. Start with four tools: create_task, send_notification, draft_email, no_action. Add integrations incrementally.
D1 SQLite with three tables — tasks, decisions, entities. The agent reads this at the start of every run and writes to it at the end. The brain's long-term memory.
Optional React app on Cloudflare Pages. Lets you message your agent directly and see its reasoning. Not required — the cron and webhook flows work without it.
The agent core, the cron trigger, and the system prompt — pre-configured for your context. Drop these into a new Workers project and you're running.
// lib/agent.ts — the main agent loop // Cloudflare Worker compatible, no Node.js dependencies const CLAUDE_API = 'https://api.anthropic.com/v1/messages'; const MODEL = 'claude-sonnet-4-6'; export async function runAgent(env: Env, trigger: AgentTrigger) { // 1. Assemble context from D1 const [tasks, decisions, entities] = await Promise.all([ env.DB.prepare("SELECT * FROM tasks WHERE status='open' ORDER BY created_at DESC LIMIT 20").all(), env.DB.prepare("SELECT * FROM decisions ORDER BY created_at DESC LIMIT 10").all(), env.DB.prepare("SELECT * FROM entities ORDER BY updated_at DESC LIMIT 30").all(), ]); const contextBlock = [ `TRIGGER: ${trigger.type} — ${JSON.stringify(trigger.payload)}`, `OPEN TASKS (${tasks.results.length}):\n` + tasks.results.map((t: any) => `- [${t.priority}] ${t.title}`).join('\n'), `RECENT DECISIONS:\n` + decisions.results.map((d: any) => `- ${d.action}: ${d.reasoning}`).join('\n'), `KNOWN ENTITIES:\n` + entities.results.map((e: any) => `- ${e.name} (${e.type}): ${e.facts}`).join('\n'), ].join('\n\n'); // 2. Tools Claude can call const tools = [ { name: 'create_task', description: 'Add a task to track. Use when something needs follow-up.', input_schema: { type: 'object', required: ['title'], properties: { title: { type: 'string' }, priority: { type: 'string', enum: ['low','normal','high'] }, context: { type: 'string' } } } }, { name: 'send_notification', description: 'Send Ryan a Slack DM. Use for anything action-needed.', input_schema: { type: 'object', required: ['message'], properties: { message: { type: 'string' }, urgency: { type: 'string', enum: ['info','action_needed','urgent'] } } } }, { name: 'draft_email', description: 'Draft an email. Saves to R2 and notifies Ryan to review.', input_schema: { type: 'object', required: ['to','subject','body'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } } }, { name: 'no_action', description: 'Nothing needs doing this run.', input_schema: { type: 'object', properties: { reason: { type: 'string' } } } } ]; // 3. Call Claude const res = await fetch(CLAUDE_API, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' }, body: JSON.stringify({ model: MODEL, max_tokens: 1024, system: systemPrompt(), messages: [{ role: 'user', content: contextBlock }], tools }) }); const data = await res.json(); // 4. Execute tool calls + log decisions for (const block of data.content) { if (block.type !== 'tool_use') continue; await executeTool(env, block.name, block.input); await env.DB.prepare( `INSERT INTO decisions (id,trigger,action,reasoning,created_at) VALUES (?,?,?,?,datetime('now'))` ).bind(crypto.randomUUID(), trigger.type, block.name, block.input.reason ?? JSON.stringify(block.input)) .run(); } } function systemPrompt() { return `You are Ryan's personal AI agent. Ryan is President of GTMpact Solutions, runs Client Success and Operations at Independence Pet Group, and develops OrgCanvas and AgentIzzy. Based in Milton, GA. Review incoming information. Take action or note no action needed. Be decisive — Ryan prefers action over deliberation. Use create_task for follow-ups, send_notification when Ryan needs to know something now, draft_email when a reply is needed, no_action when nothing warrants attention. Always give brief reasoning.`; }
// workers/trigger.ts — single entry point for all events // wrangler.toml sets: [triggers] crons = ["*/15 * * * *"] import { runAgent } from '../lib/agent'; export default { // Scheduled cron — fires every 15 minutes async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { ctx.waitUntil(runAgent(env, { type: 'scheduled', payload: { time: new Date(event.scheduledTime).toISOString() } })); }, // HTTP — handles webhooks + chat UI async fetch(request: Request, env: Env, ctx: ExecutionContext) { const url = new URL(request.url); const body = request.method === 'POST' ? await request.json() : {}; const routes: Record<string, AgentTriggerType> = { '/webhook/gmail': 'email', '/webhook/slack': 'slack', '/chat': 'chat', }; const triggerType = routes[url.pathname]; if (!triggerType) return new Response('Not found', { status: 404 }); // Run agent — chat waits for response, webhooks fire-and-forget if (triggerType === 'chat') { await runAgent(env, { type: triggerType, payload: body }); } else { ctx.waitUntil(runAgent(env, { type: triggerType, payload: body })); } return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }); } };
-- schema.sql — run with: wrangler d1 execute agent-db --file=schema.sql CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, status TEXT DEFAULT 'open', priority TEXT DEFAULT 'normal', context TEXT, source TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS decisions ( id TEXT PRIMARY KEY, trigger TEXT, action TEXT, result TEXT, reasoning TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS entities ( id TEXT PRIMARY KEY, type TEXT, name TEXT NOT NULL, aliases TEXT, facts TEXT, updated_at TEXT DEFAULT (datetime('now')) ); -- Seed your entity knowledge so the agent knows your world INSERT INTO entities VALUES (lower(hex(randomblob(8))), 'company', 'Independence Pet Group', '["IPG"]', '{"role":"Ryan is Interim Director of Sales Ops and Enablement"}', datetime('now')), (lower(hex(randomblob(8))), 'company', 'GTMpact Solutions', '["GTMpact"]', '{"role":"Ryan is President. Fractional CRO + AI consultancy. B2B, mid-market/enterprise."}', datetime('now')), (lower(hex(randomblob(8))), 'project', 'OrgCanvas', '[]', '{"type":"org chart SaaS","status":"active"}', datetime('now')), (lower(hex(randomblob(8))), 'project', 'AgentIzzy', '[]', '{"type":"AI phone agents for contractors","status":"active"}', datetime('now'));
# wrangler.toml name = "rx-agent" main = "workers/trigger.ts" compatibility_date = "2024-01-01" # Fire every 15 minutes + 7am daily brief [triggers] crons = ["*/15 * * * *", "0 7 * * *"] # D1 memory database [[d1_databases]] binding = "DB" database_name = "agent-db" database_id = "YOUR_D1_ID_HERE" # R2 for documents and email drafts [[r2_buckets]] binding = "STORAGE" bucket_name = "agent-storage" # Secrets — set with: wrangler secret put ANTHROPIC_API_KEY [vars] SLACK_WEBHOOK_URL = "https://hooks.slack.com/your-webhook" # Dev settings [dev] port = 8787 local_protocol = "http"
Each phase ships something functional. Don't advance until the current phase works end-to-end — including real data flowing through D1.
Create the Worker project, wire the cron trigger, implement agent.ts with two stubbed tools that just log to console. Verify the full loop: cron fires → Claude called → D1 written.
Wire Gmail push notifications as a webhook trigger. Implement Slack DM via incoming webhook. Wire create_task to actually write to D1. Seed the entities table with your key contacts and projects.
Build context.ts to pull open tasks, recent decisions, and entity facts on every run. Add decision logging with Claude's reasoning. Add a "what happened yesterday" summary to the daily 7am prompt.
Add Google Calendar check to the cron run so the agent knows what's on your day. Add HubSpot stale deal check. Deploy the React chat UI to Cloudflare Pages. Add a Friday weekly recap to Slack.
Where you close the gap, where you don't, and why that's fine for a solo operator running three businesses.
| Capability | Viktor | Your Build | Gap |
|---|---|---|---|
| Proactive monitoring | Continuous, real-time | Cron every 15 min | Small |
| Tool integrations | 3,200+ via Zapier | What you build + Zapier MCP | Medium |
| Long-term memory | Proprietary knowledge graph | D1 SQLite — simple, auditable | Minimal |
| Code execution | Built-in sandbox | Workers can exec code too | Minimal |
| Team Slack presence | Native @mention in channels | Needs Slack bot setup | Real |
| Setup time | Hours | ~2 weeks part-time | Medium |
| Monthly cost | $200 – $500+, unpredictable | ~$10 – $30, fixed | You win |
| Tailored to your workflow | Generic | 100% yours — IPG, GTMpact, OrgCanvas | You win |
| Becomes a product | No | Yes — verticalize for GTMpact clients | Upside |