Personal AI Agent — Architecture

Your own
Viktor.
Built by you.

An event-driven agent on Cloudflare Workers. Claude API as the reasoning core. D1 for memory. Your stack, your rules, one-tenth the cost.

Stack Cloudflare Workers + D1 + R2
Brain Claude claude-sonnet-4-6 API
Est. cost / mo $10 – $30
Viktor cost / mo $200 – $500+
Agent Flow — Live
CRON every 15 min WEBHOOK Gmail / Slack CHAT React UI Cloudflare Worker read context call claude execute tools write results Claude API Reason · Plan Draft · Decide claude-sonnet-4-6 Gmail · Calendar Slack · HubSpot Custom APIs Memory D1 tasks · decisions entities · history R2 Storage docs · outputs TRIGGERS ORCHESTRATOR BRAIN + MEMORY TOOLS + STORAGE
01

Five pieces.
Each shippable alone.

Build and test each component independently before wiring them together. Start with the Agent Core — everything else plugs into it.

01
Trigger Router

Single Worker entry point. Receives cron events, webhook payloads, and chat messages — routes each to the right handler with a normalized payload shape.

workers/trigger.ts
02
Context Builder

Before every Claude call, reads D1 for open tasks, recent decisions, and entity memory. Assembles the full prompt so Claude always has real context.

lib/context.ts
03
Agent Core

The main loop. Calls Claude with context + tools, parses tool_use blocks, executes them in sequence, logs every decision with reasoning back to D1.

lib/agent.ts
04
Tool Registry

A map of tool names to handler functions. Start with four tools: create_task, send_notification, draft_email, no_action. Add integrations incrementally.

tools/index.ts
05
Memory Store

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.

schema.sql
06
Chat UI

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.

pages/chat.tsx
📋

tasks

idTEXT PK
titleTEXT
statusopen|done
prioritylow|normal|high
contextJSON
sourceTEXT
created_atTEXT
🧠

decisions

idTEXT PK
triggerTEXT
actionTEXT
resultTEXT
reasoningTEXT
created_atTEXT
🏷️

entities

idTEXT PK
typeperson|company|project
nameTEXT
aliasesJSON array
factsJSON
updated_atTEXT
02

Starter code.
Ready to run.

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"
03

Four phases.
Running in two weeks.

Each phase ships something functional. Don't advance until the current phase works end-to-end — including real data flowing through D1.

Day 1 Skeleton

Get the loop running with mock tools

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.

wrangler init d1 create agent-db schema.sql mock tools wrangler dev test
Week 1 Real Tools

Replace mocks with real integrations

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.

Gmail webhook Slack DM D1 task writes R2 email drafts entity seed
Week 2 Memory

Make the agent contextually aware

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.

context builder decision logging daily summary entity lookup
Week 3 Proactive

Add monitoring and a chat interface

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.

Google Cal API HubSpot check chat UI Friday brief 7am daily DM
04

Honest comparison
with Viktor.

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
The real play: Once this runs for you, you have the core of a productizable AI ops agent you could offer GTMpact clients — a verticalized Viktor built for B2B sales and ops teams. Mid-market companies paying $200–500/month for Viktor would pay the same or more for something purpose-built for their stack, with a human (you) behind it. That's not a side effect of building this. That's the business case.