# Hark docs Hark is an inbox on your phone for your scripts and AI agents. Anything that can make an HTTP request can send you a card, ask you a question and wait for the answer, show live progress, or ring an alarm. You answer with a tap, often straight from the notification, and your code gets the answer back. This is the reference for **Hark Cloud** at `https://api.harkapp.io`. Everything is JSON over HTTPS, times are Unix epoch seconds, and IDs are strings. ## Quick start 1. Install the Hark app and sign in with your email, or sign in at [app.harkapp.io](https://app.harkapp.io). 2. Open **Settings > API keys**, make a key and copy it. It starts with `hk_`. 3. Send yourself a card: ```bash export HARK_TOKEN=hk_... curl -H "Authorization: Bearer $HARK_TOKEN" \ -d '{"title": "Backup finished", "body": "412 GB in 38 minutes"}' \ https://api.harkapp.io/api/messages ``` Ask a question and block until you answer (or 10 minutes pass): ```bash curl -H "Authorization: Bearer $HARK_TOKEN" \ -d '{"agent": "deploy", "title": "Ship v2.14?", "choices": ["Deploy", "Cancel"], "timeout": "10m"}' \ "https://api.harkapp.io/api/ask?wait=600" ``` The response is the question card. `request.response.choice` is `"deploy"` or `"cancel"`, or the question has `request.status: "expired"` if nobody answered. Set a timer: ```bash curl -H "Authorization: Bearer $HARK_TOKEN" \ -d '{"in": "25m", "label": "Pomodoro"}' https://api.harkapp.io/api/alarms ``` From PowerShell, use `Invoke-RestMethod` so the JSON isn't mangled by quoting: ```powershell $h = @{ Authorization = "Bearer $env:HARK_TOKEN" } Invoke-RestMethod -Method Post https://api.harkapp.io/api/messages -Headers $h ` -ContentType "application/json" -Body (@{ title = "Hello"; body = "From PowerShell" } | ConvertTo-Json) ``` From Python, use the one-file client (section [Python client](#python-client)): ```python from hark import Hark # curl -O https://docs.harkapp.io/hark.py h = Hark(token="hk_...", agent="deploy", name="Deploy bot", avatar="🚀") h.send("Build passed", title="aux-web v2.14", state="ok") ans = h.ask("Ship v2.14?", choices=["Deploy", "Cancel"], timeout="10m", wait=600) if ans and ans["choice"] == "deploy": ... ``` ## Authentication Every endpoint except `GET /api/health` and `GET /files/...` needs a token. Send it any of these ways: ``` Authorization: Bearer hk_... X-Hark-Token: hk_... ?token=hk_... (query string; for EventSource, WebSockets and quick tests) ``` There are two kinds of token, both shaped `hk__`: - **API keys** are for scripts and agents. Make, name and revoke them in **Settings > API keys**. Revoking takes effect immediately. - **Sessions** are what the app and the web inbox get when you sign in. Only a session can make API keys or delete the account. Keep keys secret: anyone with one can post to your phone and read your inbox. Don't put them in code you share, and use one key per machine or agent so you can revoke them separately. Browsers can call the API directly: every response allows cross-origin requests. ## Messages (cards) ### `POST /api/messages` Creates a card (201). If `key` matches an existing card from the same agent, updates that card instead (200). See [Live cards](#live-cards). | field | type | notes | |---|---|---| | `agent` | string | Who is posting. Lowercase `a-z0-9._-`, max 40; other text is turned into that form. Default `api`. Created on first use, and each agent gets its own thread. | | `agent_name`, `avatar`, `color` | string | Display info for the agent. `avatar` is an emoji or one or two characters; `color` is a CSS color. | | `title` | string | Bold heading, up to 300 characters. | | `body` | string | Markdown-ish text: bold, italic, code, fenced code, lists, quotes, links, headings. Aliases: `text`, `message`. Up to 50,000 characters. | | `blocks` | array | Rich content. See [Blocks](#blocks). | | `image` | string | An `https://` URL, a `/files/...` path from an upload, or a `data:image/...;base64,...` URL (stored for you). Shown in the card and as the notification's picture. | | `html` | string | Your own HTML in a sandboxed iframe (scripts allowed, no access to Hark). Up to 500 KB. Sizes itself to its content. | | `html_height` | int | Fixed iframe height in pixels (40 to 2000) instead of auto-size. | | `json` | any | Shown as a collapsible JSON tree. | | `links` | array | `["https://..."]` or `[{"url", "label"}]`, shown as chips. The first link becomes an "Open link" notification button. `link` (one) also works. | | `url` | string | The card's main link. | | `state` | string | `idle`, `running`, `waiting`, `ok`, `warn`, `error` or `info`. Shows a status pill; `ok` and `error` also tint the card. | | `progress` | number | 0 to 1 (or 0 to 100). A progress bar that glides between updates. | | `priority` | string | `min`, `low`, `default`, `high` or `max`: how loudly the phone notifies. Questions default to `high`. | | `notify` | bool | `false` puts the card in the inbox without a notification. Default `true`. | | `pin` | bool | Put the card on the pinned board at the top of the feed. | | `tags` | string[] | Up to 10 short tags. | | `key` | string | Live-card key. | | `reply_to` | string | ID of the message this replies to. | | `request`, `choices`, `fields`, `timeout`, `deadline`, `on_expire`, `allow_text`, `placeholder` | | Make the card a question. See [Questions](#questions). | A card needs at least one of `title`, `body`, `blocks`, `html`, `image`, `json`, `links` or `request`. The body can also be form fields or plain text: `curl -d "Backup done" ...` posts a card whose body is `Backup done`. The response is the stored message: ```json { "id": "m_3f1c0a9b2e4d", "seq": 118, "agent": "deploy", "dir": "in", "key": null, "created": 1790000000.12, "updated": 1790000000.12, "seen": null, "pinned": false, "dismissed": false, "title": "Ship v2.14?", "body": "...", "priority": "high", "notify": true, "request": { "...": "see Questions" } } ``` `dir` is `in` for cards posted to you and `out` for things you typed. `seq` is a strictly increasing cursor across the whole account. ### `POST /api/notify` The same as `POST /api/messages`, but query-string and form fields are merged into the body, for tools that can only call a URL: ```bash curl -X POST "https://api.harkapp.io/api/notify?token=$HARK_TOKEN&title=Doorbell&message=Someone%20is%20at%20the%20door&priority=high" ``` ### `GET /api/messages` The newest page, returned oldest to newest. Returns `{"messages": [...], "has_more": bool}`. | query | notes | |---|---| | `limit` | 1 to 500, default 60. | | `before` | Only messages with `seq` below this, for paging backwards. | | `agent` | One agent's thread. | | `only` | `waiting` (open questions), `pinned` or `unseen`. | | `include_dismissed` | `1` to include cards you swiped away. | ### `GET`, `PATCH`, `DELETE /api/messages/{id}` `PATCH` takes any content field from the table above, plus `pinned`, `dismissed` and `seen: true`. Changing an unanswered card updates its notification quietly; send `"notify": true` to buzz again. Marking a card seen, dismissing it or deleting it removes its notification from your phone. ### `POST /api/seen` `{"ids": ["m_...", ...]}` or `{"all": true}`. Returns `{"seen": [ids that changed]}`. ### `POST /api/purge` Permanently deletes every dismissed card. Returns `{"deleted": n}`. ## Blocks `blocks` is a list of objects with a `type`. A bare string becomes a `text` block, and unknown types are shown as JSON instead of being rejected. Up to 150 blocks and 1 MB; extra blocks are dropped. | type | fields | shows as | |---|---|---| | `heading` | `text` | A small bold heading. | | `text` | `text` | Markdown paragraphs. | | `quote` | `text` | A quoted line. | | `kv` | `items`: `{"k": "v"}`, `[["k", "v"]]` or `[{"label", "value"}]` | A two-column list. | | `metric` | `label`, `value`, `delta?`, `tone?` | A big number. Metrics in a row sit side by side. | | `status` | `text`, `tone` (`ok`, `warn`, `error`, `info`, `idle`) | A colored status pill. | | `progress` | `value` (0 to 1), `label?` | A labeled progress bar. | | `list` | `items`, `ordered?` | A bulleted or numbered list. | | `code` | `text` (or `lines`), `lang?` | Monospace with a copy button. | | `log` | `text` (or `lines`) | Like `code`, keeps the last 400 lines and colors error, warning and ok lines. | | `image` | `url` (or `src`, or `base64` + `content_type`), `caption?` | An image; tap to zoom. | | `link` | `url`, `label?` | A link chip. | | `divider` | | A thin rule. | | `json` | `data` | A collapsible JSON tree. | ```json {"agent": "nuc", "title": "NUC health", "blocks": [ {"type": "metric", "label": "CPU", "value": "23%", "tone": "ok"}, {"type": "metric", "label": "Disk", "value": "81%", "delta": "+4%", "tone": "warn"}, {"type": "status", "text": "All services up", "tone": "ok"}, {"type": "kv", "items": {"uptime": "41d", "load": "0.42"}}, {"type": "log", "lines": ["backup ok", "warning: slow disk"]} ]} ``` ## Live cards Post again with the same `agent` and `key` and the existing card updates in place instead of a new one appearing. Use it for builds, downloads, renders, anything with progress. Only the fields you send change. The first post notifies (unless `notify` is `false`); later updates refresh the same notification quietly, at most every 2 seconds. ```bash for p in 10 40 75 100; do curl -s -H "Authorization: Bearer $HARK_TOKEN" \ -d "{\"agent\": \"render\", \"key\": \"job-42\", \"title\": \"Rendering\", \"progress\": $p, \"state\": \"running\", \"notify\": false}" \ https://api.harkapp.io/api/messages > /dev/null sleep 2 done curl -s -H "Authorization: Bearer $HARK_TOKEN" \ -d '{"agent": "render", "key": "job-42", "state": "ok", "body": "Done"}' https://api.harkapp.io/api/messages ``` ## Questions Any card can ask something. Top-level `choices`, `fields`, `timeout`, `deadline`, `on_expire`, `allow_text` and `placeholder` are shorthand for a `request` object: ```json { "agent": "deploy", "title": "Ship aux.fan v2.14?", "body": "Build passed. 4 migrations pending.", "request": { "choices": [ {"label": "Deploy", "style": "primary"}, {"label": "Inspect"}, {"id": "rollback", "label": "Roll back", "style": "danger", "confirm": "hold"} ], "timeout": "30m", "on_expire": "inspect" } } ``` | request field | notes | |---|---| | `choices` | Up to 8 buttons: strings or `{id?, label, style?, confirm?}`. `id` defaults to a slug of the label (`"Roll back"` becomes `roll-back`). `style` is `default`, `primary` or `danger`. `confirm: "hold"` needs a press and hold in the app and is never offered on the notification. | | `fields` | Up to 20 inputs: `{name, label?, type?, required?, placeholder?, value?, options?}`. `type` is `text`, `textarea`, `number`, `select` (needs `options`), `toggle`, `date` or `time`. | | `allow_text` | A free-text answer box. On by default when there are no choices and no fields. | | `placeholder` | Placeholder for the text box. | | `timeout` | How long until it expires: `90`, `"90s"`, `"10m"`, `"1h30m"`. Or `deadline`: an absolute time (epoch, ISO 8601, or `"17:30"` in your time zone). | | `on_expire` | A choice `id` to record automatically when it expires, with `via: "timeout"`. | The first three choices without `confirm: "hold"` appear as buttons on the notification, so most questions can be answered without opening the app. ### Answers `request.status` starts `open` and becomes `answered`, `expired` or `cancelled`. Once answered, or expired with `on_expire`: ```json "response": { "choice": "deploy", "label": "Deploy", "fields": {"wake": "06:45", "gym": true}, "text": "ship it", "at": 1790000123.4, "via": "notification" } ``` `via` is `app`, `notification`, `api`, `cli` or `timeout`. ### Waiting for the answer - `POST /api/ask?wait=SECONDS` posts and blocks until the question is answered, expires or is cancelled, up to 3600 seconds. A plain `{"title": "..."}` with no choices or fields becomes a free-text question. - `GET /api/messages/{id}/wait?timeout=60` long-polls an existing question (up to 3600 seconds) and returns the message as soon as it isn't open. If it times out, it returns the still-open message: loop until `request.status` isn't `open`. - Or give the agent a [webhook](#webhooks) and get a POST, or watch [live updates](#live-updates). ### Answering and withdrawing from code - `POST /api/messages/{id}/respond` with `{"choice": "deploy"}` (an id or a label, any case), `{"fields": {...}}`, `{"text": "..."}` or a mix. Returns 409 if the question is no longer open. - `POST /api/messages/{id}/cancel` withdraws an open question (`cancelled`). ## Webhooks Give an agent a webhook and Hark POSTs to it when you answer one of its questions, when one expires, and when you send the agent a chat message: ```bash curl -X PUT -H "Authorization: Bearer $HARK_TOKEN" \ -d '{"name": "Deploy bot", "avatar": "🚀", "webhook": "https://example.com/hark", "secret": "s3cret"}' \ https://api.harkapp.io/api/agents/deploy ``` The payload: ```json {"event": "response", "agent": "deploy", "message": {"...": "the full message"}, "response": {"choice": "deploy"}, "sent_at": 1790000123.5} ``` `event` is `response`, `expired` or `message`. Headers: `X-Hark-Event`, and when the agent has a `secret`, `X-Hark-Signature: sha256=`. Check it like this: ```python import hmac, hashlib ok = hmac.compare_digest(sig, "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()) ``` A failed delivery is retried after 2 and 8 seconds. The outcome is saved on the message as `delivery: {"ok", "at", "event", "error"}` and shown on the card, so you can see in the app whether your agent got the answer. ## Chat You can type to an agent in its thread in the app. That creates an `out` message in the agent's inbox. - Agents with a webhook get an `event: "message"` POST. - Agents without one long-poll: `GET /api/agents/{id}/inbox?after=&wait=30` returns `{"messages": [...], "cursor": }` with the messages you sent after `after`, waiting up to `wait` seconds (max 300) for the first one. Pass `cursor` back as the next `after`; start with `after=0&wait=0` to catch up. Reply by posting an ordinary card with the same `agent`. `POST /api/chat` `{"agent": "deploy", "text": "status?"}` sends as you; `agent: "me"` is a note to yourself. ## Agents Agents are created the first time something posts as them. Two are built in: `me` (your notes) and `hark` (system messages and alarms). - `GET /api/agents`: every agent with `unread`, `waiting` and its `last` message, most recently active first. - `GET /api/agents/{id}` - `PUT` or `PATCH /api/agents/{id}`: `name`, `avatar`, `color`, `webhook`, `secret`, `muted`. Muted agents still post cards but never notify. - `DELETE /api/agents/{id}/messages`: clears the thread. Deletes every card from that agent, takes their notifications off your phones, and keeps the agent and its settings. Works for `me` and `hark` too. Returns `{"deleted": n}`. - `DELETE /api/agents/{id}`: deletes the agent, its settings and all its cards. Not allowed for `me` and `hark`. The agent comes back if it posts again. In the app, press and hold a thread to clear or delete it. ## Alarms and timers Alarms ring on your phone full screen, on the phone's own clock, so they work offline, with the screen off and in battery saver. Hark keeps the schedule; the app syncs it. ### `POST /api/alarms` | field | notes | |---|---| | `in` | A timer: `"25m"`, `"1h30m"`, `90`. | | `at` | An alarm time: `"07:30"`, `"7am"`, `"7:30pm"`, `"tomorrow 9"`, ISO 8601 or epoch. A bare time means its next occurrence in your time zone. Aliases: `time`, `when`. | | `label` | Shown on the ring screen and the notification. | | `repeat` | `daily`, `weekdays`, `weekends`, `hourly`, `"mon,wed,fri"`, `["sat", "sun"]`, `"every 2h"` or `{"every": "90m"}`. | | `say` | `true` speaks the label when it rings, or a string to speak instead. | | `vibrate` | Default `true`. | | `ring_seconds`, `snooze_minutes` | Per-alarm overrides of the account settings. | | `webhook` | A URL that gets `{"event": "alarm.fired", "alarm": {...}}` when it rings. | It returns the alarm: ```json {"id": "a_91be2c0d77aa", "at": 1790006400.0, "state": "scheduled", "kind": "alarm", "label": "Wake up", "repeat": "weekdays", "repeat_text": "Weekdays", "vibrate": true, "created": 1790000000.0, "updated": 1790000000.0, "fired": null, "message_id": null} ``` `state` is `scheduled`, `ringing`, `snoozed`, `done`, `missed` or `cancelled`. If nobody stops a ringing alarm within `ring_seconds` (5 minutes by default) it becomes `missed`. Repeating alarms go back to `scheduled` at their next time after they're stopped or missed. ### Other alarm endpoints - `GET /api/alarms` (`?active=1` for only scheduled, snoozed and ringing). Each alarm carries `upcoming`: its next three repeat times. - `GET /api/alarms/{id}` - `PATCH /api/alarms/{id}`: any field above, plus `enabled: true/false`. Turning a finished alarm back on schedules its next time. - `DELETE /api/alarms/{id}` - `POST /api/alarms/{id}/ack`: stop a ringing alarm. - `POST /api/alarms/{id}/snooze` `{"minutes": 5}` (default: your snooze setting). Times like `07:30` use the account's time zone, which the app sets when you sign in. See it with `GET /api/config` (`tz`) and change it with `PATCH /api/account` `{"tz": "America/New_York"}`. ## Files `POST /api/files?name=shot.png` with the raw bytes as the body and a `Content-Type` header. Returns `{"url": "/files//", "size": n, "type": "image/png"}`. Use the `url` in `image` or in image blocks. Up to 25 MB. ```bash curl -H "Authorization: Bearer $HARK_TOKEN" -H "Content-Type: image/png" \ --data-binary @shot.png "https://api.harkapp.io/api/files?name=shot.png" ``` `GET /files/...` needs no token (the names can't be guessed), so notifications and the app can load images directly. For one-off images, a `data:image/png;base64,...` URL in `image` is simpler: Hark stores it for you. ## Live updates Every change in your inbox is pushed to connected clients as it happens, the same events the app uses. - **WebSocket**: `wss://api.harkapp.io/api/ws?token=...`. Each frame is `{"event": "...", "data": {...}}`. Send `ping` and you get `pong`. - **Server-sent events**: `GET /api/stream?token=...`, with a comment ping every 15 seconds. | event | data | |---|---| | `hello` | `{version, time, unread, waiting, total}` on every connect. | | `message` | `{op: "new" \| "update" \| "delete", message}` | | `alarm` | The alarm, or `{id, deleted: true}`. | | `agent` | The agent, `{id, deleted: true}`, or the agent with `cleared: true` when its thread was cleared. | | `seen` | `{ids: [...]}` | | `resync` | Something changed in bulk (a purge); fetch again. | After a reconnect, fetch what you need again: events sent while you were away aren't replayed. ## Account and settings - `GET /api/account`: `{id, email, tz, created, usage: {month, messages, limit}}`. - `PATCH /api/account` `{"tz": "Europe/London"}` - `GET /api/config`: your settings, including `tz`, `notify`, `snooze_minutes` and `alarm_ring_seconds`. - `PATCH /api/config`: `notify` (notifications on or off for the whole account), `snooze_minutes`, `alarm_ring_seconds`, `tz`. - `GET /api/info`: unread and waiting counts, total messages, live client count. - `POST /api/test-notification`: sends a question with two buttons to your phones, to check the whole path. - `GET /api/keys`: your API keys and sessions. `POST /api/keys` `{"name"}` makes a key and returns it once, in `token` (needs a session). `DELETE /api/keys/{id}` revokes one. - `POST /api/auth/logout`: revokes the token it's called with. - `DELETE /api/account` `{"confirm": "delete my account"}`: erases everything (needs a session). The app registers itself for push with `POST /api/devices`. `GET /api/devices` lists your phones, and `POST /api/devices/{id}/test` sends one a test push. ## Errors and limits Errors are `{"error": "a readable message"}` with a fitting status: | status | meaning | |---|---| | 400 | Bad input; the message says what's wrong. | | 401 | Missing or wrong token. | | 403 | The token can't do this (an API key where a session is needed). | | 404 | Not found. | | 405 | Wrong method for this path. | | 409 | The question is no longer open. | | 413 | Too large. | | 429 | Over a limit: slow down, or wait for next month. | Limits: 25,000 new cards a month per account, and 20 requests a second per key with bursts of 60. Request bodies up to 4 MB, uploads 25 MB, `body` 50,000 characters, `html` 500 KB, `blocks` 150 and 1 MB, 8 choices, 20 fields, 12 links. ## Python client `hark.py` is one file using only the standard library. Download it next to your script: ```bash curl -O https://docs.harkapp.io/hark.py ``` ```python from hark import Hark # Token from the argument or $HARK_TOKEN; URL from the argument, $HARK_URL, or Hark Cloud for hk_ keys. h = Hark(token="hk_...", agent="nuc", name="NUC", avatar="◎") h.send("Disk at 81%", title="NUC health", state="warn") for pct in (0.2, 0.6, 1.0): # a live card: same key, same card h.card("backup", title="Backing up", progress=pct, state="running", notify=False) ans = h.ask("Prune old snapshots?", choices=["Prune", "Skip"], timeout="10m", wait=600) if ans and ans["choice"] == "prune": ... h.send(title="Screenshot", image=h.image("shot.png")["url"]) h.alarm("25m", "Pomodoro") h.alarm("07:30", "Wake up", repeat="weekdays", say=True) def on_message(m): # you typed to this agent in the app h.send(f"you said: {m['body']}", reply_to=m["id"]) h.listen(on_message) ``` `ask(..., wait=N)` returns the response (`choice`, `label`, `fields`, `text`, `via`, `at`) or `None` if nobody answered in time. Without `wait` it returns the message; call `h.wait(message_id, timeout)` later. Errors raise `HarkError` with `status` and `message`. ## Self-hosting The same API runs on your own hardware: the Hark server is a small Python program with no dependencies that runs on a Raspberry Pi, a VPS, a NAS or an Android phone. Point the app at it with **Use my own server instead** on the sign-in screen. Everything in these docs applies, except the account, keys and limits sections; a self-hosted server has one token.