pt serve exposes a small Todoist-style sync surface plus a one-field
/capture ingest point. The full surface:
| Endpoint | Method | Use |
|---|---|---|
/healthz |
GET | liveness |
/version |
GET | JSON with crate versions |
/sync |
POST | command + delta sync |
/next |
GET | DAG-ready tasks (?limit=N); read-token gated (v1.9.0) |
/detail/{uuid} |
GET | one task's side-table detail; read-token gated (v1.9.0) |
/resolve |
GET | server-side PT-N/title lookup (?query=...&include_terminal=false); read-token gated (v1.12.0) |
/capture |
POST | one-shot raw-text ingest |
/email |
POST | raw RFC 822 email ingest |
/webhook/gitea |
POST | HMAC-signed Fixes PT-N parser |
/webhook/github |
POST | as above, GitHub HMAC |
/metrics |
GET | Prometheus exposition |
Loopback pt serve binds keep the original local-dev mode and accept requests
without application credentials until a token is configured: PTASK_API_TOKEN,
PTASK_METRICS_TOKEN, or any unrevoked named token (pt token create) closes
anonymous access. While anonymous access is open, a request is served only
when its Host names the server itself (IP literal, localhost, the machine's short
hostname, *.ts.net, PTASK_DASH_ALLOWED_HOSTS), because a DNS-rebinding
page carries no credential either. A .suffix entry does not match the apex
name itself (list it separately), a request with no Host header passes, and
pt serve learns the machine's short hostname only on Linux. Tokenless clients
that address pt serve by another DNS name (a reverse proxy forwarding its own
Host, a LAN name in PTASK_SYNC_URL) need that name in
PTASK_DASH_ALLOWED_HOSTS. Because the machine APIs and the always-
mounted dashboard use separate auth schemes, non-loopback binds fail closed
unless machine-API auth (PTASK_API_TOKEN or a named token) and
PTASK_DASH_PASS are both set, and a non-loopback listener never serves
anonymous callers, even after its last named token is revoked.
PTASK_ALLOW_UNAUTHENTICATED=1 is an explicit test-only override for isolated
deployments.
Machine-API auth (the env token or a named token) gates POST /sync, POST /capture,
POST /capture/resolve, POST /email, POST /tg/callback, and the read APIs
(GET /next, GET /detail/{uuid}, GET /resolve, GET /list,
GET /metrics). The /mcp mount separately requires a non-revoked named
hal token with write scope; the legacy environment token is not accepted
there. /healthz, /version, and HMAC-verified git webhooks do not use the
machine-API bearer token.
When configured, clients must send one of:
Authorization: Bearer <PTASK_API_TOKEN>
X-PTask-Token: <PTASK_API_TOKEN>
pt remote automatically forwards PTASK_API_TOKEN as a bearer token when
the environment variable is set on the client node.
{
"sync_token": "42",
"resource_types": ["tasks"],
"commands": [
{
"type": "task_create",
"uuid": "<idempotency-key>",
"temp_id": "client-side-temp",
"args": { "text": "buy bread tomorrow 10am @home p1 ~30m" }
},
{
"type": "task_done",
"uuid": "<idempotency-key>",
"args": { "pt_id": "PT-42" }
}
]
}| Field | Notes |
|---|---|
sync_token |
"*", "", or absent → full sync. Otherwise an opaque integer string from a prior response. |
resource_types |
advisory; ["tasks"] is the only meaningful value today. |
commands |
optional; pure read if empty. |
commands[].uuid |
client-generated, idempotency key, scoped to the authenticated client. Replaying the same command (same type, args and temp_id) returns "ok" (with its temp_id_mapping) without re-applying; the same uuid with a different type, args or temp_id is a per-command error and nothing is applied. A uuid starting with capture (any case) is reserved for the capture lane and is a per-command error. |
commands[].temp_id |
optional client-side handle; mapped to the real task_uuid in the response. |
{
"sync_token": "47",
"resources": { "tasks": [<Task>, ...] },
"sync_status": {
"<command-uuid>": "ok"
| { "error": "<message>" }
},
"temp_id_mapping": { "<temp_id>": "<real-task-uuid>" },
"deleted_task_uuids": ["<task-uuid>", ...]
}resources.taskscarries the delta: full task set on full sync, changed-since-sync_token on incremental.deleted_task_uuidsare tombstones: tasks hard-deleted (task_delete,pt delete) sincesync_token. Drop them from the local copy. Always empty on a full sync, whose task set replaces client state wholesale.sync_tokenis the new monotonic cursor (currentpt_event_log.id).
type |
args |
Side effects |
|---|---|---|
task_create |
{ text, source_type? } |
runs quick-add parser, inserts to tasks + pt_extensions, optional pt_recurrence. |
task_done |
{ task_uuid } or { pt_id }, optional expected_deadline, optional note |
flips status to done or advances recurrence in-place, logs an interaction row. A done task is refused. With expected_deadline (the deadline the client last saw; "" = none) the command fails, changing nothing, if the task has moved on — so two queued completions of one occurrence never advance a recurring task twice. Omitted, the current occurrence completes as before. note (v3.43.0, string, at most 16 KiB) is closure evidence journaled in the completion event itself; a blank or non-string note fails the command without closing. |
task_priority (v1.8.0) |
{ task_uuid | pt_id, priority } |
sets priority (1..=5), logs a priority_change interaction, rescores. |
task_edit (v1.8.0) |
{ task_uuid | pt_id, deadline } |
sets the deadline (ISO string) or clears it (JSON null); other JSON types or an omitted deadline are rejected without mutation; rescores. |
task_reopen (v1.8.0) |
{ task_uuid | pt_id } |
flips a done/dismissed task back to pending (logs the neglect-score reopen signal). Resets acceptance-criteria checks so the definition of done must be met again. |
task_retext (v1.9.0) |
{ task_uuid | pt_id, title?, description? } |
replaces the title and/or description (at least one required). |
task_dismiss (v1.10.0) |
{ task_uuid | pt_id }, optional note |
soft-closes a task (status → dismissed); reversible via task_reopen. note (v3.43.0) is the reason, journaled in the dismissal event. |
task_note (v3.43.0) |
{ task_uuid | pt_id, text } |
appends a note (findings, evidence, a handover; at most 16 KiB, blank refused) as a task.noted event attributed to the client. Any status: evidence can arrive after the close. GET /detail/{uuid} returns the trail as notes. |
task_start (v1.10.0) |
{ task_uuid | pt_id } |
status → in_progress. |
task_snooze (v1.10.0) |
{ task_uuid | pt_id, until } |
snoozes until the ISO until. |
task_depend (v1.10.0) |
{ task_uuid | pt_id, on, clear? } |
adds (or with clear: true removes) a depends_on edge to the on query (PT-N or title); cycles are rejected. |
task_delete (v1.10.0) |
{ task_uuid | pt_id } |
hard-deletes the task row and its side-table rows (labels, links, recurrence, interactions history) — not reversible. A task.deleted tombstone stays in pt_event_log and reaches other clients as deleted_task_uuids. Use task_dismiss for a reversible close. |
Each command records exactly one event keyed on its uuid, so /sync replays
are idempotent. More commands (task_delete, view_save, …) are backward-
compatible additions; the wire format is stable.
Server-side lookup for remote clients that need a single task_uuid before
issuing a mutation. This avoids full-syncing the entire task table for
pt remote done|edit|priority|dismiss|reopen|show.
GET /resolve?query=PT-42&include_terminal=false
GET /resolve?query=archive%20receipt&include_terminal=true
Semantics:
PT-Nor bare integerNmatches the exact PT id across any status.- Other queries perform a case-insensitive title substring search.
include_terminal=falseexcludesdoneanddismissedtitle matches.include_terminal=trueincludes all statuses for read/reopen flows.
Response:
{ "task": <Task> }Status codes: 200 one match, 400 empty query, 404 no match, 409
multiple substring matches.
{ "text": "...", "source": "telegram|email|cli|..." }Drops into raw_items for the distillation pipeline. Returns:
{ "id": 123, "source_type": "telegram", "source_date": "2026-06-25" }Accepts a raw RFC 822 message body (message/rfc822 or text/plain); parses
subject/body into one raw_items row with source_type="email". Bodies over
2 MiB get 413. An embedded message in base64 or quoted-printable (which RFC
2046 forbids but Exchange-style gateways send) is decoded and checked like
any other; more than 2 such encoded layers, or embedded messages nested more
than 32 deep counting decoded ones, get 400 and nothing is stored. At most 4
messages are parsed at once; beyond that the answer is 503 with
Retry-After: 5. Returns:
{ "id": 123, "subject": "Subject line", "source_file": "email:<message-id>" }POST /webhook/{gitea,github} parses pushed commit messages for
Fixes PT-N / Closes PT-N directives and marks matching tasks done.
Ref PT-N and Skip PT-N are recognised by the magic-word parser but do
not close tasks.
HMAC verification: the secret comes from PTASK_GITEA_WEBHOOK_SECRET /
PTASK_GITHUB_WEBHOOK_SECRET. Body signature is X-Hub-Signature-256
(GitHub) or X-Gitea-Signature (Gitea).
One delivery closes at most 20 distinct PT-N; the rest are counted in
skipped_count, the first 100 of them are listed under skipped, and the
count is logged. PTASK_GIT_CLOSE_REPOS=owner/repo,...
limits which repositories (repository.full_name, case-insensitive) may
close tasks at all; a push from any other repository gets 200 with
skipped_repo and closes nothing. Unset, any repository holding the secret
may close tasks.
Configure PTASK_WEBHOOK_URLS=<url1>,<url2> and PTASK_WEBHOOK_SECRET for HMAC-signed POSTs
of the events produced by applied /sync commands (task.created,
task.completed, task.recurrence_advanced, task.updated,
task.deleted, ...; replays are not re-sent) and by /webhook/{gitea,github}
auto-closes. Nothing else is pushed: writes from the CLI, TUI, bot,
dashboard, MCP mount, timers and other routes reach pt_event_log (and so
/sync deltas) but not the webhook. Treat a push as a hint and /sync as
the complete feed.
Logged to pt_webhook_log. Signature header: X-Ptask-Signature: sha256=<hex>.
Body:
{ "event_type": "task.created", "task_uuid": "<uuid>", "payload": { ... },
"ts": "2026-10-06T14:03:11.512000+01:00", "event_id": 4711 }ts is the event's commit time (its pt_event_log.ts, operator timezone)
and event_id its journal id, not the delivery time. /sync commands and
git-webhook closes commit and enqueue under one process-wide lock, so events
are enqueued in commit order. Each URL has its own worker that delivers its
events one at a time in that order, so a slow or dead subscriber delays only
itself; no retries, 10s timeout per POST. Each URL's backlog is capped at
10,000 events: past that, new events for that URL are dropped, logged and
counted in pt_webhook_dropped_total. On graceful shutdown (SIGTERM /
SIGINT) the server finishes in-flight requests (up to 10s) and then gives the
queued events up to 15s to go out; whatever is left after that is dropped
(and logged).
/metrics exposes these series. All but pt_webhook_dropped_total (an
in-process counter, reset on restart) are computed from the database at
scrape time:
| Metric | Type | Labels |
|---|---|---|
pt_tasks_total |
gauge | status |
pt_tasks_priority_total |
gauge | priority |
pt_raw_items_unprocessed |
gauge | — |
pt_views_total |
gauge | — |
pt_event_log_cursor |
gauge | — (highest pt_event_log.id, the sync cursor) |
pt_webhook_log_total |
gauge | direction (in / out) |
pt_recurrence_total |
gauge | — |
pt_distill_last_success_age_seconds |
gauge | — (-1 = never ran) |
pt_distill_failed_total |
gauge | — |
pt_distill_last_run_ok |
gauge | — (1 ok / 0 failed) |
pt_distill_quarantined_captures |
gauge | — |
pt_notifications_last_sent_age_seconds |
gauge | channel |
pt_claims_active |
gauge | holder (in-progress tasks with a holder, v3.44.0) |
pt_claims_expired |
gauge | — (in-progress tasks whose claim lease ran out, not yet reclaimed) |
pt_webhook_dropped_total |
counter | — (outbound events dropped on a full per-URL backlog) |
The Triage Cockpit's API also lives in pt serve. HTTP Basic auth
(PTASK_DASH_USER/PTASK_DASH_PASS; open when no password configured —
local/dev only). Same shapes as the sidecar v0.6.0 contract. While no password
is configured, these routes (and GET /) answer only to the server's own names
— IP literals, localhost, the machine's short hostname, *.ts.net,
PTASK_DASH_ALLOWED_HOSTS (.suffix entries match the suffix) and the host of
PTASK_DASH_URL — and refuse any other Host with 421. Together with the
Host check on anonymous machine-API access (see Auth), a DNS-rebinding page
cannot drive the server.
The Python sidecar in dashboard/ (the live tailnet cockpit) is a different
process with a different posture: no login at all since PT-2201, the same Host
check on every request, and approval decisions gated by
PTASK_DASH_DECIDE_TOKEN (see dashboard/README.md).
Reads: GET /api/stats (its flux.by_window.<w>.by_actor lists created,
done, dismissed, reopened and net per actor, as pt flux — real
open↔closed transitions; deleting an open task is a closure; v3.46.0) ·
/api/tasks?status=&limit= · /api/critical?limit= · /api/timeline · /api/heatmap · /api/tasks/{id}/events (journal history) ·
GET /api/tasks/{id}/notes (the drawer's notes trail, newest first;
?limit= default 100, max 200, v3.47.1) ·
GET /api/stream (SSE, event: change frames with journal deltas).
GET / serves the cockpit when PTASK_DASH_WWW exists, else the banner.
Writes (attributed actor=dashboard): POST /api/tasks (create, quick-add
tokens parse) · POST /api/tasks/{id}/done|dismiss|reopen (done and dismiss
take an optional {"note": …}, v3.43.0) · /{id}/note {text} (v3.43.0) ·
/{id}/snooze {days} · /{id}/priority {level} ·
/{id}/edit {title?,description?,priority?,deadline?|null}.
POST /api/voice and POST /api/voice/task proxy to the Python voice shim
(PTASK_VOICE_SHIM_URL, default http://127.0.0.1:9510) — the first returns
drafted fields for the composer to review, the second creates the task
outright for the cockpit's capture bar. Two routes rather than one flag: the
proxy forwards only the path it is given, never the query string.
Executes a Telegram inline-button tap forwarded by nexus (the bot's single
getUpdates owner). Requires write scope. Only a client named in
PTASK_TG_FORWARDERS (default nexus) acts as the operator's tap; any other
write client's task tap is journaled as telegram via <client_id>, and its
approval taps get 403.
{"data": "ptdone:<task-uuid>", "callback_id": "<telegram callback id>"}Verbs: ptdone | ptsnooze (3 days) | ptdismiss. Idempotent per
callback_id (journal uuid tg-cb:<id>); duplicate taps return
{"ok":true,"duplicate":true}. Forwarded actions land in the journal as
actor=telegram, source=tg-callback. Approval verbs (ptapprove:AP-n,
ptreject:AP-n) are refused with 403 unless PTASK_TG_APPROVAL_BUTTONS=1; see
docs/approvals.md.
GET /list?filter=<DSL>&status=pending|all&limit=N — server-side filtered
task list (read scope). The DSL is the pt list grammar; parse errors
return 400 with the reason.
v2.4.0: optional client_key makes capture idempotent — a re-send with
the same key + text returns HTTP 200 {"duplicate": true, "id": <original>}
instead of a new row. Federation adapters MUST pass one (docs/agent-surface.md).
severity >= 3 (explicit field, or a puresentinel incident source with
[puresentinel sevN] in the text) creates the task SYNCHRONOUSLY —
attributed to the capturing token identity, source_type=incident,
priority sev3→4, sev4+→5. The response then carries task_uuid + pt_id,
and the raw_items record is marked processed. Requires capture scope.