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).
/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).