Server and integrations
Architecture
One operations layer, thin identity layers
apps (Android, macOS, Linux) ──(JWT)──> convex/app.ts ┐
├─> domain operations (convex/lib/*.ts)
AI agent ──(MCP, API key)──> /mcp (convex/mcp.ts) ───┤ registered as internal functions
Claude app ──(MCP, OAuth access token)──> /mcp ──────┤ in convex/ops.ts
dayhand CLI ──(MCP tools/call, API key)──────────────┤
integration ──(REST /api/v1, API key)──> convex/rest.ts ┘ (same tools, lib/restRoutes.ts)
apps ──(device token)──> /auth/token ─┐
──(pairing code)──> /auth/pair ─┼─> convex/pairing.ts ──> convex/devices.ts
dayhand CLI ──(API key)──> /devices/* ┘
Claude app ──> /.well-known/*, /oauth/* (convex/oauthHttp.ts) ──> convex/oauth.ts
Android app ──(JWT)──> app.lookup/decideConnectionRequest ─┐
dayhand CLI ──(API key)──> /connections* ──────────────────┴──> convex/oauthOwner.ts
calendar app ──(secret address)──> GET /cal/<token>.ics ─┐
Android app ──(JWT)──> app.*CalendarFeed ────────────────┼─> convex/calendarFeeds.ts ──> lib/ical.ts
dayhand CLI ─(API key)─> /calendar/feed* (calendar.ts) ──┘
- Domain operations take the acting identity explicitly:
userIdandactor(app,clioragent), pluskeyIdandactorNamefor changes made with an API key. They are exposed as Convex internal functions (convex/ops.ts), so no client can call them directly. convex/app.tstakesuserIdfrom the signed-in user (ctx.auth) and setsactor: "app".convex/mcp.tstakesuserIdfrom the personal API key and setsactorto the key’s kind (clioragent),actorNameto its name andkeyIdto its id; for an OAuth access token it setsactor: "agent",actorNameto the client’s name andconnectionIdto the connection, and allows only the tools of the token’s scopes (oauth.md). Arguments that try to set any of these are rejected. The/devices/*endpoints do the same. Around it:lib/mcpTools.ts(tool catalog, input schemas from the validators, output schemas fromdocs/schemasvia the generatedlib/outputSchemas.ts, behaviour hints),lib/mcpContent.ts(resources and prompts, read through the same internal queries) andlib/mcpProtocol.ts(the 2025 and 2026-07-28 protocol eras on one endpoint).dryRunexists only on the MCP layer: the internal mutation runs the operation, every check included, then throws its result, so Convex rolls the transaction back (convex/ops.ts).- The CLI is an MCP client too, so app, agent and CLI run exactly the same code.
convex/rest.tsis the REST API (api.md): each route oflib/restRoutes.tsnames the MCP tool it runs, so it calls the same internal function with the same arguments. It adds only HTTP: problem details (lib/problem.ts),Idempotency-KeyasclientId,ETagandIf-Match(checked inside the mutation,ops.ts),X-RateLimit-*from the same per-key counters as/mcp.lib/openapi.tsbuilds the OpenAPI 3.1 document from the same table, validators anddocs/schemas;lib/toolResults.tsholds the result and error shapes both surfaces share.- Webhooks:
recordEvent(lib/core.ts), which every change of a task goes through, also queues a message for each live webhook of the user that wants its type (lib/webhookEvents.ts), in the same transaction, so a rolled-back change (a dry run, an error) sends nothing.convex/webhooks.tsdelivers it from the scheduler, signed per Standard Webhooks (lib/webhookSign.ts), to an address checked against SSRF (lib/webhookTarget.ts), and retries it with the samewebhook-id.
Data model (convex/schema.ts)
| Table | Purpose |
|---|---|
tasks |
ref (the user’s task number), title, note, dueDate (YYYY-MM-DD), optional dueTime (HH:MM), status (todo/doing/done), isNow, resumeNote, repeat, labels, optional projectId (projects, since 0.13.0), links, checklist (items id, title, done), reminders (own reminders; absent = the defaults), agent work since 0.10.0 (agent-work.md): acceptance (criteria id, text, met), plan, proof, dependsOn, blockedReason, claimedBy, claimExpiresAt; proposals since 0.11.0: proposedAt (waiting in the inbox), source (kind, ref, url), requestedBy, returnTo (webhook ids), mergedInto; completedAt, archivedAt, deletedAt, createdAt, updatedAt, clientId |
projects |
Since 0.13.0: the user’s ordered project catalog; fields in convex/schema.ts, operations below |
taskEvents |
History: actor, keyId or connectionId and actorName (the API key or agent connection behind a cli or agent change), action, changed fields before and after, at, clientId, and for task changes seq, the position in the activity log (list_events) |
eventCounters |
Last activity log position given per user (lastSeq); every task change reads and writes it, so positions follow the commit order |
taskCounters |
Last task number given per user (lastRef) |
taskSources |
Since 0.11.0: which task each outside source (kind, ref) made, so one source makes one task; re-pointed when a proposal is merged, removed when the task is purged |
approvals |
Since 0.11.0: approval requests: taskId, title (what the agent wants to do), detail, options (id, label, consequence), reversible, expiresAt, requestedBy, source (agent or policy; a policy request also has tool, argsHash, arguments, usedAt), the decision (decidedAt, optionId, note, decidedBy), clientId |
agentControls |
Since 0.11.0: the owner’s switch, one row per user: pausedAt, pausedBy, reason |
toolPolicies |
Since 0.11.0: one agent key’s rule for one write tool or *: keyId, tool, rule (allow, ask, deny) |
apiKeys |
name, kind (cli or agent), scope (read, write or full; absent = full), expiresAt, parentKeyId (the key that created it through /api/v1/keys), SHA-256 hash of the key, createdAt, revokedAt |
pairingCodes |
One-time pairing codes: public selector, SHA-256 secretHash of the secret part, createdBy, expiresAt (10 minutes), usedAt, failedAttempts |
devices |
Paired phones: name, token selector, SHA-256 secretHash, pairedBy, createdAt, lastUsedAt, revokedAt |
authLimits |
Fixed-window counters, one row per bucket: failed POST /auth/pair attempts, and the OAuth limits (oauth:*: requests per client, registrations, wrong codes typed on the phone) |
callLimits |
MCP calls per API key or agent connection in the current one-minute window: keyId or connectionId, kind (read or write), windowStart, calls |
oauthClients |
Clients from Dynamic Client Registration: clientId (helmc_…), name, redirectUris, createdAt, lastUsedAt (clients with a metadata document URL are not stored) |
oauthRequests |
Authorization requests waiting for the phone: browser handle selector + secretHash, SHA-256 of the code shown (userCodeHash), client, redirectUri, state, PKCE codeChallenge, scopes, resource, then the decision (userId, grantedScopes, approvedAt/deniedAt) and the single-use authorization code (codeHash, codeExpiresAt, codeUsedAt) |
oauthGrants |
Agent connections: userId, client (clientId, clientType, clientName), redirectUri, scopes, resource, createdAt, lastUsedAt, refreshExpiresAt, revokedAt |
oauthTokens |
Access and refresh tokens of a connection: kind, selector, SHA-256 secretHash, expiresAt, rotatedAt (refresh), scopes (after a narrowing refresh) |
calendarFeeds |
The read-only calendar feed, at most one per user: selector and secret of the address /cal/<selector><secret>.ics (the secret is kept so the address can be shown again, calendar.md), createdAt |
webhooks |
HTTPS endpoints for task events (webhooks.md): url, events, description, signing secret (whsec_…, kept to sign), failureStreak, lastSuccessAt, lastFailureAt, disabledAt, disabledReason |
webhookDeliveries |
The delivery log: one message to one webhook, eventId (the webhook-id, the same on every retry), type, the exact payload, status (pending, succeeded, failed), attempts (time, duration, HTTP status or error), nextAttemptAt, finishedAt; 30 days |
settings |
timezone (default Europe/London), summaryTime (default 05:00), defaultReminderMinutes (default [0]) |
Rules
- Today and overdue. “Today” is the calendar date in the user’s time zone. A task is
overdue when it is not done, not archived, not in the trash and its
dueDateis before today. This is decided in one place,convex/lib/dates.ts(localDate,isOverdue). Views accept an optionalat(Unix ms) so clients can re-evaluate after midnight; Convex caches queries, so a subscribed client should pass the current time. - One now task.
setNowclearsisNowon every other task of the user and moves the chosen task todoing. Leavingdoing, completing, archiving or deleting clears it. - Repeat. Completing a repeating task creates the next occurrence: the first date of the
series after the due date that is also after today (a late completion does not create an
overdue task), or, for a rule counted from completion, the first date after today. A series
ends with
untilorcount. The logic is one pure module,convex/lib/repeat.ts:advancefor completing andoccurrencesfor listing a series ahead;convex/lib/rrule.tsmaps rules to and from RFC 5545 RRULE (mcp.md). The Android app has the same logic indomain/Recurrence.kt. - The owner’s controls. Every change an agent makes (an
agentkey or an agent connection) first passesgateAgentWrite(convex/lib/agentControls.ts) inside the same mutation: paused agents are refused, a key’s tool policydenyis refused, andaskneeds an approval request decided “allow”, used up by that change. The app andclikeys are never refused. Proposals (proposedAt) are left out of Today, the board,list_all,readyand the calendar until accepted (agent-work.md). - Trash.
softDeletesetsdeletedAt;restoreclears it. A daily cron (convex/crons.ts, 03:30 UTC) permanently removes tasks deleted more than 30 days ago, with their history. - Task numbers. Adding a task (and a repeat’s next occurrence) takes the next number from
the user’s
taskCountersrow. Numbers only grow, so a purged task’s number is never given again. Every operation that takes ataskIdalso accepts"#<number>". - History. Every change writes a
taskEventsrow with the actor and only the changed fields. History is shown asapp,clioragent: <key or client name>. Settings, API key and device changes are recorded without a task id. - Idempotency. Every mutation accepts an optional
clientId. Adding a task with aclientIdthat already exists returns the existing task; any other operation whoseclientIdis already in the history is not applied again. The Android outbox relies on this.
Projects
Backend 0.13.0 adds projects in convex/lib/projectOps.ts and convex/app.ts. This is backend
support only: dedicated project MCP tools and REST routes, CLI commands and project menus in the
apps are not implemented yet. Existing task tools already accept project selectors;
JSON import and Backlog.md transfer can carry projects.
A task has at most one project; an absent tasks.projectId means no project. Labels remain free
tags across projects. The catalog is flat, ordered by position, with archived entries retained.
Names are unique per user after trimming, NFC normalization and lowercasing, including archived
projects; "none" is reserved for unassigned tasks. Field limits and allowed color tokens are
owned by PROJECT_LIMITS and
PROJECT_COLORS, not client palettes.
app:listProjectstakes no arguments and returns every project, archived ones included, without counts.app:listAllremains the full task sync snapshot, with no project selector: apps filter and count their local tasks. Internalops:listProjectsinstead takesincludeArchivedandatand returnsresultwith per-project open, overdue, due-today and done counts, plusnoProjectopen and overdue counts. Archived tasks, trash and proposals do not count.app:addProjectreturns{ project, moved, skipped }. WithfromLabel, the label’s tasks without a project move into it, including done, archived, trashed tasks and proposals. Tasks already in a project are reported as skipped, as are other agents’ live claims when the internal caller is an agent. Labels stay unlessremoveLabel: trueremoves that label from moved tasks.app:updateProjectreturns the project; optional fields left out are unchanged, andnullclears color, icon or note.positioncontrols ordering; a new project defaults to the end, after the highest position including archived projects.archived: trueexcludes the project from the defaultops:listProjectsresult, but leaves its tasks in place and visible in task views. A repeating task’s next occurrence keeps its project, even after the project is archived.app:deleteProjectis owner-only and returns{ project, moved }. If any task references it (including done, archived, trashed tasks and proposals),moveTasksTois required: another non-archived project or"none". No tasks are deleted. Bulk moves are bounded byPROJECT_LIMITS.move; move tasks individually first if deletion would exceed it.
The app mutations share the validators in args.ts and the
internal operations in ops.ts. Changes are idempotent by clientId. Project changes record
projectAdd, projectUpdate or projectDelete history without a task id, so they are absent
from list_events and emit no task webhook. Each moved task instead gets its ordinary update
history entry and task.updated webhook. Upgrade and rollback requirements are in
deploy.md.
Authentication
- Agent and CLI: personal API keys (
dayhand_+ 32 random bytes, base64url), each with a name and a kind (clioragent, defaultagent) that becomes the actor of its changes. Keys are generated in an action (mutations have a deterministic random source), shown once, and stored as SHA-256 only. A missing, wrong or revoked key gets HTTP 401. Each key may make 120 reads and 60 changes a minute over MCP (convex/callLimits.ts); a call over the limit is refused before it runs. - Agent connections (OAuth 2.1): the Claude apps and other MCP clients get an access token
(
helmat_…, one hour) and a rotating refresh token (helmrt_…, 60 days) through an authorization code with PKCE, after the owner approves the request on the paired phone by the code its sign-in page shows. Tokens are selector + secret, stored as SHA-256, bound to<issuer>/mcp, and stop at once when the connection is revoked. Protocol and limits: oauth.md; threats: oauth-threat-model.md. - App: a custom ES256 JWT. The deployment trusts the public key given in
DAYHAND_JWKS(a JWKSdata:URI) for issuerDAYHAND_JWT_ISSUER, audiencehelm;userIdis the token identifier<issuer>|<subject>. The deployment itself signs these tokens withDAYHAND_JWT_PRIVATE_JWK, an environment variable read only byPOST /auth/token(convex/lib/appToken.ts); it checks that the key matchesDAYHAND_JWKSand answers503when it does not. - Pairing:
dayhand pair(or the admin) creates a one-time code, valid 10 minutes. The phone exchanges it atPOST /auth/pairfor a long-lived, revocable device token, and the device token atPOST /auth/tokenfor an app JWT valid one hour. Codes and device tokens are selector + secret; only SHA-256 of the secret is stored and compared in constant time. Pairing failures are limited per deployment.dayhand devicesanddayhand revokemanage devices; a revoked device gets no new JWTs. Protocol: pairing.md; deployment and key migration: deploy.md. - Tokens by hand:
scripts/jwt.mjscreates a key pair and tokens for tests;scripts/app-token.mjs(npm run app-token) signs a long-lived token from a key file for scripts and local development. The Android app pairs instead (version 0.2 took such a token pasted, and keeps one stored until the phone is paired).
Reminders
Reminders and the morning summary are computed on the devices, not on the server: the server
stores only the settings (summaryTime, defaultReminderMinutes) and each task’s own
reminders (which replace the defaults for that task). Each app plans them from its local copy
of the tasks, in the time zone from these settings, with the same rules: the Android app arms
AlarmManager (android/README.md), the
macOS app leaves notifications with the system (macos/README.md)
and the Linux app rings them while it runs (linux.md).
This page is docs/architecture.md in the repository, where
you can suggest a change.