Architecture

Workers

Background workers — what ships, what they do, how to add your own.

Updated · 2026-05-28

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

WorkerCronTokensPurpose
morning-brief0 6 * * *< 3kPlan the day from recovery + calendar + outcomes
whoop-sync*/15 * * * *0Pull Whoop recovery / sleep / cycle / strain / workouts
google-sync*/15 * * * *0Pull calendar events for today + next 7d
gmail-sync*/15 * * * *0Pull inbox metadata — sender, subject, label
news-digest0 7 * * *< 2kFiltered news against your interests profile
weekly-drift0 18 * * 0< 4k7d vs prior 83d trends. One sentence of recommendation.
librarianon-demand< 1kCanonical 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:

  1. Scans scheduled/ for exported { name, schedule, run }.
  2. Registers each with BullMQ using its cron string.
  3. 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-sync logs the error and returns success. The dashboard shows STALE on 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:

WorkerCalls/dayTokens/callTotal/day
morning-brief13,0003,000
news-digest12,0002,000
weekly-drift1/74,000570
voice intents~108008,000
librarian~3060018,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.