Hark Docs

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.

Building with an AI agent?Give it llms.txt: a short summary of the API it can work from, with links to the rest. llms-full.txt is this whole page as Markdown.

Quick start#

  1. Install the Hark app and sign in with your email, or sign in at app.harkapp.io.
  2. Open Settings > API keys, make a key and copy it. It starts with hk_.
  3. Send yourself a card:
bashexport 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):

bashcurl -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:

bashcurl -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):

pythonfrom 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_<account>_<secret>:

  • 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.

fieldtypenotes
agentstringWho 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, colorstringDisplay info for the agent. avatar is an emoji or one or two characters; color is a CSS color.
titlestringBold heading, up to 300 characters.
bodystringMarkdown-ish text: bold, italic, code, fenced code, lists, quotes, links, headings. Aliases: text, message. Up to 50,000 characters.
blocksarrayRich content. See Blocks.
imagestringAn 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.
htmlstringYour own HTML in a sandboxed iframe (scripts allowed, no access to Hark). Up to 500 KB. Sizes itself to its content.
html_heightintFixed iframe height in pixels (40 to 2000) instead of auto-size.
jsonanyShown as a collapsible JSON tree.
linksarray["https://..."] or [{"url", "label"}], shown as chips. The first link becomes an "Open link" notification button. link (one) also works.
urlstringThe card's main link.
statestringidle, running, waiting, ok, warn, error or info. Shows a status pill; ok and error also tint the card.
progressnumber0 to 1 (or 0 to 100). A progress bar that glides between updates.
prioritystringmin, low, default, high or max: how loudly the phone notifies. Questions default to high.
notifyboolfalse puts the card in the inbox without a notification. Default true.
pinboolPut the card on the pinned board at the top of the feed.
tagsstring[]Up to 10 short tags.
keystringLive-card key.
reply_tostringID of the message this replies to.
request, choices, fields, timeout, deadline, on_expire, allow_text, placeholderMake the card a question. See 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:

bashcurl -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}.

querynotes
limit1 to 500, default 60.
beforeOnly messages with seq below this, for paging backwards.
agentOne agent's thread.
onlywaiting (open questions), pinned or unseen.
include_dismissed1 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.

typefieldsshows as
headingtextA small bold heading.
texttextMarkdown paragraphs.
quotetextA quoted line.
kvitems: {"k": "v"}, [["k", "v"]] or [{"label", "value"}]A two-column list.
metriclabel, value, delta?, tone?A big number. Metrics in a row sit side by side.
statustext, tone (ok, warn, error, info, idle)A colored status pill.
progressvalue (0 to 1), label?A labeled progress bar.
listitems, ordered?A bulleted or numbered list.
codetext (or lines), lang?Monospace with a copy button.
logtext (or lines)Like code, keeps the last 400 lines and colors error, warning and ok lines.
imageurl (or src, or base64 + content_type), caption?An image; tap to zoom.
linkurl, label?A link chip.
dividerA thin rule.
jsondataA 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.

bashfor 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 fieldnotes
choicesUp 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.
fieldsUp to 20 inputs: {name, label?, type?, required?, placeholder?, value?, options?}. type is text, textarea, number, select (needs options), toggle, date or time.
allow_textA free-text answer box. On by default when there are no choices and no fields.
placeholderPlaceholder for the text box.
timeoutHow long until it expires: 90, "90s", "10m", "1h30m". Or deadline: an absolute time (epoch, ISO 8601, or "17:30" in your time zone).
on_expireA 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 and get a POST, or watch 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:

bashcurl -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=<hex HMAC-SHA256 of the raw body>. Check it like this:

pythonimport 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=<seq>&wait=30 returns {"messages": [...], "cursor": <seq>} 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#

fieldnotes
inA timer: "25m", "1h30m", 90.
atAn 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.
labelShown on the ring screen and the notification.
repeatdaily, weekdays, weekends, hourly, "mon,wed,fri", ["sat", "sun"], "every 2h" or {"every": "90m"}.
saytrue speaks the label when it rings, or a string to speak instead.
vibrateDefault true.
ring_seconds, snooze_minutesPer-alarm overrides of the account settings.
webhookA 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/<account>/<name>", "size": n, "type": "image/png"}. Use the url in image or in image blocks. Up to 25 MB.

bashcurl -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.
eventdata
hello{version, time, unread, waiting, total} on every connect.
message{op: "new" | "update" | "delete", message}
alarmThe alarm, or {id, deleted: true}.
agentThe agent, {id, deleted: true}, or the agent with cleared: true when its thread was cleared.
seen{ids: [...]}
resyncSomething 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:

statusmeaning
400Bad input; the message says what's wrong.
401Missing or wrong token.
403The token can't do this (an API key where a session is needed).
404Not found.
405Wrong method for this path.
409The question is no longer open.
413Too large.
429Over 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:

bashcurl -O https://docs.harkapp.io/hark.py
pythonfrom 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.