Appearance
hooka Integration Guide
This guide is for any external service that wants to send notifications through hooka.
For machine-readable API import (Postman/Insomnia/SDK generators), use openapi.yaml.
Building a Telegram bot from a blank agent session? Start with TELEGRAM_BOT_QUICKSTART.md. It is the shortest path from a Hooka URL and key to a working Telegram chat loop.
1) Integration Flow (Recommended)
- Create a scoped token for the service.
- (Optional) Create a topic route to target specific Telegram chats.
- Service publishes events to a topic (
/publish/{topic}) with idempotency keys. - Service retries safely on transient failures.
2) Base Settings
Set these in the integrating service:
bash
HOOKA_URL="https://hooka.example.com"
HOOKA_API_KEY="<scoped-token-or-master-key>"
HOOKA_TOPIC="deployments"HOOKA_API_KEY is the canonical token env var name, used consistently in the examples below. The Go CLIs (hooka, hooka-agent-bridge) also accept the aliases HOOKA_API_TOKEN and HOOKA_TOKEN as fallbacks (first non-empty wins), so existing setups using those names keep working. The shared TypeScript client at ../web/src/lib/hooka-client.ts prefers explicit baseURL and apiToken constructor args over any fixed env var name.
Integrating from Go? Use the Go SDK rather than hand-rolling HTTP: it reads the same env vars, retries idempotent requests, and covers the whole API surface documented here. Its getting-started guide is the Go version of the flow described on this page.
go
import hooka "github.com/guzus/hooka/sdk/go"
c, err := hooka.New() // HOOKA_URL + HOOKA_API_KEY
res, err := c.Publish(ctx, "deployments", hooka.Message{
Title: "Deploy finished",
Text: "[email protected] is live",
}, hooka.WithIdempotencyKey("deploy-"+sha))3) Auth Modes
Authorization: Bearer <token>(preferred)- URL token for systems that cannot set headers:
/send/{token}— scoped tokens only; the master API key is refused as a URL token (the attempt is logged and recorded on theaudittopic)
Auth introspection
GET /me returns the identity and capabilities for the presented bearer token. Any valid token (master key or scoped) can call it — a client can use it to validate a freshly pasted token at first run and learn what topics it is scoped to. The response includes name, kind (master or scoped), can_publish, can_read, topic_pattern, and is_master. Returns 401 {"ok": false, "error": "unauthorized"} if the token is missing or invalid.
bash
curl -s "$HOOKA_URL/me" -H "Authorization: Bearer $TOKEN"4) Create a Scoped Token (Admin)
Use master key (API_KEY) once to issue a least-privilege token:
bash
curl -X POST "$HOOKA_URL/tokens" \
-H "Authorization: Bearer <MASTER_API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"name": "ci-deploy-bot",
"can_publish": true,
"can_read": false,
"topic_pattern": "deploy*"
}'Response includes token_value. Store it as a secret in the integrating system.
5) Route a Topic to Delivery Target(s)
hooka currently supports two route providers:
telegram: target is Telegramchat_idwebhook: target is anhttp/httpsURL
bash
curl -X POST "$HOOKA_URL/topics/deployments/routes" \
-H "Authorization: Bearer <MASTER_OR_PUBLISH_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"provider":"telegram","chat_id":"-1001234567890","parse_mode":"HTML"}'If no route exists, TELEGRAM_DEFAULT_CHAT_ID is used.
Webhook route example:
bash
curl -X POST "$HOOKA_URL/topics/deployments/routes" \
-H "Authorization: Bearer <MASTER_OR_PUBLISH_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"provider":"webhook",
"target":"https://example.com/hook",
"headers":{"Authorization":"Bearer upstream-secret"}
}'5.1) Topic ACL (Optional Anonymous Access)
By default, auth is required for publish/read. Admin can open a topic explicitly:
bash
curl -X POST "$HOOKA_URL/topics/public-status/acl" \
-H "Authorization: Bearer <MASTER_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"anonymous_publish":false,"anonymous_read":true}'Read current ACL:
bash
curl -X GET "$HOOKA_URL/topics/public-status/acl" \
-H "Authorization: Bearer <MASTER_API_KEY>"5.2) Anonymous Ingest Hooks (Publish-Only Capability URLs)
The ACL above opens a topic to everyone who can guess its name. When you instead want to let one specific counterparty you don't control push messages to you — a third-party agent, a one-off conversation, a service that can't set headers — mint an ingest hook: a Slack-incoming-webhook-style URL where the unguessable token is the credential.
Properties, by design:
- Publish-only, single-topic. The token works on
/in/{token}and nowhere else. It can never read, list, or touch another topic. - Hashed at rest, shown once, revocable per counterparty. Mint one URL per conversation/agent; revoke it when done without rotating anything else.
- Optional
ttlandmax_usesfor self-destructing hand-outs. - Per-hook rate limit (
INGEST_BURSTthenINGEST_RATE_PER_MINUTE), so a leaked URL can at worst trickle. - Whitelisted payload, bullet-rendered. Only
text,title,priority(clamped to 1–4, never the max-priority paging tier), andtags. OneLabel: valueper line becomes a Telegram bullet; poster-supplied HTML is escaped and markdown/autolink are off (so a URL inside an<a href>cannot become a live tag). Parse modes, reply markup, and chat targeting stay server-controlled. Iftitleis omitted, the first line of a multi-line body is used as the title. - Refused on topics with webhook routes (same amplification guard as anonymous ACL publish) — bind hooks to telegram-only topics.
- Unremovable provenance. Every delivered message opens with
📥 via ingest "<name>"(so even the notifying first chunk of a long message is attributed) and carries hook metadata in its payload. Treat messages arriving this way as untrusted input. - Uniform 404 for unknown/revoked/expired/exhausted tokens; the URL is redacted from request logs (including uncleaned
//in/...forms). - Honest retry accounting.
Idempotency-Keyreplays return the original message id without consumingmax_uses; a post that fails because the topic has no route yet (503) refunds its use. 429 responses carryRetry-After.
Mint one (admin):
bash
hooka ingest-create summaries --name "bobs-agent" --ttl 168h --max-uses 50
# or raw HTTP:
curl -X POST "$HOOKA_URL/ingest-hooks" \
-H "Authorization: Bearer <MASTER_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"name":"bobs-agent","topic":"summaries","ttl":"168h","max_uses":50}'
# → { "ingest_hook": {...}, "token_value": "hin_...", "url": "https://hooka.example/in/hin_..." }Then tell the counterparty agent exactly one thing:
Send your result as an HTTP POST to
https://hooka.example/in/hin_...— JSON{"title":"...","text":"..."}or plain text. No auth header. Put oneLabel: valueper line intext(those become bullets). HTML is escaped.
The counterparty posts anonymously:
bash
curl -X POST "https://hooka.example/in/hin_..." \
-H "Content-Type: application/json" \
-d '{"text":"Session summary: ...","title":"Research done","priority":3,"tags":["summary"]}'
# → {"ok":true,"id":12345}
# plain text works too:
curl -X POST "https://hooka.example/in/hin_..." -d "done: all tests green"An agent that only has the URL can GET it to discover this contract. Idempotency-Key (≤128 chars) is honored and namespaced per hook. Manage hooks with hooka ingests / hooka ingest-revoke <id> (HTTP: GET /ingest-hooks, DELETE /ingest-hooks/{id}).
The same URL doubles as a GitHub webhook receiver: deliveries that carry GitHub's X-GitHub-Event header are rendered as repository activity instead of the plain JSON shape above. See §17.
6) Publish Notifications
Basic publish
bash
curl -X POST "$HOOKA_URL/publish/$HOOKA_TOPIC" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Deploy Finished",
"text": "api@a1b2c3",
"priority": 4,
"tags": ["prod", "release"]
}'Store for history and streaming without notification
Authenticated producers can make a topic a durable bus without invoking its routes or the server's TELEGRAM_DEFAULT_CHAT_ID fallback. The message is written to history, becomes visible to SSE consumers, and returns delivered: 0 with an empty destinations list.
bash
curl -X POST "$HOOKA_URL/publish/raw-feed" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"raw event","store_only":true}'store_only is explicit per message. Omitting it preserves normal route resolution, including the configured default-chat fallback for unrouted topics.
Clients can use ?dry_run=true and require the response's store_only:true as a startup compatibility preflight, then check the same echo on every publish receipt. The latter is detection after the request, not an atomic downgrade guard: it cannot undo delivery if an old or changed server ignored store_only. A versioned store-only endpoint would be required to close that server-compatibility race.
Attach opaque application data
application_payload is an optional JSON value owned entirely by the publisher. Hooka stores it but does not render, route, index, or otherwise interpret it:
bash
curl -X POST "$HOOKA_URL/publish/raw-feed" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text":"new channel post",
"store_only":true,
"application_payload":{"provider":"telegram","event_id":"evt-7"}
}'This name is intentionally distinct from StoredMessage.payload. The latter is the complete publish request object, stored as JSONB and returned only when include_payload=true; the publisher's value remains nested at payload.application_payload. On singular /publish/{topic}, the full envelope is retained, including fields unknown to this server version, so a newer producer's data survives storage and replay. Batch publish decodes and re-marshals each item; named fields including application_payload survive, but unknown fields do not. JSONB preserves values and object fields, not source whitespace or key order.
Publish with Telegram inline buttons
bash
curl -X POST "$HOOKA_URL/publish/$HOOKA_TOPIC" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Deploy Finished",
"text": "Open the runbook or dashboard.",
"priority": 4,
"reply_markup": {
"inline_keyboard": [
[
{"text": "Dashboard", "url": "https://example.com/dashboard"},
{"text": "Runbook", "url": "https://example.com/runbook"}
]
]
}
}'hooka now passes raw Telegram-native objects through for:
rich_message(InputRichMessagewith exactly one non-emptymarkdown,html, orblocks; optionalmedia,is_rtl, andskip_entity_detectionpass through)reply_markupreply_parameterslink_preview_optionsdisable_web_page_previewas a legacy alias forlink_preview_options: {"is_disabled": true}message_thread_iddisable_notificationprotect_contentallow_paid_broadcastmessage_effect_id
Telegram Bot API 10.2 Rich Messages
Rich messages are an explicit provider-specific path, not a parse_mode. Keep text as a meaningful plain fallback because Hooka persists/searches it and forwards the original request to webhook routes (and uses text for iOS). Telegram routes instead call sendRichMessage exactly once per attempt:
bash
curl -X POST "$HOOKA_URL/publish/$HOOKA_TOPIC" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Deploy report",
"text": "API healthy; worker healthy (plain fallback)",
"rich_message": {
"markdown": "# Deploy report ✅\n\n| Service | Status |\n|---|---|\n| API | Healthy |\n| Worker | Healthy |"
}
}'Exactly one of non-empty markdown, html, or blocks is required. Hooka enforces Telegram's 32,768-character input limit for markdown/html and lets Telegram validate nested block/media limits. title, text, parse_mode, format, and raw_text do not affect Telegram rendering while rich_message is present. link_preview_options and disable_web_page_preview:true are rejected because sendRichMessage does not accept them. The CLI accepts --rich-message-json or --rich-message-file on both send and publish. Hooka does not upload new files through this JSON path; media objects must contain references Telegram already accepts, such as an existing file ID or URL.
Idempotent publish (strongly recommended)
bash
curl -X POST "$HOOKA_URL/publish/$HOOKA_TOPIC" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Idempotency-Key: deploy-${CI_PIPELINE_ID}-${CI_JOB_ID}" \
-H "Content-Type: application/json" \
-d '{"text":"Deploy completed"}'Re-sending the same idempotency key returns the original message_id instead of creating duplicates.
Telegram chat actions
Agents can show Telegram's "typing..." indicator while generating a response:
bash
curl -X POST "$HOOKA_URL/send-chat-action" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"chat_id":"-1001234567890","message_thread_id":"42","action":"typing","topic":"bot-out"}'topic is optional, but useful for scoped tokens: when present, the token must have publish permission for that topic.
Publish a voice message
hooka forwards OGG/Opus payloads to Telegram via sendVoice. The server trusts the caller to provide a valid OGG/Opus file — it does not transcode or sniff the content. Two endpoints are available:
POST /send-voice— direct send (no topic routing, like/send-photoand/send-document). Single Telegram chat per request.POST /publish/{topic}/voice— topic fan-out. Persists a message record, resolves the topic's Telegram routes, and forwards to each one. Reuses the same idempotency, dead-letter, and observability semantics as the document publish path.
Both accept multipart/form-data with a 50 MiB size cap on the voice field.
Direct send:
bash
curl -X POST "$HOOKA_URL/send-voice" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-F "voice=@./clip.ogg" \
-F "chat_id=-1001234567890" \
-F "caption=Status update" \
-F "parse_mode=HTML"Topic publish (with idempotency):
bash
curl -X POST "$HOOKA_URL/publish/$HOOKA_TOPIC/voice" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Idempotency-Key: voice-${RUN_ID}" \
-F "voice=@./clip.ogg" \
-F "caption=Daily briefing" \
-F "title=Morning Brief" \
-F "tags=brief,voice"Form fields: voice (required, OGG/Opus binary), chat_id, caption, parse_mode, message_thread_id, plus title, tags, priority for the publish variant.
Error contract:
400— missingvoicefield, invalid multipart form, oversize, or nochat_idavailable (and no default configured).401— missing or invalid token / no anonymous publish on the topic.405— non-POST method.502— Telegram rejected the upload or no telegram routes were resolved.
Batch publish to a topic
When a job has N pre-formatted messages to push, sending one big batch saves the round-trip overhead vs. N parallel /publish/{topic} calls and gives you a single idempotency key for the whole batch.
POST /publish/{topic}/batch accepts up to 100 messages per request. The server fans out internally with a worker pool capped at 8 concurrent goroutines. The batch never aborts on a single message failure — each result is reported in results[i]:
bash
curl -X POST "$HOOKA_URL/publish/$HOOKA_TOPIC/batch" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Idempotency-Key: hourly-${RUN_ID}" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"title": "Update 1", "text": "first", "priority": 3, "tags": ["ci"]},
{"title": "Update 2", "text": "second", "priority": 3, "tags": ["ci"]}
]
}'Successful response (HTTP 200):
json
{
"ok": true,
"topic": "your-topic",
"delivered": 2,
"failed": 0,
"results": [
{"index": 0, "ok": true, "message_id": 12345, "delivered": 1, "failed": 0},
{"index": 1, "ok": true, "message_id": 12346, "delivered": 1, "failed": 0}
]
}Per-message failure shape:
json
{"index": 3, "ok": false, "error": "no active routes for topic", "code": "no_routes"}Error codes you can branch on: text_required, missing_chat_id, invalid_topic, no_routes, rate_limited, delivery_failed, dispatch_error.
The Idempotency-Key header applies to the whole batch. Internally the server synthesizes per-message keys as <batch-key>:<index> and stores them in the existing per-message idempotency table — retrying the batch returns the cached message_ids without storing duplicate rows.
Batch-level errors that return non-200:
400— emptymessagesarray, more than 100 messages, malformed JSON, or invalid topic.401— missing or invalid token / no anonymous publish on the topic.405— non-POST method.
Batch send (chat-targeted)
POST /send-batch is the per-chat-id equivalent. Each message must carry chat_id (or fall back to TELEGRAM_DEFAULT_CHAT_ID). Messages targeting the same chat_id are serialized internally — different chats stay parallel — so a many-messages-to-one-chat batch doesn't trip Telegram's per-chat 1msg/sec limit.
bash
curl -X POST "$HOOKA_URL/send-batch" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"chat_id": "-1001111111111", "text": "alpha"},
{"chat_id": "-1002222222222", "text": "beta"},
{"chat_id": "-1001111111111", "text": "gamma"}
]
}'A message missing chat_id (with no default configured) is reported as {"index": K, "ok": false, "error": "missing chat_id", "code": "missing_chat_id"} while the rest of the batch continues.
7) Read APIs for Auditing / Dashboards
Topic messages
bash
curl -X GET "$HOOKA_URL/topics/deployments/messages?limit=50" \
-H "Authorization: Bearer <READ_OR_MASTER_TOKEN>"Add include_payload=true when a subscriber needs the complete stored publish envelope, including application_payload, unknown forward-compatible fields from singular publishes, or Telegram inbound chat_id, message_thread_id, and sender fields.
Filtered messages
bash
curl -X GET "$HOOKA_URL/topics/deployments/messages?limit=50&min_priority=4&tags=prod,release&since=2026-03-05T00:00:00Z" \
-H "Authorization: Bearer <READ_OR_MASTER_TOKEN>"Dead letters (failed terminal deliveries)
bash
curl -X GET "$HOOKA_URL/topics/deployments/deadletters?limit=50" \
-H "Authorization: Bearer <READ_OR_MASTER_TOKEN>"Live stream (SSE) for subscribers
Use this instead of polling /topics/{topic}/messages in a loop. A poll loop hammers the server every N seconds whether or not anything changed; the SSE stream lets the server push only when a new message arrives.
Latency model: the database remains the source of truth. Publishes and Telegram inbound captures handled by the same hooka process wake connected streams immediately, so local subscribers usually receive the next event: message without waiting for a poll interval. The endpoint also keeps a 2-second database fallback to catch missed signals, process restarts, and writes handled by another server instance.
bash
curl -N "$HOOKA_URL/topics/deployments/stream?since=2026-03-05T00:00:00Z&min_priority=4&tags=prod,release" \
-H "Authorization: Bearer <READ_OR_MASTER_TOKEN>" \
-H "Accept: text/event-stream"Query parameters:
since(RFC3339): only emit messages after this timestamp; defaults to "now" if omitted.min_priority(1–5): drop lower-priority messages.tags(CSV): only emit messages tagged with all of these.batch_limit(1–500, default 100): cap rows returned per server tick.include_payload(boolean, default false): include stored raw payload metadata.
Event stream:
event: ready— emitted once on connect with the resolved filters and current cursor.event: message— one per new message.datais the fullStoredMessageJSON.event: error— server-side query failure; the connection is closed afterwards.: keepalive— every 20s, just a comment to keep the connection open.
Reconnection: the server doesn't persist client state. To resume after a disconnect, reopen with ?since=<last_message.created_at>. Use the StoredMessage.id to dedupe if your downstream is exactly-once.
Node example:
js
import { EventSource } from 'eventsource';
let since = new Date().toISOString();
function connect() {
const url = `${HOOKA_URL}/topics/deployments/stream?since=${encodeURIComponent(since)}`;
const es = new EventSource(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
es.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
handle(msg);
since = msg.created_at; // advance cursor for next reconnect
});
es.addEventListener('error', () => {
es.close();
setTimeout(connect, 1000); // reconnect; since is already advanced
});
}
connect();8) Error Handling Contract
Common status outcomes:
200: accepted/sent400: invalid payload, invalid topic, bad query params401: unauthorized or token scope mismatch429: rate-limited502: all delivery targets failed after retries5xx: server or dependency issue
Retry guidance:
- Retry:
429,502,5xx - Do not retry blindly:
400,401 - Always use
Idempotency-Keywhen retrying writes
9) Integration Examples
GitHub Actions
yaml
- name: Notify Deploy
env:
HOOKA_URL: ${{ secrets.HOOKA_URL }}
HOOKA_API_KEY: ${{ secrets.HOOKA_API_KEY }}
run: |
curl -sS -X POST "$HOOKA_URL/publish/deployments" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Idempotency-Key: gha-${{ github.run_id }}-${{ github.job }}" \
-H "Content-Type: application/json" \
-d '{"title":"Deploy","text":"${{ github.repository }} @ ${{ github.sha }}","priority":4,"tags":["prod","github-actions"]}'GitLab CI
yaml
notify:
script:
- |
curl -sS -X POST "$HOOKA_URL/publish/deployments" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Idempotency-Key: gl-${CI_PIPELINE_ID}-${CI_JOB_ID}" \
-H "Content-Type: application/json" \
-d "{\"text\":\"Pipeline ${CI_PIPELINE_ID} finished\",\"tags\":[\"gitlab\"]}"Node.js
js
await fetch(`${process.env.HOOKA_URL}/publish/incidents`, {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.HOOKA_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `incident-${incidentId}`,
},
body: JSON.stringify({
title: "Incident",
text: "Checkout latency high",
priority: 5,
tags: ["prod", "incident"],
}),
});Python
python
import os
import requests
requests.post(
f"{os.environ['HOOKA_URL']}/publish/alerts",
headers={
"Authorization": f"Bearer {os.environ['HOOKA_API_KEY']}",
"Idempotency-Key": "alert-123",
},
json={"text": "CPU > 90%", "priority": 4, "tags": ["infra", "cpu"]},
timeout=10,
)Go
go
payload := map[string]any{"text": "Backup complete", "tags": []string{"backup"}}
b, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", os.Getenv("HOOKA_URL")+"/publish/ops", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+os.Getenv("HOOKA_API_KEY"))
req.Header.Set("Idempotency-Key", "backup-20260305")
req.Header.Set("Content-Type", "application/json")
_, _ = http.DefaultClient.Do(req)10) Services Without Custom Header Support
Use URL token mode for direct send:
bash
curl -X POST "$HOOKA_URL/send/<TOKEN>" \
-H "Content-Type: application/json" \
-d '{"text":"Event happened"}'Prefer scoped tokens whenever possible. Do not expose master key in URLs.
11) Operational Checklist
- Use one token per integrating service.
- Scope token topic patterns (
billing*,deploy*, etc.). - Rotate tokens periodically via
/tokens+ revoke old tokens. - Use idempotency keys for all publish requests.
- Monitor dead letters per critical topic.
- Scrape
/metricsand alert on delivery failures / retry spikes. - Use
/readyfor platform readiness checks (returns503if DB is down). - Tune retention vars (
MESSAGE_RETENTION_DAYS,IDEMPOTENCY_RETENTION_DAYS,DEAD_LETTER_RETENTION_DAYS,REMINDER_RETENTION_DAYS) for your compliance window. - Keep topic naming consistent and documented.
12) Useful Endpoints Summary
GET /healthGET /readyGET /metricsPOST /publish/{topic}POST /send-chat-actionPOST /publish/{topic}/voice(multipart, OGG/Opus)POST /sendandPOST /send/{token}POST /send-voice(multipart, OGG/Opus)POST /topics/{topic}/routesGET /topics/{topic}/routesGET /topics/{topic}/aclPOST /topics/{topic}/aclGET /topics/{topic}/messagesGET /topics/{topic}/stream(SSE)GET /topics/{topic}/deadlettersGET|PUT|DELETE /topics/{topic}/digestPOST /tokensGET /tokensDELETE /tokens/{id}POST /login/requestsGET /login/requestsGET /login/requests/{id}POST /login/requests/{id}/approvePOST /login/requests/{id}/denyPOST /login/requests/{id}/exchangePOST /remindersGET /remindersGET /reminders/{id}DELETE /reminders/{id}POST /appsandGET /appsGET|PATCH|DELETE /apps/{namespace}POST /apps/{namespace}/commandsGET|PATCH|DELETE /apps/{namespace}/commands/{name}
13) Log in with Hooka
This flow lets a public client request a scoped Hooka token and lets an admin approve or deny it with the existing master API auth. The client must keep the returned device_code secret. Public requests must ask for a concrete topic pattern such as demo-*; wildcard * can only be granted by an authenticated admin override during approval.
Create a public request:
bash
curl -X POST "$HOOKA_URL/login/requests" \
-H "Content-Type: application/json" \
-d '{"client_name":"demo-site","can_read":true,"can_publish":true,"topic_pattern":"demo-*"}'List, approve, or deny as admin:
bash
curl "$HOOKA_URL/login/requests?status=pending" \
-H "Authorization: Bearer $HOOKA_MASTER_KEY"
curl -X POST "$HOOKA_URL/login/requests/lr_abc123/approve" \
-H "Authorization: Bearer $HOOKA_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"can_read":true,"can_publish":false,"topic_pattern":"demo-*"}'
curl -X POST "$HOOKA_URL/login/requests/lr_abc123/deny" \
-H "Authorization: Bearer $HOOKA_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"reason":"unknown client"}'Poll/exchange from the client:
bash
curl -X POST "$HOOKA_URL/login/requests/lr_abc123/exchange" \
-H "Content-Type: application/json" \
-d '{"device_code":"hdc_secret"}'Exchange responses use 202 authorization_pending, 403 access_denied, 410 expired_token, or 200 with token_value. The token value is returned once; later exchanges return 409 already_exchanged.
14) Reminders (Scheduled Notifications)
Schedule one-shot or recurring notifications. Reminders dispatch through the normal publish pipeline at fire time — topic routing, retries, and dead-letters all apply. An in-memory timer gives precise firing; a 30s poller is the durability safety net for missed timers (e.g. during restarts). Multiple server instances are safe — claim uses FOR UPDATE SKIP LOCKED.
Schedule fields
Provide at least one field overall; choose at most one first-fire field and at most one recurrence field:
| Field | Meaning | Example |
|---|---|---|
fire_at | RFC3339 absolute first-fire time | 2026-04-20T09:00:00Z |
in | Go duration from now (first fire) | 30m, 2h, 1h30m |
every | Go duration recurring interval | 1h, 24h (min 1s) |
cron | 5-field cron or descriptor | 0 9 * * 1-5, @hourly, @every 1h |
If only every/cron is given, the first fire_at is derived from the schedule. You may combine one first-fire field with one recurrence field, for example {"in":"10m","every":"24h"}.
Schedule a reminder
bash
# one-shot relative
curl -X POST "$HOOKA_URL/reminders" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Idempotency-Key: reminder-deployments-2026-04-20" \
-H "Content-Type: application/json" \
-d '{
"topic": "deployments",
"in": "30m",
"notification": {"text": "stand up in 30m", "priority": 3}
}'
# recurring weekdays 9 UTC
curl -X POST "$HOOKA_URL/reminders" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"topic": "standup",
"cron": "0 9 * * 1-5",
"notification": {"title": "Daily standup", "text": "Join the call", "priority": 4}
}'
# direct Telegram forum topic (fixed destination recorded in the response)
curl -X POST "$HOOKA_URL/reminders" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"in": "10m",
"notification": {
"chat_id": "-1001234567890",
"message_thread_id": "42",
"text": "Check the deployment"
}
}'The returned reminder is the durable creation receipt: it includes the normalized schedule, notification, created_at, status, and an explicit delivery object. Direct chat/thread destinations have resolution:"fixed". Topic reminders have resolution:"at_fire_time" because their routes are intentionally looked up when they fire. Reuse the same request Idempotency-Key after a timeout; the response returns the original reminder with idempotency_hit:true instead of scheduling twice.
List, get, cancel
bash
# default list excludes terminal statuses (fired/failed/cancelled)
curl "$HOOKA_URL/reminders?topic=deployments&limit=50" \
-H "Authorization: Bearer $HOOKA_API_KEY"
# include history
curl "$HOOKA_URL/reminders?topic=deployments&include_terminal=true" \
-H "Authorization: Bearer $HOOKA_API_KEY"
curl "$HOOKA_URL/reminders/42" -H "Authorization: Bearer $HOOKA_API_KEY"
# DELETE is idempotent; cancelling a non-pending reminder returns cancelled:false
curl -X DELETE "$HOOKA_URL/reminders/42" -H "Authorization: Bearer $HOOKA_API_KEY"Statuses
| Status | Meaning |
|---|---|
pending | Scheduled or durably waiting for a retry. |
firing | Transient — claim in progress. |
fired | One-shot completed successfully. |
failed | One-shot exhausted REMINDER_MAX_ATTEMPTS. |
cancelled | Cancelled by DELETE. |
Recurring reminders return to pending with a new fire_at after each fire.
Auth
POST /remindersneeds publish permission on the target topic. A reminder withnotification.chat_iduses direct delivery and needs permission on thedirectpseudo-topic, even iftopicis also present.GET/DELETEon a specific id use the same rule — any token that could schedule the reminder can manage it.GET /reminderswithout atopicfilter requires the master key.
Idempotency
Use the request Idempotency-Key header to make creation retry-safe. Separately, idempotency_prefix names delivery attempt keys as <prefix>-<reminder_id>-<occurrence>-<attempt>. Occurrences retry durably with exponential backoff. Delivery is intentionally at-least-once: Telegram offers no provider-side idempotency transaction, so a crash after Telegram accepts a message but before Hooka records success can rarely produce a duplicate. The header and prefix are each limited to 200 UTF-8 bytes.
15) Telegram Group Admin Workflow
For operators who manage chats directly in Telegram UI:
- Invite the bot to a group.
- In the group, run
/chatidto get the exact chat ID, current forum thread ID, and bound hooka topics instantly. - Hooka notifies
TELEGRAM_ADMIN_CHAT_IDwith inline controls. - Admin taps
Stop Actingto disable deliveries to that chat, orResumeto re-enable. - In the group (or forum thread), run
/webhookto mint an anonymous ingest URL that delivers into that chat./webhook rotatere-issues;/webhook revokekills it;/webhook statusreports without revealing the token. The URL is a capability secret: anyone who can read the chat can POST to it.
Required env vars for this workflow:
TELEGRAM_UPDATES_ENABLED=true(default)TELEGRAM_ADMIN_CHAT_ID=<your-telegram-id-or-admin-chat-id>TELEGRAM_ADMIN_USER_ID=<your-telegram-user-id>(for button authorization and/webhook)HOOKA_PUBLIC_URL(orHOOKA_URL/RAILWAY_PUBLIC_DOMAIN) so/webhookcan print an absolute POST URL
Memo-style inbound capture
To capture ordinary Telegram text and photo messages as hooka topic messages, configure the current chat or forum thread from inside Telegram:
text
/capture <topic>
/capture_status
/capture_stopInbound capture is Telegram-to-hooka: non-command Telegram text and photos become stored hooka topic messages. /capture todo enables capture for the current chat/thread, ensures the todo topic exists, and stores future captured messages there with telegram and inbound tags. Photo messages also receive a photo tag. The raw payload includes Telegram chat, thread, message, sender, and photo file metadata. hooka stores Telegram file_id / file_unique_id metadata for photos, but it does not download or retain image bytes. Captioned photos use the caption as message text; captionless photos use [Photo] as a stable placeholder. Captured messages do not need a route; they are stored directly and then topic watchers run. Replies that match a hooka-delivered notification still use reply handlers instead of being duplicated into the inbound topic. Enabling capture makes the topic visible in the Topics list immediately; actual inbound messages appear in that topic's log after Telegram sends them to hooka. Consumers that need Telegram chat, thread, message, sender, or photo metadata can read messages or SSE streams with include_payload=true.
Telegram group privacy mode still applies. For full group/thread capture, turn off privacy mode for the bot in BotFather; otherwise Telegram may only deliver commands, DMs, replies, and mentions to hooka. In existing groups, Telegram may continue delivering only command updates until the bot is removed and re-added; promoting the bot to admin also makes ordinary message delivery reliable. If /capture works but the topic message log stays empty for normal text, this is the first thing to check. Sending /capture without a topic returns usage text; use /capture_status to inspect the current setting.
The same capture rows are manageable outside Telegram:
bash
hooka inbound-captures --topic todo --include-disabled
hooka inbound-capture-set --chat-id -1001234567890 --topic todo --priority 3
hooka inbound-capture-stop --chat-id -1001234567890 --topic todobash
curl -H "Authorization: Bearer $HOOKA_API_KEY" \
"$HOOKA_URL/inbound-captures?topic=todo&include_disabled=true"
curl -N -H "Authorization: Bearer $HOOKA_API_KEY" \
"$HOOKA_URL/topics/todo/stream?tags=telegram,inbound&include_payload=true"
curl -X POST "$HOOKA_URL/inbound-captures" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"chat_id":"-1001234567890","topic_name":"todo","enabled":true,"priority":3}'The web UI shows enabled capture rows in a topic's Routes tab and can add or stop capture rows from the topic detail page.
Optional defaults:
bash
TELEGRAM_INBOUND_ENABLED=true
TELEGRAM_INBOUND_ACK_DEFAULT=false
TELEGRAM_INBOUND_PRIORITY=316) Alertmanager (Prometheus) integration
Prometheus Alertmanager fires webhooks with a fixed-shape payload (version="4", groupKey, status, alerts[]). That shape is not templatable, so /publish/{topic} cannot accept it directly. POST /alertmanager/{topic} translates each alert in the batch into one stored message via the same publish pipeline used by /publish/{topic} — idempotency, persistence, topic_routes fan-out, retries, and dead letters all apply.
Endpoint
POST /alertmanager/{topic} — Bearer auth, same scope rules as /publish/{topic} (token must allow publish on the topic).
Request body is the AM webhook v1 payload:
bash
curl -X POST "$HOOKA_URL/alertmanager/alerts" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"version": "4",
"groupKey": "{}:{alertname=\"HighCPU\"}",
"truncatedAlerts": 0,
"status": "firing",
"receiver": "hooka-default",
"groupLabels": {"alertname": "HighCPU"},
"commonLabels": {"alertname": "HighCPU", "severity": "warning", "instance": "api-1"},
"commonAnnotations": {"summary": "CPU > 90% for 5m"},
"externalURL": "https://alertmanager.example.com",
"alerts": [
{
"status": "firing",
"labels": {"alertname": "HighCPU", "severity": "warning", "instance": "api-1"},
"annotations": {"summary": "CPU > 90% for 5m", "description": "api-1 hot for 5m"},
"startsAt": "2026-04-27T12:34:56.000Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "https://prom.example.com/graph?g0.expr=...",
"fingerprint": "a1b2c3d4e5f60718"
}
]
}'Response shape (200):
json
{
"ok": true,
"topic": "alerts",
"alerts_total": 1,
"alerts_processed": 1,
"results": [
{"fingerprint": "a1b2c3d4e5f60718", "message_id": 4711, "delivered": 1, "failed": 0}
]
}Per-alert errors are reported in results[].error rather than failing the batch — this prevents AM from retrying every alert when only one is bad. Status codes:
200: at least one alert was dispatched (others may have per-alert errors).400: empty/invalid payload (e.g. missingalerts[], malformed JSON).401: auth failure or topic-scope mismatch.502: every alert failed dispatch (AM may retry the whole batch).
Severity → priority mapping
The endpoint reads labels.severity per alert and maps it to hooka's priority:
severity | hooka priority | Notes |
|---|---|---|
critical | 5 | |
warning | 3 | |
info | 2 | |
watchdog | 1 | sets disable_notification=true (silent push) |
| (other / missing) | 3 | default |
Configure Alertmanager
url_file keeps the topic-scoped URL out of the AM config file (it pairs with the bearer token to grant publish on the topic). One topic per Telegram thread is the recommended pattern for severity-based threading:
yaml
receivers:
- name: hooka-default
webhook_configs:
- url_file: /etc/alertmanager/secrets/hooka_url # contains https://hooka.example.com/alertmanager/alerts
http_config:
authorization:
type: Bearer
credentials_file: /etc/alertmanager/secrets/hooka_token
send_resolved: true
- name: hooka-critical
webhook_configs:
- url_file: /etc/alertmanager/secrets/hooka_url_critical # https://hooka.example.com/alertmanager/alerts-critical
http_config:
authorization:
type: Bearer
credentials_file: /etc/alertmanager/secrets/hooka_token
send_resolved: trueRecommended topic + route setup
Create one topic per thread you want; attach a Telegram route with the desired chat_id and message_thread_id:
bash
# Default alerts topic
curl -X POST "$HOOKA_URL/topics/alerts/routes" \
-H "Authorization: Bearer $MASTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider":"telegram","chat_id":"-1001234567890","message_thread_id":"42","parse_mode":"HTML"}'
# Critical alerts → separate thread
curl -X POST "$HOOKA_URL/topics/alerts-critical/routes" \
-H "Authorization: Bearer $MASTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider":"telegram","chat_id":"-1001234567890","message_thread_id":"43","parse_mode":"HTML"}'DeadMansSwitch (Watchdog) handling
AM's DeadMansSwitch / Watchdog alert is meant to fire continuously every minute — its absence indicates the alert path is broken. Setting its label to severity: watchdog makes hooka stamp the resulting message with priority=1 and disable_notification=true, so the alert lands silently in the topic without spamming chat. Pair it with a webhook route on the same topic that pings healthchecks.io to make absence visible:
bash
curl -X POST "$HOOKA_URL/topics/watchdog/routes" \
-H "Authorization: Bearer $MASTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider":"webhook","target":"https://hc-ping.com/<uuid>"}'Idempotency
The endpoint auto-synthesizes the idempotency key per alert as am:<groupKey>:<fingerprint>:<status> so AM retries land on the same stored message. Do not pass an Idempotency-Key header on this endpoint — the synthesized key wins. Re-firing alerts (same fingerprint, same status) and resolved transitions (status="resolved") each produce a stable, distinct key.
Why not /publish/{topic}?
The AM webhook payload is fixed-shape (version, groupKey, alerts[], RFC3339 timestamps, hex fingerprint). /publish/{topic} expects hooka's own NotificationRequest. A dedicated endpoint translates AM's batch into one NotificationRequest per alert without forcing every operator to template AM payloads or run a relay.
17) GitHub repository monitoring
GitHub can POST a JSON payload to a URL of your choice whenever something happens in a repository or organization (a webhook). It cannot send a bearer header, but it can be handed a URL — and an anonymous ingest URL (§5.2) is exactly that: a publish-only, single-topic, revocable, rate-limited capability whose token is the credential. So there is no separate GitHub endpoint: register an ingest URL as the webhook, and hookad detects GitHub deliveries by their X-GitHub-Event header and renders them as repository activity.
Setup (two minutes)
- Mint an ingest URL bound to the topic that should receive the activity. From Telegram, run
/webhookin the chat or forum thread you want the messages in (§15). From the CLI:hooka ingest-create github --name "gh-<repo>". - On GitHub open the repository (or organization) → Settings → Webhooks → Add webhook, and fill in:
- Payload URL: the
https://hooka.example/in/hin_...URL. - Content type:
application/json(required; the form-encoded default is rejected). - Secret (optional, recommended): paste the same token.
hookadthen verifiesX-Hub-Signature-256on every delivery and answers 403 on a mismatch. - Events: "Send me everything" is fine — the noise policy below decides what reaches Telegram. Or pick individually.
- Payload URL: the
- GitHub sends a
pingimmediately; a🔗 owner/repo: webhook connectedmessage confirms the path end to end.
An organization-level webhook covers every repository in the org with one registration. Personal accounts have no account-wide webhook, so cover every repository you own with the bundled sync script. It is idempotent (repos that already have the URL are skipped), skips forks and archived repos by default, and can be rerun on a schedule to pick up new repositories:
bash
scripts/gh-webhook-sync.sh your-github-user "https://hooka.example/in/hin_..." "hin_..."
# DRY_RUN=1 to preview; INCLUDE_FORKS=1 / INCLUDE_ARCHIVED=1 to widenNew repositories. A per-repo webhook cannot tell you a repo was created (it does not exist yet when the repo is). Two ways to get that:
- Schedule the sync script with
STATE_FILEset. Each run lists your repos (one paginated call), registers the webhook on any repo it has not seen, and announces it through the same URL as arepository createdevent, so a🆕 owner/repo: repository createdline shows up like any other event. Run it from cron or a systemd/launchd timer, e.g. every 15 minutes. To opt a repo out, list it inEXCLUDE_FILEand delete its hook on GitHub. - Or install a GitHub App on your account with "All repositories" and point its webhook at the ingest URL: GitHub then sends
repository.created(and every other event) for future repos with no per-repo registration at all. That is the always-on option; it needs a few clicks in GitHub's UI.
A single repository by hand:
bash
gh api repos/OWNER/REPO/hooks --method POST --input - <<'JSON'
{"name":"web","active":true,"events":["*"],
"config":{"url":"https://hooka.example/in/hin_...","content_type":"json","secret":"hin_..."}}
JSONAll repositories can share one ingest URL; each message is tagged with the repository name and titled owner/repo: ..., so one topic scales to hundreds of repositories. The per-hook rate limit (INGEST_RATE_PER_MINUTE, default 12, with an INGEST_BURST allowance) applies only to forwarded events, not to skipped ones.
Batching: one rollup instead of a message per event
With many repositories, put a topic digest (§18) in front of the Telegram route: point the ingest hook at a source topic with no routes, and have hooka roll it up into a target topic every N minutes, grouped by repository:
bash
# source topic "github" (no routes), target "github-digest" → Telegram thread
curl -X POST "$HOOKA_URL/topics/github-digest/routes" -H "Authorization: Bearer $MASTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider":"telegram","chat_id":"-100...","message_thread_id":"42","parse_mode":"HTML"}'
curl -X PUT "$HOOKA_URL/topics/github/digest" -H "Authorization: Bearer $MASTER_API_KEY" \
-H "Content-Type: application/json" -d '{"target_topic":"github-digest","interval":"30m"}'Every 30 minutes with activity, one message arrives:
your-org/api
• ⬆️ main ← fix(ingest): render bullets
• ❌ workflow “CI” failed on main — fix(ingest): render bullets
your-org/web
• 🔀 PR #4 opened — thingThe raw per-event messages stay in the github topic history and SSE stream, so agents can still react live while humans get the rollup.
What is forwarded (noise policy)
GitHub fires many deliveries per human action. Only the following reach the topic; everything else is acknowledged with 200 {"ok":true,"skipped":true,"reason":...} so GitHub does not retry, and skipped deliveries cost no hook use and no rate-limit token.
| Event | Forwarded when | Priority |
|---|---|---|
ping | always | 2 |
push | any branch, with ≥1 commit (branch named in the title; newest 5 commits shown, compare link) | 3, force-push 4 |
pull_request | opened, reopened, ready_for_review, closed (merged vs. closed without merging) | 3 |
issues | opened, reopened, closed | 3 |
issue_comment, pull_request_review_comment | created, author is not a bot | 3 |
pull_request_review | submitted: approved, changes requested, or a non-empty comment review | 3 |
release | published | 3 |
workflow_run | completed with conclusion failure, startup_failure, timed_out, action_required (successes are silent) | 4 |
create, delete | tags only (branches are skipped) | 3 |
star, fork | star created, every fork — delivered silently | 2 |
repository | publicized, privatized, archived, unarchived, deleted, renamed, transferred | 4 |
member | added, removed, edited | 4 |
discussion | created | 3 |
Not forwarded: PR synchronize/edited/labeled/assigned/review_requested, empty pushes (branch deletes), CI successes, check_run/check_suite/status, bot comments, and any event type not listed.
Message shape
Title: {emoji} owner/repo: PR #123 opened — title, ⬆️ owner/repo: main ← commit subject, ❌ owner/repo: workflow “CI” failed on main — commit subject. Body: a few Label: value lines (author, branch, diff size, an excerpt of the PR/issue/ comment body capped at 300 characters) and an inline View PR / View run / Compare button. Every message opens with the usual ingest provenance line (📥 via ingest "<name>" · GitHub pull_request) and is tagged ingest, github, the event kind, and the repository name. The stored payload carries source: "github", github_event, github_action, github_delivery, repository, and url — not the raw GitHub body.
Safety properties on top of §5.2
- Every payload value is HTML-escaped. The adapter owns the markup; a crafted PR title cannot inject Telegram HTML.
- Only
https://github.com/links survive. Link buttons and anchors are dropped for any other host, so a leaked URL cannot be used to push phishing links dressed up as GitHub activity. - Priority is capped at 4, never the paging tier — same as every ingest post.
- Redeliveries are idempotent.
X-GitHub-Delivery(GitHub's per-delivery GUID, reused on manual redelivery) is the idempotency key, namespaced per hook; a replay returns the original message id without consuming a use. - Signature verification is opt-in by setting the GitHub secret to the token. Without a secret the URL alone is the credential, exactly as for any other ingest post.
- GitHub marks a delivery failed if no response arrives within 10 seconds. Telegram delivery is synchronous but normally sub-second; a slow Telegram API surfaces in GitHub's "Recent Deliveries" tab as a timeout and is retried on manual redelivery only.
18) Topic digests (batched delivery)
A digest turns a chatty source topic into one rollup message per interval on a target topic. Messages published to the source (by any path: /publish, batch, ingest, GitHub, reminders) are stored as usual, visible in history and on the SSE stream, but skip immediate delivery. Every interval, hooka drains everything past its cursor into a single grouped message and publishes it to the target through the normal dispatch path, so the target's routes, idempotency, retries, and dead letters all apply.
Endpoint
PUT /topics/{source}/digest (admin, master key) with {"target_topic":"...","interval":"30m"}. GET returns the configuration including next_flush_at; DELETE removes it (the source resumes immediate delivery through its own routes, if any).
bash
curl -X PUT "$HOOKA_URL/topics/alerts/digest" \
-H "Authorization: Bearer $MASTER_API_KEY" -H "Content-Type: application/json" \
-d '{"target_topic":"alerts-hourly","interval":"1h"}'
# → {"ok":true,"digest":{"source_topic":"alerts","target_topic":"alerts-hourly",
# "interval":"1h0m0s","last_message_id":4711,"last_flushed_at":"...","next_flush_at":"..."}}Rules and behaviour:
intervalis a Go duration between1mand24h. The flush loop ticks every 30 seconds, so a flush lands within 30 seconds of the interval elapsing.- Empty windows publish nothing. The clock resets, no "0 updates" message.
- No replay on creation. The cursor starts at the source's newest message.
- No chains. A target cannot become a source and a source cannot become a target; both are refused with 400.
- Rollup shape. No title or summary line (a title would carry the priority prefix); just one bullet per message using its title (linked to the payload's
urlwhen it is an http(s) URL). Messages whose payload has arepositoryfield are grouped under a bold per-repository heading, most active first, with theowner/repo:title prefix stripped; others are listed flat at the end. At most 40 lines are shown (… and N more (see topic history)), and at most 200 messages are drained per flush (… N more not shown; further updates roll into the next digest). - Priority is the maximum of the batched messages, so a CI failure makes the rollup priority 4. Tags:
digest,{source}. - Idempotent and race-safe. The rollup's key is
digest:{source}:{first_id}-{last_id}. Each due window is claimed with a compare-and-set before draining, so two server instances (deploy overlap) never deliver overlapping rollups. A rollup that was stored but failed delivery is dead-lettered like any other message and the cursor advances (replay it from the dead-letter queue); only a rollup that was never stored (target has no route) keeps the cursor, and the flush retries after the next interval. The youngest 5 seconds of messages are held back so an insert that committed out of id order is never skipped. - Publishers to a digest source get a normal
200 {"ok":true,...}withdelivered: 0,destinations: [], and adigestobject naming the target topic andnext_flush_at; dry runs reportplatforms: []plus the samedigestobject. The message is stored, not lost. - The stored rollup's payload carries
type: "digest",source_topic,first_message_id,last_message_id,count,window_start,window_end.
19) Apps (a namespace for someone else's commands)
An app is a program built on Hooka. It is not a new transport; it is a composition of three things Hooka already has:
| Piece | What it is | Hooka primitive |
|---|---|---|
| namespace | the Telegram command surface: /spies …, /keyword_alert … | new: apps + app_commands registry |
| in topic | every invocation is published here as an app.command message | a topic, delivered by its routes |
| out topic | the app publishes its replies here | a topic; "installed in a chat" = a Telegram route from it to that chat |
Hooka routes the command and does not run it. Delivery to the app is whatever routes the in topic has: add a webhook route and you get the same retries, delivery records, and dead letters as any other topic; add none and the message is stored for SSE and history readers. There is no app-specific webhook and no fallback to the operator's default chat.
That is the split: Spies can add a Google search command, and the keyword alert feature can take a namespace of its own, without either one becoming a hard-coded Hooka handler. hooka-agent-bridge can be the app too: point it at the app's in and out topics (see AGENT_BRIDGE.md). For the end-to-end walkthrough, from registration to a deployed worker and a smoke test, see APP_GUIDE.md; this section is the reference.
Register Spies, then a Google command
bash
hooka app-create --namespace spies --name Spies \
--description "Search and alerts"
hooka app-command-add spies google \
--description "Search Google" \
--usage "<query>" \
--topic app-spies-google/spies google site:github.com hooka, typed in a chat Spies is installed in, publishes this payload to app-spies-google and does not call Google:
json
{
"kind": "app.command",
"namespace": "spies",
"command": "google",
"args": "site:github.com hooka",
"invocation": "namespace",
"topic": "app-spies-google",
"out_topic": "app-spies-out",
"chat_id": "-1001234567890"
}Spies reads that topic (SSE, GET /topics/app-spies-google/messages, or a webhook route it adds with POST /topics/app-spies-google/routes) and runs the search. It answers by publishing to out_topic.
Target the reply. An app can be installed in several chats, and a plain publish to out_topic fans out to every route on it, so one chat's result would land in all of them. Copy chat_id and message_thread_id from the event into the publish to answer only the chat that asked:
bash
curl -X POST "$HOOKA_URL/publish/app-spies-out" \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
-d '{"chat_id":"-1001234567890","message_thread_id":"","text":"3 results for site:github.com hooka …"}'A publish with chat_id goes to that chat only (and inherits the thread of the matching route when message_thread_id is empty). Publish without chat_id only when the reply is genuinely for every installed chat.
Give keyword alert its own namespace
bash
hooka app-create --namespace keyword_alert --name "Keyword alert"
hooka app-command-add keyword_alert watch \
--description "Watch a keyword" \
--usage "<keyword>" \
--defaultdefault makes the namespace itself the command. /keyword_alert openai queues watch with args openai on app-keyword_alert-in. The explicit form /keyword_alert watch openai and the flat menu command /keyword_alert_watch openai do the same. /keyword_alert with no args, or /keyword_alert help, lists the commands. help cannot be a command name.
Install from the Hooka chat
The configured admin (TELEGRAM_ADMIN_USER_ID) can do the same registration from the chat that should receive the app's replies. Chat install does not accept custom topics: the in topic is app-{namespace}-in and the out topic is app-{namespace}-out.
/app install keyword_alert Keyword alert
/app command keyword_alert watch --default Watch a keyword
/app list
/app remove keyword_alert/app install creates the app if needed and upserts a Telegram route from the out topic to the current chat or forum thread. That route is both the reply path and the permission: /keyword_alert openai is accepted only in chats the app is installed in, and is refused elsewhere, help included. /app remove deletes the namespace everywhere, not only the route in this chat. Anyone who is not the configured admin gets a refusal, including when TELEGRAM_ADMIN_USER_ID is unset.
Commands that carry a file
A document or photo whose caption is an app command (/xray hr, /xray@botname hr, or the flat /xray_review hr) is that command, handled exactly like the typed one with the caption as text. Built-in commands do not read captions, and a caption that is not an app command, or a file with no caption, is handled as before. The app.command event then carries the file by reference:
json
"attachment": {
"kind": "document",
"file_id": "BQACAgIAAxkBAAIB...",
"file_unique_id": "AgADmAQAAq...",
"file_name": "resume.pdf",
"mime_type": "application/pdf",
"file_size": 48213
}kind is document or photo; a photo is the largest size, with mime_type image/jpeg and file_name photo.jpg. Empty fields are omitted. A bare namespace command with a file (/xray alone) runs the namespace's default command with empty args rather than answering with help; /xray help is still help.
The app downloads the file with its own token:
GET /topics/{topic}/messages/{id}/attachment
Authorization: Bearer <token with read on {topic}>{id} is the stored message id. The response is the file: Content-Type is the attachment's mime_type (or application/octet-stream), Content-Disposition: attachment carries a cleaned file name (RFC 2231 encoded when not ASCII), and Cache-Control: private, no-store. Hooka resolves the file with Telegram's getFile on each call and streams it through; nothing is stored, and the bot token never leaves the server.
| Status | When |
|---|---|
| 200 | the file |
| 400 | invalid topic or message id |
| 401 | no read permission on {topic} |
| 404 | the message does not exist, is on another topic, has no attachment, or was not queued by Hooka for an app command |
| 502 | Telegram refused or failed, or the file is over Telegram's 20 MiB bot download limit |
App command messages are stored with source: "telegram_app_command", and only those can serve a download. A publisher who can write the topic can post a payload shaped like an app command, but cannot set that provenance, so the endpoint cannot be turned into a fetcher for arbitrary Telegram files. The Go SDK exposes this as DownloadAttachment (0.1.3).
The store
internal/server/catalog.json is a curated list of app manifests compiled into the server; HOOKA_APP_CATALOG can point at an operator's own catalog file (same schema, validated at startup) that replaces it. GET /store lists it with installation state; POST /store/{namespace}/install registers the app, adds missing commands, adds the manifest's webhook route to the in topic, routes the out topic to an optional chat_id, and mints the app's app-{namespace}-* token on first install (or on mint_token: true). It is idempotent. The same install is hooka app-install <namespace>, /app install <namespace> in Telegram (no token from a chat), and the dashboard's Store page. See APP_GUIDE.md section 7 to publish an app.
Rules
- Master key only on
/appsand/store. A scoped publish token cannot register or delete apps. The Telegram/appcommand is limited toTELEGRAM_ADMIN_USER_ID. - Namespace: 2–24 characters,
[a-z][a-z0-9_]. Built-in bot commands (capture,recent,poll,webhook, …) are reserved. - Command names use the same alphabet, 1–32 characters. The Telegram menu entry is
{namespace}_{command}when that fits in 32 characters. The/namespace <command>form always works. in_topicandout_topicmust differ, or commands would echo back into the chat. Empty commandtopicinherits the app in topic.- At most 40 apps and 30 commands per app. Built-in bot commands stay at the front of the Telegram menu; app commands fill the rest, up to Telegram's 100-command cap. The menu refresh runs in the background, coalesced, so it never delays an HTTP response or Telegram's webhook acknowledgement.
- Replays of the same Telegram message are idempotent (
app:{namespace}:{command}:{chat}:{message_id}, plus the callback id for button presses): nothing is stored or delivered twice. - Hooka acknowledges a command with a typing indicator when the in topic has a route or a live SSE subscriber, and with a "not running right now" message otherwise. The app's own reply is the visible answer.
- Inline buttons with
callback_dataapp:<namespace> <verb> <args>are routed as that command from the presser (invocation: "button"). - A document or photo captioned with an app command is that command, with an
attachment; the app fetches it fromGET /topics/{topic}/messages/{id}/attachment(see above). default_commandis re-checked against enabled commands inside the write, so a concurrent command delete cannot leave it dangling.- Disabling an app, or deleting it, drops its commands from the menu.
bash
curl -X POST "$HOOKA_URL/apps" \
-H "Authorization: Bearer $MASTER_API_KEY" -H "Content-Type: application/json" \
-d '{"namespace":"keyword_alert","name":"Keyword alert"}'
# Deliver invocations to the app with an ordinary webhook route:
curl -X POST "$HOOKA_URL/topics/app-keyword_alert-in/routes" \
-H "Authorization: Bearer $MASTER_API_KEY" -H "Content-Type: application/json" \
-d '{"provider":"webhook","target":"https://keyword-alert.example/hook"}'