Architecture
Workers
Background workers — what ships, what they do, how to add your own.
A worker in Mayva is a function that does something Mayva-shaped (reads context, calls Claude, writes a structured outcome) on a schedule.
Workers that ship
| Worker | Cron | Tokens | Purpose |
|---|---|---|---|
morning-brief | 0 6 * * * | < 3k | Plan the day from recovery + calendar + outcomes |
whoop-sync | */15 * * * * | 0 | Pull Whoop recovery / sleep / cycle / strain / workouts |
google-sync | */15 * * * * | 0 | Pull calendar events for today + next 7d |
gmail-sync | */15 * * * * | 0 | Pull inbox metadata — sender, subject, label |
news-digest | 0 7 * * * | < 2k | Filtered news against your interests profile |
weekly-drift | 0 18 * * 0 | < 4k | 7d vs prior 83d trends. One sentence of recommendation. |
librarian | on-demand | < 1k | Canonical writer across the four memory layers |
The integration workers (whoop-sync, google-sync, gmail-sync) cost zero LLM tokens — they just fetch and store. LLM cost only kicks in when an agent worker reads the data.
Adding a worker
Drop a file in packages/workers/src/scheduled/:
// packages/workers/src/scheduled/lunch-reminder.ts
import { generate } from '@mayva/core/agent';
import { saveSuggestion } from '@mayva/core/librarian';
export const name = 'lunch-reminder';
export const schedule = '30 11 * * 1-5'; // 11:30 weekdays
export async function run() {
const brief = await generate({
prompt: 'Should the user eat in the next hour? Look at recovery and today\'s calendar.',
schema: z.object({ shouldEat: z.boolean(), why: z.string() }),
maxTokens: 200,
});
if (brief.shouldEat) {
await saveSuggestion({
kind: 'lunch-reminder',
priority: 'p2',
payload: { body: brief.why },
});
}
}
That’s it. The daemon picks it up on next restart. It’ll appear on the dashboard’s worker_runs log immediately.
The daemon
packages/workers/src/index.ts does three things:
- Scans
scheduled/for exported{ name, schedule, run }. - Registers each with BullMQ using its cron string.
- Runs an in-process queue worker that consumes the jobs.
We use BullMQ on Redis because:
- It survives restarts (jobs persist).
- It gives us retries, backoff, concurrency caps for free.
- The same daemon can run both scheduled and on-demand jobs (e.g. “regenerate today’s brief” from the dashboard).
Self-host mode runs BullMQ against the local Redis we bring up via docker-compose. Hosted mode uses managed Redis.
On-demand jobs
Some workers don’t have a schedule — they fire from the dashboard:
// packages/workers/src/on-demand/regenerate-brief.ts
export const name = 'regenerate-brief';
export async function run({ date }: { date: string }) {
// ...
}
Triggered from the dashboard via a single enqueue call against the core facade. Useful for “show me a different version” or “generate this on the fly.”
Reliability
The non-negotiables:
- No-throw contract for integration workers. If Whoop is down,
whoop-synclogs the error and returns success. The dashboard showsSTALEon the Whoop pulse, not an exception. - Idempotent writes. Re-running a brief for the same date overwrites the previous row — it doesn’t duplicate.
- Budget caps. Each worker has a max-tokens budget. If a prompt blows past it, we truncate context aggressively rather than upgrading the model.
The contract is enforced by a thin wrapper (runWorker in packages/workers/src/lib/run.ts) that wraps every job: it catches errors, logs to worker_runs, and reports success-or-error to BullMQ.
Cost math
A normal day, all workers running, no research loops:
| Worker | Calls/day | Tokens/call | Total/day |
|---|---|---|---|
| morning-brief | 1 | 3,000 | 3,000 |
| news-digest | 1 | 2,000 | 2,000 |
| weekly-drift | 1/7 | 4,000 | 570 |
| voice intents | ~10 | 800 | 8,000 |
| librarian | ~30 | 600 | 18,000 |
| Total | ~32,000 tokens/day |
At Sonnet pricing (~$3/M input), that’s a few cents a day. If you bring up the research loop, costs scale linearly with how aggressive it is.