Server and integrations
Deploying Dayhand
Dayhand deploys the backend/ folder to a Convex deployment (Convex Cloud or self-hosted). The
repository holds no secrets: everything below goes into the deployment’s environment.
Run your own server in 5 minutes
One command sets up a complete Dayhand server of your own on your own free Convex account: sync for
the phone app, the MCP endpoint for AI agents, the REST API and dayhand. Nobody else runs or
maintains anything for you, and nobody else can see your tasks.
You need Node.js 22.12 or newer, on macOS, Linux or Windows. The Convex account is made (or signed in to) in your browser during the setup.
npm i -g dayhand
dayhand server deploy
npm i -g dayhand installs the command line (or brew install maiarowsky/tap/dayhand; every way:
Install). The package carries the server it deploys, the backend/ folder of this
repository, so there is nothing to clone. Because the installed package
folder may be read-only (a Homebrew keg, a global npm prefix), dayhand server runs the setup from a
copy of that backend in your user data folder: ~/Library/Application Support/dayhand-data/backend/<version>
on macOS, ${XDG_DATA_HOME:-$HOME/.local/share}/dayhand-data/backend/<version> on Linux
(relative XDG_DATA_HOME values are ignored), %LOCALAPPDATA%\dayhand-data\Data\backend\<version>
on Windows.
The copy is made once per dayhand version: dependencies are installed there when missing, and
dayhand server start also downloads the Convex local backend there once. Later runs reuse it. A new
dayhand version makes a new copy (delete the folders of old versions when you like). Your keys and
server state stay in the server’s private folder (DAYHAND_SERVER_CONFIG_DIR, below), not in the copy.
An explicit --backend <folder> (or DAYHAND_BACKEND_DIR when the flag is absent) selects a backend;
an invalid selection fails rather than falling back. Without an explicit path, the order is the
backend in the CLI’s own source checkout, a backend in the current folder or its ancestors (including
their backend/ subfolders), then the packaged backend. Checkouts run in place:
git clone https://github.com/maiarowsky/dayhand.git
cd dayhand
dayhand server deploy
Without dayhand, the same setup is cd dayhand/backend && npm run setup in a clone (it then prints the
dayhand login command to use later).
dayhand server deploy (which runs backend/scripts/setup.mjs):
- installs the backend’s dependencies from the lockfile when they are missing
(
npm ci --ignore-scripts); - logs you in to Convex with the browser (
npx convex login; skipped when this computer is already signed in); - creates a project,
dayhandby default, in your team and its production deployment (or uses the one you name with--deployment); - generates an ES256 key pair for the app’s sign-in and sets
DAYHAND_JWT_ISSUER(the server’s address,https://<deployment>.convex.site),DAYHAND_JWKSandDAYHAND_JWT_PRIVATE_JWKin the deployment’s environment, passing the values on stdin; - deploys
backend/and checks that/api/v1/openapi.json,/auth/tokenand/mcpanswer; - creates a CLI key (kind
cli) and signsdayhandin with it (OS keychain, or a private file), and makes a pairing code for the phone, valid for 10 minutes.
If you already used another server, setup stores the new server’s key but does not overwrite an existing profile. Select the printed address explicitly for subsequent commands, for example in a POSIX shell:
export DAYHAND_URL=https://<deployment>.convex.site
dayhand whoami
Replace <deployment> with the name setup prints. Set this again in a new terminal, or pass
--url https://<deployment>.convex.site to each command, including pair and key creation.
At the end it prints the server address, the pairing code with a QR code, and the next steps:
- Phone: open Dayhand, type the server address on the Connect screen and the code (or scan
the QR code). Another phone later:
dayhand pair. - dayhand: already signed in;
dayhand add "First task"anddayhandshow it works. - Claude: in the Claude app, Settings → Connectors → Add custom connector with
https://<deployment>.convex.site/mcp; in Claude Code,claude mcp add --transport http dayhand https://<deployment>.convex.site/mcp, then/mcpto sign in. Either way you approve the connection on the paired phone (oauth.md).
Options: --team <slug> (asked when you belong to several teams), --project <name>,
--deployment <name> (an existing production deployment, e.g. made in the dashboard),
--issuer <url> (only for a new server). --dry-run lists every step and command without
changing anything or using the network. Without a terminal, add --yes: every question then
gets its safe answer (keep what is there).
Where the keys are
Secrets never go into the repository or the output. They are kept in a private folder (mode 700,
files mode 600; on Windows the profile folder’s permissions): DAYHAND_SERVER_CONFIG_DIR, or
~/Library/Application Support/dayhand-server (macOS), $XDG_CONFIG_HOME/dayhand-server or
~/.config/dayhand-server (Linux), %APPDATA%\dayhand-server (Windows). cloud/ holds the Convex
server, local/ the one on this computer:
| File | What it is |
|---|---|
server.json |
The deployment, its address, the issuer and the user id (<issuer>|owner); no secrets |
signing-key.json |
A copy of the app-signing key. The deployment’s environment holds the one in use; keep the copy in a password manager or delete it |
cli-key |
The CLI key dayhand was signed in with; revoke it with dayhand key revoke if it leaks |
convex.env |
Which deployment the Convex CLI acts on (local/: the local backend’s admin key) |
local/instance-secret.json, local/data/ |
The local backend’s secret and database |
The Convex CLI keeps its own login in ~/.convex. Anyone with admin access to the deployment
(your Convex account) can read its environment variables.
Updating
Update the installed package using the same package manager you installed it with, then redeploy. With npm:
npm i -g dayhand@latest
dayhand server deploy
With Homebrew:
brew update
brew upgrade dayhand
dayhand server deploy
For a local server, stop it with Ctrl+C before updating and run dayhand server start instead of
dayhand server deploy, with the same options as before (including --public-url if used). The new
CLI version stages its own backend copy; it reuses the server’s private folder, keys and data.
Run package-installed commands outside any checkout or staged backend directory so that an older
backend cannot win.
Checkout alternative: from the repository root, update the checkout and rebuild its linked CLI:
git pull
(cd cli && npm ci --ignore-scripts && npm run build)
dayhand server deploy
Without dayhand, run cd backend && npm run setup after git pull instead (npm run setup -- --local
for a local server).
A second run deploys the new code and keeps everything else: the deployment, the keys, the CLI
key (while it still works) and the data. It never replaces DAYHAND_JWT_ISSUER (it is part of every
user id, so a new one would hide your tasks) and asks before replacing a signing key the
deployment already has. Read the “Upgrading to” section of the new version below first: some
versions need a step after the deploy.
What it costs
Nothing, on Convex’s Free plan. Its limits per team, as published on convex.dev/pricing and docs.convex.dev/production/state/limits (checked 2026-10-23; check them again, they change):
| Resource | Free plan |
|---|---|
| Function calls (app sync, MCP and API requests, scheduled jobs) | 1,000,000 a month |
| Database storage | 0.5 GB |
| Database I/O | 1 GB a month |
| File storage (Dayhand stores no files; backups count) | 1 GB |
| Data egress | 1 GB a month |
| Action compute | 20 GB-hours a month |
| Deployments | 40 per team |
| Developers | 1 to 6 |
On Free the limits are hard caps: once one is reached, new writes may fail; nothing is billed. The paid Starter plan instead charges for usage above the same amounts. One person’s tasks are small next to these numbers; the Convex dashboard shows what you use.
Backups
For an npm or Homebrew installation, use the writable backend copy made by dayhand server deploy
or dayhand server start, not the installed package directory. <backend-copy> is the path printed
as Using the Dayhand backend in … during setup, for the version shown by dayhand --version.
The platform-specific locations are listed in server setup.
Export the data (tasks, history, keys’ hashes, devices) to an absolute zip-file path outside the backend copy and the server’s private folder:
cd "<backend-copy>"
npx convex export --env-file "<folder>/cloud/convex.env" --path "<backup-file>.zip"
Replace <backend-copy> with the full path (expand ~ to your home directory when quoting it),
<folder> with the server’s private folder from Where the keys are, and
<backup-file> with the absolute destination path without .zip.
Checkout alternative: from the repository root, cd backend, then run the same export and
import commands there instead. Keep the zip outside the repository too.
To restore into the same deployment, from the same backend directory:
npx convex import --env-file "<folder>/cloud/convex.env" --replace-all "<backup-file>.zip"
This drops changes made since the export. For a local server, keep dayhand server start running
and use <folder>/local/convex.env instead of <folder>/cloud/convex.env for both export and import.
Into a new deployment, keep the same DAYHAND_JWT_ISSUER (from server.json),
or the restored tasks belong to a user nobody signs in as: with a new DAYHAND_SERVER_CONFIG_DIR,
run dayhand server deploy --deployment <new> --issuer <old issuer>, import the zip with that
folder’s convex.env, run dayhand server deploy once more (the import replaced the keys table, so
it makes a new CLI key) and pair the phone again. The environment variables are not in the
export; the signing key can be new.
On this computer, without an account
dayhand server start # or: cd backend && npm run setup -- --local
The same setup with the Convex local backend binary on this computer (macOS or Linux, x64 or
arm64; not Windows), downloaded once from Convex’s GitHub releases, version pinned and
checksum verified. The binary is source-available under the FSL-1.1-Apache-2.0 licence (each
release becomes Apache 2.0 two years later), not open source. The server runs until Ctrl+C;
the same command starts it again with its data and keys (local/ in the private folder). It
listens on 127.0.0.1 only (ports 3210 and 3211, --port and --site-port to change them),
so the phone cannot reach it as it is. The Claude apps need the public HTTPS server above;
Claude Code on the same computer can use http://127.0.0.1:3211/mcp with an agent key
(mcp.md).
Phone and computer (Tailscale)
A release build of the Android app talks to its server over HTTPS only. To use a phone with the
server on your computer, put both in a Tailscale network (tailnet):
tailscale serve gives the computer an HTTPS address with a certificate inside the tailnet.
-
Install Tailscale on the computer and the phone and sign both in to the same tailnet. In the admin console turn on MagicDNS and HTTPS certificates. The computer’s name is then
<machine>.<tailnet>.ts.net. -
On the computer, publish the HTTP actions (pairing, MCP) on port 443 and the Convex API (the app’s sync) on port 8443, inside the tailnet only:
tailscale serve --bg --https=443 http://127.0.0.1:3211 tailscale serve --bg --https=8443 http://127.0.0.1:3210 -
If the server is already running, stop it with Ctrl+C first. Start it again with that address, keeping the same data and keys, so it gives the phone the HTTPS addresses instead of 127.0.0.1:
dayhand server start --public-url https://<machine>.<tailnet>.ts.net(
--public-api-urlwhen the Convex API is not on port 8443.) Then pair the phone with the printed address and code. In a second terminal, select that address for later CLI commands (POSIX shell), since an existing profile can still point at 127.0.0.1:export DAYHAND_URL=https://<machine>.<tailnet>.ts.net dayhand whoami
tailscale serve status shows what is published; tailscale serve reset stops it. The phone
syncs only while the computer is on and in the tailnet. This setup has not been tested by the
project yet; if the app does not sync, check that the phone opens
https://<machine>.<tailnet>.ts.net/api/v1/openapi.json.
Environment variables
| Variable | Value | Read by |
|---|---|---|
DAYHAND_JWT_ISSUER |
Issuer of app tokens, e.g. https://dayhand.example |
auth.config.ts, token signing |
DAYHAND_JWKS |
Public key(s) as a JWKS data URI (data:application/json;base64,…) |
auth.config.ts (Convex verifies app JWTs) |
DAYHAND_JWT_PRIVATE_JWK |
Private ES256 key as a JWK with kid; its public half must be in DAYHAND_JWKS under the same kid |
POST /auth/token only; never returned |
DAYHAND_MCP_ALLOWED_ORIGINS |
Optional. Comma-separated browser origins allowed to call /mcp, e.g. https://inspector.example. Unset: any request with an Origin header gets 403 |
POST /mcp |
DAYHAND_PUBLIC_URL |
Optional. The URL users add in Claude, without /mcp, when it is not the Convex site URL (a custom domain). Unset: CONVEX_SITE_URL |
OAuth issuer and resource (oauth.md) |
DAYHAND_OAUTH_CIMD_ORIGINS |
Optional. Comma-separated origins whose OAuth client metadata documents are fetched. Unset: https://claude.ai; production should leave it unset |
GET /oauth/authorize |
DAYHAND_WEBHOOK_ALLOWED_ORIGINS |
Optional. Comma-separated origins exempt from the webhook address rules (HTTPS, public hosts), e.g. a receiver on a self-hosted deployment’s own network. Unset: none; production should leave it unset | Webhooks (webhooks.md) |
DAYHAND_WEBHOOK_RETRY_DELAYS |
Optional. Comma-separated seconds before each webhook retry. Unset: 5,300,1800,7200,18000,36000,36000 |
Webhook delivery |
App users are <DAYHAND_JWT_ISSUER>|<subject> (single-user deployments use owner as subject).
Personal API keys for the CLI and agent should belong to that same user, so the phone, the CLI
and the agent see one task list.
Renamed variables
Before 0.9.0 these variables were called HELM_* (HELM_JWT_ISSUER, HELM_JWKS,
HELM_JWT_PRIVATE_JWK and so on). The backend still reads HELM_<name> while
DAYHAND_<name> is unset, and logs once per variable that it is deprecated (npx convex logs).
dayhand server deploy and npm run setup leave the old names in place and say which ones the
deployment still uses. Moving to the new names is optional; to do it, copy each value under its
new name, deploy, check, then remove the old one:
cd backend
npx convex env list # which HELM_* names the deployment has
for name in JWT_ISSUER JWKS JWT_PRIVATE_JWK; do
npx convex env get HELM_$name | npx convex env set DAYHAND_$name
done
npx convex deploy # Convex reads auth.config.ts at deploy time
Do the same for the optional variables the list shows. The issuer must stay exactly the same
value (it is part of every user id). Check that the phone still syncs and dayhand whoami
works, then remove the old names (npx convex env remove HELM_JWT_ISSUER and so on) and deploy
once more. Before rolling back to 0.8.x, put the HELM_* names back: 0.8.x reads only those.
First deployment by hand
What npm run setup does, step by step, for a deployment you manage yourself:
cd backend
node scripts/signing-key.mjs new ~/dayhand-signing-key.json # mode 600, keep it out of the repo
npx convex env set DAYHAND_JWT_ISSUER https://dayhand.example
npx convex env set DAYHAND_JWKS "$(node scripts/signing-key.mjs jwks ~/dayhand-signing-key.json)"
node scripts/signing-key.mjs private ~/dayhand-signing-key.json | npx convex env set DAYHAND_JWT_PRIVATE_JWK
npx convex deploy
npx convex run apiKeys:create '{"userId":"https://dayhand.example|owner","actor":"app","name":"cli","kind":"cli"}'
signing-key.mjs private refuses to print to a terminal; piping into npx convex env set NAME
(no value argument) keeps the key out of shell history and process lists.
Upgrading to 0.13.0
0.13.0 adds backend project support. There is no new status, error code, secret or webhook event, and no forward migration: existing tasks and labels stay as they are. For task selectors see MCP; transfer rules are in JSON import and Backlog.md.
- Take a snapshot to roll back to:
npx convex export --path dayhand-before-0.13.0.zip. - Deploy:
dayhand server deploy(ornpx convex deploy). - Check:
dayhand versionshows server 0.13.0.
Older clients keep working: they ignore the field they do not know, and their changes send only what changed, so they never take a task out of its project.
Rollback: 0.12.x’s schema does not know projectId, so Convex refuses to deploy it while any task
has one (“Object contains extra field projectId”). Turn the projects back into labels first.
Every writer must be stopped: the apps, agents, REST and MCP clients. Keep them stopped from
before starting migrations:projectsToLabels until 0.12.x is fully deployed, inclusive.
npx convex run migrations:projectsToLabels: every task in a project gets a label with the project’s name and loses itsprojectId, then the projects are deleted. A task that already has 20 labels keeps them and loses only the project; the deployment’s logs name each one (npx convex logs). Nothing enters the history. It works in batches and schedules itself.- Wait until
npx convex run migrations:statusshowstasksWithProject: 0andprojects: 0. The starting call’sdonedescribes only its own batch, not the scheduled run: with more than 200 tasks, even a repeated scan that changes nothing starts withdone: false. - After it finishes, run
npx convex run migrations:projectsToLabels '{"projects":true}'to repeat only the project-deletion phase. Never use this argument before step 2: it skips the task scan. Requiredone: true,cleared: 0andprojectsDeleted: 0, then checkmigrations:statusagain fortasksWithProject: 0andprojects: 0. If either check fails, repeat from step 1, keeping every writer stopped. - Only then deploy 0.12.x. Keep every writer stopped until this deployment has finished.
To go back to exactly the state before the upgrade instead, restore the snapshot from step 1,
npx convex import --replace-all dayhand-before-0.13.0.zip (this also drops every change made
since), then deploy 0.12.x.
Upgrading to 0.12.0
0.12.0 adds the phase guides dayhand://workflow/intake, /execution and /finalization, the
agent_brief tool and Backlog.md import and export (import_backlog, export_backlog;
backlog-md.md), with their REST routes /brief, /import/backlog and
/export/backlog. There is no new table, field, status, error code or secret, and no migration:
imported tasks use the source field and the taskSources table of 0.11.0.
- Deploy:
dayhand server deploy(ornpx convex deploy). - Check:
dayhand versionshows server 0.12.0 anddayhand primeprints an “Agent brief” section.
Rollback: deploy 0.11.x again; nothing needs restoring. Tasks imported from Backlog.md stay ordinary tasks with a source.
Upgrading to 0.11.0
0.11.0 lets the owner stay in charge of agents (agent-work.md): approval requests
decided on the phone, a switch that pauses every agent, a tool policy per agent key, a proposal
inbox, and the plan health report. Everything is new tools, routes, optional task fields
(proposedAt, source, requestedBy, returnTo, mergedInto), error codes (agents_paused,
forbidden, approval_required), webhook events (approval.decided, task.returned) and four
tables (approvals, agentControls, toolPolicies, taskSources). There is no migration, no new
status and no new secret; nothing changes until the owner uses a control or an agent proposes a
task. Older clients keep working: they do not see proposals (which stay out of every list they
read), and an old agent that gets agents_paused treats it like any unknown error.
- Take a snapshot to roll back to:
npx convex export --path dayhand-before-0.11.0.zip. - Deploy:
dayhand server deploy(ornpx convex deploy). - Check:
dayhand versionshows server 0.11.0,dayhand agentsprintsAgents run, anddayhand healthprints the report.
Two things behave differently: tools/list of an agent (an agent key or an agent connection)
leaves out the owner’s tools (deciding approvals, pausing agents, policies, accepting proposals),
and is marked cacheScope: "private" on 2026-07-28. A webhook added without events gets the
same task events as before; approval.decided has to be asked for.
Rollback: 0.10.x’s schema does not know the new tables and fields, so Convex refuses to deploy it
while any of them hold data. Restore the snapshot from step 1 first,
npx convex import --replace-all dayhand-before-0.11.0.zip (this also drops every change made
since), then deploy 0.10.x. Agents paused at that moment run again after the rollback.
Upgrading to 0.10.0
0.10.0 adds agent work (agent-work.md): the activity log (list_events,
dayhand://activity, GET /api/v1/events), acceptance criteria, a plan and proof on tasks,
dependencies with a blocked reason and list_all ready, and time-boxed claims with the new error
code conflict. Everything is new, optional fields, tools, routes and one table
(eventCounters); no field changes its meaning, there is no new status and no new secret. Older
clients keep working; they just do not see the new fields.
One migration numbers the history from before 0.10.0 into the activity log. Until it has run,
list_events shows only changes made after the deploy; nothing else depends on it.
- Take a snapshot to roll back to:
npx convex export --path dayhand-before-0.10.0.zip. - Deploy:
dayhand server deploy(ornpx convex deploy). - Number the old history:
npx convex run migrations:backfillEventSeqs. It works in batches and schedules itself;npx convex run migrations:statusshowstaskEventsWithoutSeq: 0when it is done. Running it again changes nothing. - Check:
dayhand versionshows server 0.10.0 anddayhand log --since 0 --limit 5lists the oldest changes.
Agents get the new behaviour only when they use it: an agent completing a task that has acceptance
criteria needs them met and a proof summary, and another agent’s claim refuses its changes with
conflict. The app and cli keys are never refused. The Android app 0.9.1 shows a claim, the
criteria and the proof in a task’s details; older apps ignore the new fields.
Rollback: 0.9.x’s schema does not know the new fields, so Convex refuses to deploy it while any
task has them or any history row has seq (which the migration gives every old row). Restore the
snapshot from step 1 first, npx convex import --replace-all dayhand-before-0.10.0.zip (this also
drops every change made since), then deploy 0.9.x. To keep those changes, roll forward with a fix
instead.
Upgrading to 0.9.0
0.9.0 renames Helm to Dayhand. There is no schema change, no migration and no new secret: tasks, API keys, paired phones, agent connections and calendar addresses keep working, and every old name below is still accepted:
- the variables are
DAYHAND_*; theHELM_*ones are read while the new ones are unset (Renamed variables); - MCP
serverInfo.nameisdayhandand the resources aredayhand://today,dayhand://guideanddayhand://tasks/{ref}(helm://URIs are still read); - new API keys start with
dayhand_(helm_keys keep working: keys are found by their hash); dayhand pairprintsdayhand://pairlinks. The Android app from 0.9.0 and the macOS app open both forms; an older Android app opens onlyhelm://pair, so type the code there;- the import format is
dayhand-json(helm-jsonstill accepted), the calendar export filedayhand.ics, and the feed’s events sayDayhand #<number>(their UIDs stay…@helm, so calendars do not show them twice); WWW-Authenticatesaysrealm="dayhand", the OpenAPI documentx-dayhand-scope, and problem types are underhttps://schemas.dayhand.invalid/; a client that compares these strings exactly needs the new ones;- the old
helmcommand ofbackend/(npx helm) is gone; usedayhandfromcli/.
- Take a snapshot to roll back to:
npx convex export --path dayhand-before-0.9.0.zip. - Deploy:
dayhand server deploy(ornpx convex deploy). - Check:
dayhand versionshows server 0.9.0, the phone syncs, andnpx convex logsshows one deprecation line for eachHELM_*variable still in use.
Rollback: deploy 0.8.x again. Nothing in the data changed, and dayhand_ keys work there too;
only the HELM_* variables must be in place (see Renamed variables).
The sections below were written before the rename: hlm is now dayhand, the helm command
of backend/ is gone, and their HELM_* and HLM_* variables are now DAYHAND_*.
Upgrading to 0.8.0
0.8.0 adds the REST API for integrations (api.md), webhooks (webhooks.md), import, and API keys with a scope and an expiry date:
- new optional fields in
apiKeys:scope,expiresAt,parentKeyId; - new tables
webhooksandwebhookDeliveries, and a daily cron (webhooks.purgeDeliveries, 04:15 UTC) that drops delivery log entries older than 30 days; - new routes under
/api/v1/(/api/v1/openapi.jsonis public); - the optional variables
HELM_WEBHOOK_ALLOWED_ORIGINSandHELM_WEBHOOK_RETRY_DELAYS(leave both unset in production).
Every new field is optional and every key without a scope is a full key, so there is no
migration and no new secret: existing keys, the app, agents and hlm 0.7 keep working.
Nothing sends webhooks until a user adds one. One behaviour tightens: /devices*,
/connections* and /calendar/feed* now need a full key; since every existing key is full,
this only affects the new read and write keys.
-
Take a snapshot to roll back to:
npx convex export --path helm-before-0.8.0.zip(it holds the user’s tasks; keep it out of the repository). -
Deploy:
npx convex deploy. -
Check, with the key
hlmuses (hlm whoamishows the URL):curl -s https://<name>.convex.site/api/v1/openapi.json | head -c 200 # public, "openapi": "3.1.0" curl -s -o /dev/null -w '%{http_code}\n' https://<name>.convex.site/api/v1/today \ -H "Authorization: Bearer $HLM_API_KEY" # 200 curl -s -o /dev/null -w '%{http_code}\n' https://<name>.convex.site/api/v1/today # 401npx convex logsshows any error. The MCP endpoint reportsserverInfo.version0.8.0. -
Optional: give each integration its own key with the smallest scope and an expiry, from a full key (
hlm key create n8n --scope read --expires 90d, hlm 0.8.0) or as the admin (npx convex run apiKeys:create '{"userId":"<issuer>|owner","actor":"app","name":"n8n","scope":"read","expiresAt":<ms>}').
Rollback: first remove what 0.7.0 does not know, then deploy 0.7.0 again.
- Remove the webhooks (
hlm webhook rm <id> --yesfor each, which also deletes their delivery logs) and revoke the keys that have a scope or an expiry date, or were created with a key (hlm key list,hlm key revoke <id>). - Deploy 0.7.0. Its schema has no
webhooks,webhookDeliveries,scope,expiresAtorparentKeyId, so Convex refuses the push while any row has them. Empty a table the same way ascallLimitsfor 0.4.0 below (npx convex import --table webhookDeliveries --replace --yes empty.jsonl, likewisewebhooks). A revoked key still carries itsscope: delete those rows by hand (npx convex data apiKeysshows them) or restore the snapshot from step 1 of the upgrade (npx convex import --replace-all helm-before-0.8.0.zip, which drops changes made since). - Tasks created by an import and history entries with the actions
import,webhook.*andapiKey.*are ordinary rows that 0.7.0 reads as they are; nothing else needs to change. Integrations that use/api/v1then get404until 0.8.0 is back.
Upgrading to 0.7.0
0.7.0 adds the read-only calendar feed (calendar.md): the table calendarFeeds,
the routes GET /cal/<token>.ics and /calendar/feed*, and the format ics of export_plan.
Nothing existing changes, so there is no migration and no new environment variable:
npx convex deploy is enough. The feed URL is built from CONVEX_SITE_URL, which Convex sets
itself. The feed is off for every user until they turn it on (Android 0.8.0 Settings →
Calendar, or hlm calendar on with hlm 0.7.0); older apps and hlm keep working without it.
Rollback: turn the feeds off (hlm calendar off --yes), then deploy 0.6.0 again. Its schema
has no calendarFeeds; if Convex refuses the push because the table holds rows, empty it the
same way as callLimits for 0.4.0 below. Subscribed calendars then get 404 and keep their
last copy.
Upgrading to 0.6.0
0.6.0 adds OAuth for the Claude apps (oauth.md): the tables oauthClients,
oauthRequests, oauthGrants and oauthTokens, the optional field connectionId in
taskEvents and callLimits (where keyId becomes optional), and a daily cron
(oauth.purgeExpired, 04:00 UTC). Existing rows stay valid, so there is no migration and no
new secret: npx convex deploy is enough. API keys, the app and hlm 0.5 keep working; the
only visible change for them is a longer WWW-Authenticate header on 401 from /mcp.
Connecting the Claude app afterwards is a manual step done once on the phone
(oauth.md). The Android app needs 0.7.0
for Settings → Agent connections; older apps keep working without it.
Rollback: revoke the connections first (hlm connection revoke <id>), then deploy 0.5.0
again. Its schema has no OAuth tables and no connectionId; if Convex refuses the push because
of rows in them, empty the four oauth* tables and callLimits the same way as for 0.4.0
below. History entries written through a connection keep their connectionId and would block
the push too; they are rare enough to delete by hand (npx convex data taskEvents), or stay on
0.6.0.
Upgrading to 0.5.0
0.5.0 adds checklist and reminders to tasks and new optional fields to repeat (from,
monthDay, weekdayOfMonth, until, count). All of them are optional in the schema, so the
deploy accepts existing data and needs no migration: a task without them repeats, rings and
exports as before. Deploy with npx convex deploy.
Clients: the CLI and the agent keep working unchanged. An app older than 0.6.0 shows repeat rules with the new fields as their plain unit and does not show checklists or per-task reminders; changing such a task’s repeat rule in that app replaces the rule with what it shows.
Rollback: deploying 0.4.0 again fails schema validation once any task uses a new field. Roll
forward with a fix, or remove the new fields from those tasks first (or restore a snapshot taken
before the deploy with npx convex export).
Upgrading to 0.4.0
0.4.0 adds the table callLimits (MCP calls per key and minute) and changes nothing in the
existing tables, so there is no migration: npx convex deploy is enough, and 0.3 clients (the
app, hlm 0.3, agents) keep working. Calls are now limited per key (120 reads and 60 changes a
minute), and /mcp refuses requests with a browser Origin that is not in
HELM_MCP_ALLOWED_ORIGINS; MCP clients outside a browser send no Origin.
Rollback: deploy 0.3.0 again. Its schema does not define callLimits; if Convex refuses the
push because that table holds rows, empty it first (npx convex import --table callLimits --replace --yes empty.jsonl with an empty file). The rows only count calls, so nothing is lost.
Upgrading to 0.3.0
0.3.0 adds task numbers (tasks.ref, new table taskCounters) and key kinds (apiKeys.kind,
taskEvents.keyId and actorName). All new fields are optional in the schema, so the deploy
accepts existing data, and the app, the agent and old API keys keep working before the
migration runs. Until then, tasks created before the upgrade have no number (#n does not
find them) and old keys count as agent keys.
-
Take a snapshot to roll back to:
npx convex export --path helm-before-0.3.0.zip(it holds the user’s tasks; keep it out of the repository). -
Deploy:
npx convex deploy. -
Right after, number the existing tasks (oldest first, per user; runs in batches of 200 and schedules itself until done) and mark the existing keys as
agentkeys:npx convex run migrations:backfillTaskRefs npx convex run migrations:backfillKeyKindsRun it right away: tasks added between the deploy and the migration get numbers first, so older tasks get the numbers after them. Both migrations are idempotent and can be run again.
-
Check that nothing is left:
npx convex run migrations:statusmust showkeysWithoutKind: 0andtasksWithoutRef: 0(later backends return additional counters). IftasksWithoutRefis not 0 yet, the scheduled batches are still running; check again after a few seconds. -
If the key the user types commands with is a CLI key, make a new one with
"kind": "cli"(see mcp.md), switch the CLI to it and revoke the old one, so the history showscliinstead ofagent: <name>. Kinds cannot be changed on an existing key.
Rollback: deploying 0.2.0 again over migrated data fails schema validation (ref, kind,
actor: "cli", keyId and actorName are unknown to it). Roll forward with a fix instead, or
restore the snapshot from step 0 (npx convex import --replace-all helm-before-0.3.0.zip,
which drops changes made since) and deploy 0.2.0.
Moving an existing key from disk into the deployment
For a deployment that already trusts a key pair kept on a computer (the private half was used to sign app tokens locally):
-
Check that the deployment publishes the key you hold:
npx convex env get DAYHAND_JWKS | node scripts/signing-key.mjs check /path/to/key.jsonIt must print
OK: DAYHAND_JWKS publishes this key (kid …). The file may hold the private JWK itself or{ "privateJwk": … }, as written bynpm run app-token -- --generateandscripts/signing-key.mjs new. -
Set the private key, then deploy the code with pairing:
node scripts/signing-key.mjs private /path/to/key.json | npx convex env set DAYHAND_JWT_PRIVATE_JWK npx convex deploy -
Check the configuration without a real device: a bogus device token must get
401(configured), not503(not configured):curl -s -X POST https://<name>.convex.site/auth/token \ -H 'Content-Type: application/json' -d '{"deviceToken":"helmdev_x.y"}' # {"error":"invalid_grant",…}On
503,npx convex logsshows the reason (missing variable, or a key that does not matchDAYHAND_JWKS). -
Check that the CLI’s API key belongs to the app user:
npx convex data apiKeysmust showuserId=<DAYHAND_JWT_ISSUER>|owner. Thendayhand pairworks and the phone sees the same tasks. -
Paste-in tokens issued earlier with
npm run app-tokenkeep working until they expire (same key). Pair the phone instead, then stop issuing them. -
Move the key file off the disk into an offline backup (a password manager), or delete it. The deployment environment is now the only place that needs it. Anyone with admin access to the deployment can read its environment variables.
Rotating the signing key
node scripts/signing-key.mjs new new-key.json.- Publish both public keys for an hour: set
DAYHAND_JWKSto a data URI whosekeysarray holds the old and the new key, and deploy (auth.config.tsis read at deploy time). - Pipe the new private key into
DAYHAND_JWT_PRIVATE_JWK; new tokens are signed with it. - After an hour (the token lifetime), set
DAYHAND_JWKSto the new key only and deploy again.
Paired devices keep working through a rotation: their device tokens do not depend on the key.
This page is docs/deploy.md in the repository, where
you can suggest a change.