API reference

Every endpoint below is available under both /api/v1/mykal/<path> (canonical) and /v1/mykal/<path> (alias, identical behaviour, omitted from the OpenAPI schema). Authentication is done with the SHARED_AUTH bearer token or session cookie, carried in the Authorization: Bearer ... header. Tenant resolution prefers the user's own tenant_id and falls back to the X-Tenant-ID header.

All responses are JSON with UTF-8. Datetimes are ISO-8601 with a Z suffix. UUIDs are lowercase v7, 36 chars. Permission codes are documented per-endpoint; see MYKAL_ADMIN / MYKAL_SUPER_ADMIN as implicit catch-alls.

tasks

POST /api/v1/mykal/tasks

Create one routine task. Permission: MYKAL_TASK_CREATE.

{
  "title": "Draft Q3 roadmap",
  "raw_input": "Draft Q3 roadmap ~90m prio 2",
  "priority": 2,
  "duration_min": 90,
  "deadline_at": "2026-04-25T17:00:00Z",
  "reminder_channels": ["email","inapp","webhook"],
  "parent_task_id": null
}

201 → RoutineTaskRead. 409 on depth overflow (MAX_DEPTH=5) or cycle. 404 on missing parent.

GET /api/v1/mykal/tasks

List tasks. Permission: MYKAL_TASK_READ. Query: status, parent_task_id, tag, limit (1–500), offset.

GET /api/v1/mykal/tasks/{task_id}

Get a task by id. Permission: MYKAL_TASK_READ.

GET /api/v1/mykal/tasks/{task_id}/tree

Get a task and its full subtree (with rolled-up duration). Permission: MYKAL_TASK_READ.

PATCH /api/v1/mykal/tasks/{task_id}

Partial update. Permission: MYKAL_TASK_UPDATE.

DELETE /api/v1/mykal/tasks/{task_id}

Delete (DB-cascade to children). 204 No Content. Permission: MYKAL_TASK_DELETE.

POST /api/v1/mykal/tasks/{task_id}/move

Re-parent. Body {"parent_task_id": "<uuid|null>"}. Permission: MYKAL_TASK_UPDATE.

POST /api/v1/mykal/tasks/{task_id}/reorder

Set sort_order among siblings. Body {"sort_order": <int>}. Permission: MYKAL_TASK_UPDATE.

POST /api/v1/mykal/tasks/dependencies

Create a dependency edge. Body {task_id, depends_on_id, dep_type, lag_min}. 409 on cycle. Permission: MYKAL_TASK_UPDATE.

DELETE /api/v1/mykal/tasks/dependencies/{dep_id}

Remove an edge. 204. Permission: MYKAL_TASK_UPDATE.

POST /api/v1/mykal/tasks/bulk-import

Synchronous parse + optional commit (≤100 rows). 413 if over threshold (use the jobs endpoint). Permission: MYKAL_TASK_BULK_IMPORT. Caps: 5/hr, 20/day per user.

POST /api/v1/mykal/tasks/bulk-import/jobs

Async bulk import (>100 rows). Returns 202 + job_id to poll.

GET /api/v1/mykal/tasks/bulk-import/jobs/{job_id}

Poll async job status.

POST /api/v1/mykal/tasks/bulk-import/jobs/{job_id}/commit

Commit a parsed job into routine_tasks. Optional auto_schedule: true to trigger a scheduling run.

DELETE /api/v1/mykal/tasks/bulk-import/jobs/{job_id}

Cancel. 204.

GET /api/v1/mykal/routine-tasks/tree

Alias → flat list ordered for tree rendering.

PATCH /api/v1/mykal/routine-tasks/{task_id}/parent

Alias → same behavior as /tasks/{id}/move.

schedule

All require MYKAL_SCHEDULE_RUN.

POST /api/v1/mykal/schedule

Run the scheduling agent for N tasks. Sync <=10; larger is handled in the background and returns a run_id to poll. 429 if hard quota breached.

{
  "task_ids": ["01HY...","01HY..."],
  "provider": "anthropic"
}

GET /api/v1/mykal/schedule/runs/{run_id}

All decisions for a run.

GET /api/v1/mykal/schedule/runs/latest

Most-recent run for the current user.

POST /api/v1/mykal/schedule/reflow

Re-schedule a subtree after a parent block moves. Body: {root_task_id, provider?}.

GET /api/v1/mykal/schedule/decisions/{decision_id}

Single decision detail.

POST /api/v1/mykal/schedule/decisions/{decision_id}/override

User override. Appends a new decision row (never mutates history). Body {chosen_slot_start, chosen_slot_end, rationale?}.

POST /api/v1/mykal/schedule/override

Alias → same, but the decision_id is in the body.

GET /api/v1/mykal/schedule/cost/me

Today's LLM spend + quota status (daily_usd, soft, hard, allowed, breached flags).

calendar

GET /api/v1/mykal/calendar/events

Unified events (tasks + external). Query: from, to (ISO), view (day|week|month|agenda), limit. Permission: MYKAL_TASK_READ.

GET /api/v1/mykal/calendar/events/{event_id}

Event detail.

POST /api/v1/mykal/calendar/events/{event_id}/done

Mark the underlying task done. Permission: MYKAL_TASK_UPDATE.

POST /api/v1/mykal/calendar/events/{event_id}/skip

Mark skipped. Permission: MYKAL_TASK_UPDATE.

DELETE /api/v1/mykal/calendar/events/{event_id}

Unschedule (task goes pending).

GET /api/v1/mykal/calendar/free-busy

Busy blocks in a window. Required query: from, to.

integrations

Permission: MYKAL_INTEGRATION_MANAGE (catch-all: MYKAL_ADMIN).

GET /api/v1/mykal/integrations

List current user's integrations.

POST /api/v1/mykal/integrations/{provider}/oauth/start

Begin OAuth. provider is google or microsoft. Returns auth_url.

POST /api/v1/mykal/integrations/{integration_id}/sync

Trigger an immediate sync. Returns fetch/create/update/delete counts.

POST /api/v1/mykal/integrations/{integration_id}/reconnect

Start a fresh OAuth for an existing row.

DELETE /api/v1/mykal/integrations/{integration_id}

Disconnect (best-effort upstream revoke, row kept, tokens nulled, status=revoked).

The /google/notify, /microsoft/notify, and /oauth/callback endpoints are public and excluded from the schema. They exist for the calendar providers to push change-notifications and complete the OAuth handshake; they verify the provider's own token.

webhooks

Write = MYKAL_WEBHOOK_MANAGE. Read = MYKAL_WEBHOOK_READ (or manage).

GET /api/v1/mykal/webhooks

List endpoints (secret NEVER returned; only secret_preview).

POST /api/v1/mykal/webhooks

Create. Body: {name, url, description?, enabled?, max_attempts (0-8, default 4), fallback_to_email?}. Response includes the plaintext secret exactly once.

{
  "id": "01HZ...",
  "secret": "r4hR...xN8w",
  "secret_preview": "r4hR...xN8w",
  "url": "https://example.com/hook",
  "enabled": true,
  "max_attempts": 4,
  "consecutive_failures": 0,
  "created_at": "2026-04-18T12:34:56Z"
}

GET /api/v1/mykal/webhooks/{endpoint_id}

Detail (no secret).

PATCH /api/v1/mykal/webhooks/{endpoint_id}

Update name / description / url / enabled / max_attempts / fallback_to_email.

DELETE /api/v1/mykal/webhooks/{endpoint_id}

Remove. 204.

POST /api/v1/mykal/webhooks/{endpoint_id}/rotate-secret

Rotate. New plaintext returned once; old secret immediately invalid for future deliveries. consecutive_failures resets.

POST /api/v1/mykal/webhooks/{endpoint_id}/test

Fire a synthetic signed webhook.test payload. Returns {ok, status_code, latency_ms, signature, response_preview, error?}. Does NOT count against the retry budget.

reminders

Read = MYKAL_TASK_READ. Write = MYKAL_REMINDER_MANAGE.

GET /api/v1/mykal/reminders/upcoming

Next days (1–90, default 7) of reminders, capped at limit.

GET /api/v1/mykal/reminders/deliveries

Paged delivery log. Filters: status (pending|sent|failed|dead), channel (email|inapp|webhook), reminder_id.

GET /api/v1/mykal/reminders/deliveries/{delivery_id}

Delivery detail.

POST /api/v1/mykal/reminders/deliveries/{delivery_id}/redeliver

Reset status to pending, attempts=0, next_attempt_at=now.

GET /api/v1/mykal/reminders/dlq

Dead-letter queue rows for the user.

POST /api/v1/mykal/reminders/dlq/{dlq_id}/requeue

Re-insert as a fresh pending delivery and remove the DLQ row.

notifications

GET /api/v1/mykal/notifications

Paged bell feed. Filter unread_only.

PATCH /api/v1/mykal/notifications/{notif_id}/read

Mark read.

settings

All require MYKAL_SETTINGS_MANAGE.

GET /api/v1/mykal/settings/me

Return (and lazily create) the user's settings row.

PATCH /api/v1/mykal/settings/me

Partial update. Tenant-locked llm_provider_default is ignored if set.

GET /api/v1/mykal/settings/working-hours

Read working-hours JSONB (per-weekday start/end list).

PUT /api/v1/mykal/settings/working-hours

Replace working-hours.

GET /api/v1/mykal/settings/no-go-windows

List no-go windows.

POST /api/v1/mykal/settings/no-go-windows

Append one. Server generates an id if not supplied.

DELETE /api/v1/mykal/settings/no-go-windows/{window_id}

Remove by id. 204. 404 if missing.

llm + quota

All require MYKAL_SCHEDULE_RUN.

GET /api/v1/mykal/llm/usage/today

Today's per-provider cost + token counts.

GET /api/v1/mykal/llm/usage/monthly

Daily breakdown for a month. Query: month=YYYY-MM.

GET /api/v1/mykal/quota/me

Soft/hard USD caps, breach flags, allowed.

admin

All require MYKAL_ADMIN (quota patch requires MYKAL_QUOTA_OVERRIDE).

GET /api/v1/mykal/admin/health

DB, Redis, LLM registry, integrations, reminder+calendar worker status.

GET /api/v1/mykal/admin/quotas

List per-user quotas in the tenant (cap 500).

GET /api/v1/mykal/admin/quotas/{user_id}

One quota row (default 5/20 USD soft/hard, 5/hr + 20/day bulk).

PATCH /api/v1/mykal/admin/quotas/{user_id}

Raise/lower caps. soft > hard returns 422.

GET /api/v1/mykal/admin/tenant/settings

Tenant defaults (stub: stored as a UserSetting with user_id=tenant_id; to be replaced by a real mykal.tenant_settings table per DECISIONS.md D-03).

PATCH /api/v1/mykal/admin/tenant/settings

Set llm_provider_tenant_default / llm_provider_locked.

dashboard

GET /api/v1/mykal/dashboard/summary

Per-user rollup (tasks due/overdue, upcoming reminders 24h, connected integrations, today's LLM $, unread notifications). Permission: MYKAL_TASK_READ.

GET /api/v1/mykal/dashboard/tiles

Tile tree filtered by the principal's permissions.

Idempotency & rate limits

Writes are not yet natively idempotent (no Idempotency-Key header); clients should retry on 5xx with exponential backoff and dedupe on the returned id. Outbound webhook deliveries DO carry a stable X-Mykal-Delivery-Id that receivers must dedupe on. Rate limits are global-per-IP via RateLimitMiddleware: default 100/min, 1000/hr, burst 20 (see RATE_LIMITS.md).