Personal agent daemon · ~1,800 lines of TypeScript · M0–M7

Veda
an always-on agent, explained

Veda lives in a container and never stops. Email, calendar and chat all arrive as one shape — a signal. A fast half answers within seconds like a person; a slow half thinks in the background and follows up in pieces, correcting the fast half when it was wrong.

Postgres is the queue 2 models, 2 speeds 10 tables 0 Redis / Kafka / vector DB 5 eval fixtures

01The idea in one page

Three bets, taken from todo.md, decide whether this project lives: judgment (does it ping about the right things?), memory extraction quality, and boring reliability. Architecture is deliberately the least interesting part.

One shape for everything

A WhatsApp message, a new email, a calendar reminder and a 07:30 timer are all Signal{ source, kind, payload }. One bus, one handler table, one audit trail of what woke the agent up.

Two speeds, not one

Instinct (Haiku-class) always replies in under three seconds. Thinker (Opus-class) runs as a background job with tools and full context, then texts follow-ups in pieces — and may correct what Instinct said.

Postgres does the plumbing

Queue (SKIP LOCKED), wake-ups (LISTEN/NOTIFY), scheduling (a deliver_after column), memory search (full-text). No broker, no scheduler, no vector store.

Silence is a feature

Most email deserves no ping. An agent that pings too much gets muted, and a muted agent is dead. Ingestion never sleeps — only delivery is scheduled.

02The box it lives in

Two containers, one persistent volume each. The agent's home directory (/home/agent) survives restarts and holds the Google OAuth credentials plus the Gmail poll cursor. Postgres holds everything else.

docker compose · restart: unless-stopped agent node:22 · tsx as PID 1 channels/ in · out bus.ts queue · timers brain.ts 2 speeds relevance.ts triage world.ts open loops memory.ts recall tools.ts the gate google.ts poll · act llm.ts budget volume /home/agent google-oauth.json postgres 17 pgvector image · volume signals append-only jobs the queue messages history memories + entities open_loops holding pending_… held pings audit every call llm_usage cost cap triggers turn an INSERT into a job + NOTIFY LISTEN veda_jobs owner chat · :8787 dev Gmail API REST, no SDK Calendar API same OAuth Anthropic or OpenRouter haiku + opus message in / pieces out poll history.list · 60s today's events · 60s draft · send · RSVP — only after approval SQL + NOTIFY prompts / replies
Everything crossing the container edge is either a signal coming in, a message going out, or a model call. Write actions to Google (dashed) exist but cannot fire without the owner approving them first — see §08.

03The signal bus — the nervous system

src/bus.ts is 200 lines and it is the whole queue. Publishing a signal is a single INSERT INTO signals — from the app, or from psql, it makes no difference. A database trigger turns that insert into a pending job; a second trigger fires pg_notify, which wakes the worker loop instantly instead of waiting for the next poll.

emit() INSERT INTO signals trigger signals_enqueue jobs row status = pending trigger pg_notify() worker loop claim: UPDATE … FOR UPDATE SKIP LOCKED · one at a time wakes …or the 5s poll, for run_at wake-ups handler respond · handleEmail eventSoon · dayBrief · think status = done lastProcessedAt bumped (the watchdog reads it) throw attempts + 1, last_error saved attempts < 3 → pending run_at += 30s × attempts attempts = 3 → failed row parked, never lost — for autopsy
One insert is the entire publish API. Nothing is lost: a crash leaves rows running, and boot resets those to pending before the loop starts.

Two job lanes

Jobs registered with { background: true } — the Thinker, memory extraction, email pings — run detached so a 20-second think never blocks the next instinct reply. Everything else runs inline, one at a time. That single flag is the whole concurrency model.

Timers instead of a scheduler

A 15-second interval checks a small array of timers and emits ordinary signals: timer.tick hourly (a liveness probe that exercises the entire pipeline), timer.morning-review at 07:30, and timer.google-poll every minute, registered by the Google module itself via addTimer(). Same bus, no cron, no second process.

Marked shortcut. The timer scheduler dedupes in memory, so a restart inside the same minute can double-fire. The code says so, in a ponytail: comment naming the fix (a next_run column) if it ever bites.

04The two-speed brain

This is the differentiator. respond() always runs Instinct and maybe enqueues a Thinker job. Instinct returns { reply, needsDeepThought } and its reply goes out with no artificial delay — that is the sub-three-second acknowledgement. The Thinker then works with the full thread, retrieved memories and tools, and texts follow-ups in pieces.

0s 3s 11s 18s background Instinct haiku · 300 tok thread tail ×12 Thinker opus · 16k tok thread ×50 + tools owner's message → signal → job one call reply sent · 2.7s "on it — checking your calendar" needsDeepThought → enqueue world state + memories tool loop (≤5 steps) piece 1 piece 2 piece 3 typing… pause… send — like a person texting memory extract a new owner message here → delivery stops mid-flight
Measured green at 2.7s / 11s via OpenRouter. Instinct's reply is already in the thread when the Thinker starts, so its prompt says: add, never repeat — and correct yourself plainly if the fast half got it wrong.

Interruption is handled twice

A newer user message makes an in-flight deep thought stale, and the code checks that on both sides: pending thinker jobs for the thread are cancelled at enqueue time, and a running think re-checks staleness before every single piece it sends. You never get a follow-up to a question you already moved on from.

Instinct cannot act — by construction

Instinct is given no tools at all. Its prompt is hardened to match: any ask to do, record or change something gets a quick "on it" and a deep-thought flag, never a claim that it is done. The first eval run caught it claiming it had marked a loop done; that is why the rule is explicit now.

// src/brain.ts — the orchestrator, minus the plumbing
const tail  = await history(source, threadId, 12);
const raw   = await llm("instinct", tail, { system: INSTINCT_SYSTEM(await worldState()) });
const { reply, needsDeepThought } = parseJson(raw, { reply: raw, needsDeepThought: false });

await ch.send(threadId, reply);           // no delay — this is the ack
await recordMessage(source, threadId, "veda", reply);

if (needsDeepThought) await enqueueJob("thinker", { source, threadId, userMessageId });

Model layer and the money cap

src/llm.ts is one function over the AI SDK. The provider is chosen by whichever key is in .env (Anthropic wins over OpenRouter). Every call writes a row to llm_usage with token counts and a computed cost; before every call the day's spend is summed and compared to DAILY_BUDGET_USD (default $5). At the cap it alerts the owner once and throws — an always-on Opus reading newsletters is a real way to lose money quietly.

05Relevance — deciding when to stay quiet

Email arrives all day. One cheap classifier call maps each one onto a five-word enum, and the enum decides delivery. Ties are broken toward silence: unsure between notify-now and hold, pick hold; unsure between log and ignore, pick ignore. An unparseable answer becomes hold — no spam ping, but nothing dropped either.

email signal from · subject snippet classifyEmail haiku · 10 tokens out one word, nothing else ignore log hold notify-now urgent deliberate silence the signals row is the log pending_notifications deliver_after = 07:30 flushed as one morning brief ping now thinker writes it in Veda's voice · 1–2 short messages quiet? 23–07:30 yes no urgent bypasses quiet hours — the escape hatch exists from day one
Ingestion never sleeps; only delivery is scheduled. The "scheduler" for held items is one timestamp column plus the 07:30 timer signal.

The morning brief

timer.morning-review hands the Thinker everything held overnight plus the world state, and asks for one to three short messages: a line on the night, then what landed, today's calendar and what it is still holding. When nothing was held it says so — "Quiet one so far, nothing has landed." Delivered rows are stamped so they never repeat.

Calendar reminders skip the model entirely

An event starting within fifteen minutes emits event-soon, and the handler formats a plain string: heads up — Standup at 10:00. No LLM call, because there is no judgment to make.

06World state and open loops

Both halves of the brain get the same short text block, rebuilt on every turn by worldState(): the current time, today's calendar, the open loops Veda is holding (with their ids), and a digest of the last 24 hours of signals. If the calendar call fails the brain still works — the block just says the calendar is not connected.

// what gets injected into both prompts
Now: Sat Aug 23 2026 09:14:02 GMT+0530
Today's calendar: Standup (10:00); Bill sorting (18:30)
Open loops you're holding:
  #12 [task] send the zeppelin invoice to Acme — Fairooza is waiting on it
  #14 [approval] gmail_send — waiting for the owner's go
Signals last 24h: gmail/email ×23, dev/message ×6, calendar/event-soon ×2

An open loop is a thing Veda is holding for its owner: a reply owed, a task, something it is waiting on — or, since §08, a pending tool approval. The Thinker gets three tools (open_loop_create, open_loop_complete, open_loop_list), which is what makes "mark it as done" simply work. Only status = 'open' rows appear in the block, so completing a loop is exactly what makes it never get mentioned again.

07Durable memory

Extraction runs as a background job after a Thinker turn — never inline in the reply path, and never as a raw-conversation-to-vector dump. One cheap call sees the last ten turns and a block of existing related memories, then returns operations: create, or supersede an existing memory by id.

thinker turn finishes job: memory-extract last 10 turns retrieveMemories() FTS OR-query + entity name match · rank "don't re-extract what these already say" one call candidates + dedupe + conflict, together create #37 type · content · subject confidence · provenance supersede #12 #12 → status superseded #37.supersedes = 12 nothing is deleted — the chain stays
Corrections supersede rather than delete, so the history of what the owner used to believe stays inspectable. A hallucinated id fails the update and the new fact is kept unlinked.

Retrieval is Postgres full-text search over content || subject, plus an entity match — if a known entity name appears in the query, memories about it come back too. Results are ranked, recency breaks ties, and the top eight go into the Thinker's context as a compact block with ids, so you can always see what it was recalling.

Marked shortcut. The pgvector image is running and the extension is installed, but no embedding fallback is wired — there is no embedding model in the stack yet. The note in memory.ts says to add it when the eval suite catches FTS misses, not before.

08Acting on the world, behind a gate

Tools live in a Map, not a framework. Each carries a permission — ALLOW, ASK or DENY — and gatedTools() hands the model only the wrapped execute, so bypassing the gate is not a prompt question. Reads are ALLOW; gmail_draft, gmail_send and calendar_respond are ASK. Every call, whatever the outcome, writes a row to audit — written by the runtime, not by the model.

thinker calls a tool the gate wrapped execute in the runtime ALLOW → runs reads, open-loop tools DENY → blocked returns an error, not a crash ASK → not executed open_loops row, kind 'approval', data = the call returns { pendingApproval: 14 } "want me to send this?" Veda shows the exact draft and says "go" will do it owner replies "go" → approve(#14) runtime check, not prompt vibes: is there an owner message NEWER than the ask? no → refused. the model cannot approve itself. yes → executes, loop closed audit allow · ask deny · approved every branch lands here
The self-approval guard is the interesting part: approving requires an owner message with a timestamp later than the ask, checked in SQL. A model that invents an approval in its own turn is refused by the database, not by a prompt.

Tool errors come back to the model as data ({ error: "…" }) rather than crashing the turn, so a failed send becomes something Veda can tell you about instead of a silent dropped job.

09The data model

Ten tables, defined in TypeScript with Drizzle and applied as generated SQL migrations at boot. Raw sql is used where an ORM would fight the design: the SKIP LOCKED claim, the full-text index, and LISTEN/NOTIFY on the raw driver client.

TableHoldsWhy it exists
signalssource, kind, payload, received_atAppend-only record of everything that ever woke the agent. Doubles as the audit trail and the "last 24h" digest.
jobskind, payload, status, run_at, attempts, last_errorThe entire queue. Claimed with FOR UPDATE SKIP LOCKED; retries and backoff are two columns.
messageschannel, thread_id, role, contentConversation history — what both models read, and what the staleness check queries.
memoriestype, content, subject, confidence, status, supersedesLong-term memory. Six types; corrections supersede instead of deleting.
entitiesname (unique), kindPeople, orgs, places, things — upserted from a memory's subject, used to widen retrieval.
relationshipsfrom, to, relationExists per the plan; nothing writes it yet, and the schema comment says so.
open_loopstitle, kind, context, data, statusWhat Veda is holding. kind='approval' reuses the same table for pending tool calls.
pending_notificationssummary, deliver_after, delivered_atHeld pings. One timestamp column is the whole delivery scheduler.
audittool, args, decision, resultEvery tool call and its permission decision, written by the gate.
llm_usagemodel, input/output tokens, cost_usdOne row per model call; today's sum is the hard budget stop.

10Reliability — the unglamorous half

Expired OAuth tokens, dead containers and runaway token bills kill this kind of project long before any architecture question matters. Silent death is the failure mode, so the daemon is built to complain.

Watchdog

timer.tick fires hourly and exercises the whole pipeline. If nothing has been processed for two hours, the owner gets told on any working channel.

Failing sources

Five consecutive failures from the same source — a revoked Gmail refresh token, say — raise an alert once, then reset the counter so it doesn't spam.

Graceful shutdown

SIGTERM stops claiming new work, finishes what's in flight, and leaves the rest pending. tsx runs as PID 1 because pnpm swallows the signal.

Crash recovery

There is one worker, so any row still running at boot is an orphan. Boot resets them to pending before the loop starts.

Cost ceiling

A daily USD cap, summed from llm_usage before each call. At the cap Veda alerts once and goes quiet until tomorrow.

Cursor recovery

If the agent was down long enough for Gmail's history cursor to expire, the 404 is caught, the cursor resyncs to now, and the gap is skipped rather than crashing the poll.

Evals are the real deliverable

Five fixtures, each asserting against the database rather than against vibes. They need the daemon running on the dev compose overlay.

CommandAssertsStatus
pnpm eval:two-speedInstinct acks in under 3s, Thinker follows up under 60sgreen — 2.7s / 11s
pnpm eval:relevanceNine real-shaped emails land on acceptable triage decisionsgrows with every misfire
pnpm eval:open-loops"Mark the first as done" completes it in the DB and it is never mentioned againgreen
pnpm eval:memoryA fact is recalled in a new session; a correction supersedes the old rowgreen
pnpm eval:toolsSend without approval is blocked and asks; "go" executes and auditsgreen, no Google creds needed

11Running it

# dev: source mounted, tsx watch, ports exposed
docker compose -f compose.dev.yml up

# chat with it — or POST/SSE it from a script
open http://localhost:8787
curl -N localhost:8787/events
curl -d '{"text":"whats for today?"}' localhost:8787/message

# connect Google once — writes /home/agent/google-oauth.json
docker compose run --rm -p 8790:8790 agent pnpm auth:google

# publish a signal from outside the app — the trigger does the rest
psql $DATABASE_URL -c "INSERT INTO signals (source, kind, payload)
  VALUES ('gmail','email','{\"from\":\"a@b.c\",\"subject\":\"hi\",\"snippet\":\"…\"}')"

# prod
docker compose up -d

Where things live

FileResponsibility
src/index.tsBoot: connect, migrate, register handlers, start bus and channels, trap signals.
src/bus.tsEmit, dispatch, worker loop, retries, timers, watchdog.
src/brain.tsInstinct + Thinker prompts, the orchestrator, staleness checks.
src/llm.tsProvider selection, model roles, pricing, the daily budget stop.
src/relevance.tsEmail triage, quiet hours, morning brief, event reminders.
src/world.tsThe world-state block and the open-loop tools.
src/memory.tsExtraction job and retrieval.
src/tools.tsRegistry, permission gate, approvals, audit.
src/google.tsOAuth refresh, Gmail polling, calendar reads, write actions.
src/channels/The Channel interface and the dev HTTP/SSE channel.

12Where it stands

Milestones M0 through M7 are complete. What remains is mostly reach — a channel a phone can actually use — and the tuning period, which is the actual work rather than a chore after it.

done
M0–M1 — the box, the bus, timers, watchdog, graceful shutdownPostgres as queue; a plain INSERT is the publish API.
done
M3 — two-speed brain, interruption handling, cost capGreen at 2.7s / 11s.
done
M4 — Gmail + Calendar polling, relevance enum, quiet hours, morning brief
done
M5 — world state, open loops, "mark it as done"
done
M6 — durable memory, entities, supersede-not-delete
done
M7 — tool registry, permission gate, approvals, audit
next
M2 remainder — Telegram, then WhatsApp via BaileysTelegram first because it is twenty minutes and zero ban risk; the dev channel carried the whole brain until now.
next
Owner allowlist — only respond to the owner's number, checked in code, not in the prompt
next
Tuning period, ~2 weeks — run against the real inbox; every wrong ping and wrong silence becomes an eval caseTrack pings acted on ÷ pings sent. Below about half, tune before building anything else.
later
Deliberately deferred — browser tools, skills, subagents, dynamic workflows, capability discoveryThe seams exist (tools are a registry, memory is typed, everything flows through signals). Most of these being never built is the success case.