Appearance
Build and Deploy a Hooka App
An app is a program that owns a Telegram command on the Hooka bot. Hooka routes the command to your program and delivers your replies back to the chat. It does not run your code, and it adds no transport of its own: an app is a namespace plus two topics, and everything else is ordinary Hooka.
Two ways in (Telegram, SMS), your app as a separate process between an in topic and an out topic, and the out topic's routes as the way back:
Rendering diagram…
Solid arrows are synchronous calls or stores; dotted arrows are the two asynchronous ways an app receives commands. SMS has its own view in diagrams/sms-flow.mmd.
| You need | Where it comes from |
|---|---|
HOOKA_URL | your server, e.g. https://hooka.example.com |
| a master key | the operator; only it can register apps and mint tokens |
| a scoped token for the app | step 2 below |
| a chat where the bot is present | invite the bot, /chatid shows the id |
| somewhere to run a process | Docker, any process host, or a shell |
This guide builds keyword_alert: /keyword_alert <keyword> in a chat asks the app to watch a keyword, and the app answers in the same chat.
1. Register the app
From the chat that should receive the app's replies, as the configured Telegram admin (TELEGRAM_ADMIN_USER_ID):
text
/app install keyword_alert Keyword alert
/app command keyword_alert watch --default Watch a keywordThe first line creates the app with the in topic app-keyword_alert-in and the out topic app-keyword_alert-out, then routes the out topic to this chat. That route is what "installed" means, and it is also the permission: the command only works in chats it has been installed into. The second line registers watch as the default verb, so /keyword_alert openai is enough.
The same registration over HTTP, with the master key:
bash
export HOOKA_URL="https://hooka.example.com"
curl -sS -X POST "$HOOKA_URL/apps" \
-H "Authorization: Bearer $MASTER_API_KEY" -H "Content-Type: application/json" \
-d '{"namespace":"keyword_alert","name":"Keyword alert"}'
curl -sS -X POST "$HOOKA_URL/apps/keyword_alert/commands" \
-H "Authorization: Bearer $MASTER_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"watch","description":"Watch a keyword","usage":"<keyword>","default":true}'
# install = a telegram route on the out topic
curl -sS -X POST "$HOOKA_URL/topics/app-keyword_alert-out/routes" \
-H "Authorization: Bearer $MASTER_API_KEY" -H "Content-Type: application/json" \
-d '{"provider":"telegram","chat_id":"-1001234567890","parse_mode":"HTML"}'Or with the CLI: hooka app-create --namespace keyword_alert --name "Keyword alert" and hooka app-command-add keyword_alert watch --description "Watch a keyword" --usage "<keyword>" --default.
2. Mint a token for the app
The app never holds the master key. It needs to read its in topic and publish to its out topic, nothing else:
bash
curl -sS -X POST "$HOOKA_URL/tokens" \
-H "Authorization: Bearer $MASTER_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"keyword-alert-app","can_read":true,"can_publish":true,"topic_pattern":"app-keyword_alert-*"}'Keep the returned token_value as the app's HOOKA_API_KEY.
3. Check the wiring before writing code
Type /keyword_alert openai in the chat. Nothing is listening yet, so the bot answers "Keyword alert is not running right now. Your command is saved and will be handled when it starts." (once the app is streaming, the only acknowledgement is a typing indicator), and the in topic now holds the event:
bash
curl -sS "$HOOKA_URL/topics/app-keyword_alert-in/messages?limit=1&include_payload=true" \
-H "Authorization: Bearer $HOOKA_API_KEY"json
{
"kind": "app.command",
"namespace": "keyword_alert",
"command": "watch",
"args": "openai",
"defaulted": true,
"topic": "app-keyword_alert-in",
"out_topic": "app-keyword_alert-out",
"chat_id": "-1001234567890",
"message_thread_id": "",
"telegram_message_id": 4711,
"from": {"id": 42, "username": "ada"}
}Nothing was delivered anywhere yet: the in topic has no routes, so the message is stored for readers. Publish a reply by hand to prove the return path:
bash
curl -sS -X POST "$HOOKA_URL/publish/app-keyword_alert-out" \
-H "Authorization: Bearer $HOOKA_API_KEY" -H "Content-Type: application/json" \
-d '{"chat_id":"-1001234567890","text":"Watching <b>openai</b>.","parse_mode":"HTML"}'The chat receives it. Do not continue until it does.
4. Write the app
An app is a loop: read the in topic, do the work, publish to the out topic with the event's chat_id and message_thread_id so the answer reaches only the chat that asked. Two ways to read the in topic:
Stream it. Open one long-lived SSE connection and dedupe by message id. This is the simplest option and needs no public endpoint on your side.
bash
curl -N "$HOOKA_URL/topics/app-keyword_alert-in/stream?include_payload=true" \
-H "Authorization: Bearer $HOOKA_API_KEY"Or be pushed. Add a webhook route to the in topic. Hooka then POSTs every event to you with the same retries, delivery records, and dead letters every route gets, and you can inspect failures with the dead-letter tools.
bash
curl -sS -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"}'A minimal app in Go using the public SDK:
go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
hooka "github.com/guzus/hooka/sdk/go"
)
type command struct {
Kind string `json:"kind"`
Command string `json:"command"`
Args string `json:"args"`
OutTopic string `json:"out_topic"`
ChatID string `json:"chat_id"`
MessageThreadID string `json:"message_thread_id"`
}
func main() {
// Reads HOOKA_URL and HOOKA_API_KEY from the environment. WithTimeout(0)
// removes the client-wide deadline; the subscription below is long-lived
// and would otherwise be cut every 30 seconds. Publishes bound themselves
// with a context timeout.
c, err := hooka.New(hooka.WithTimeout(0))
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
// Subscribe reconnects with backoff and resumes from the last message
// it delivered, so a network blip does not lose or duplicate commands.
err = c.Subscribe(ctx, "app-keyword_alert-in", &hooka.StreamOptions{IncludePayload: true},
func(m hooka.StoredMessage) error {
var cmd command
if err := json.Unmarshal(m.Payload, &cmd); err != nil || cmd.Kind != "app.command" || cmd.Command != "watch" {
return nil
}
reply := "Watching <b>" + cmd.Args + "</b>." // do the real work here
pubCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
_, err := c.Publish(pubCtx, cmd.OutTopic, hooka.Message{
ChatID: cmd.ChatID,
MessageThreadID: cmd.MessageThreadID,
Text: reply,
ParseMode: "HTML",
}, hooka.WithIdempotencyKey(fmt.Sprintf("watch-reply-%d", m.ID)))
return err
})
log.Fatal(err)
}The rules that matter:
- Always send
chat_id. An app can be installed in many chats. A publish withoutchat_idgoes to every route on the out topic. - Use an idempotency key per reply so a reconnect or retry cannot answer twice.
- Ignore what is not yours. Filter on
kind == "app.command"and on the command name; the in topic is yours, but be strict anyway. - Session state lives in the app, not in Hooka. Hooka keeps history and delivery; it does not remember what the app was doing.
- Buttons are commands. Put an inline keyboard on a reply with
callback_dataof the formapp:<namespace> <verb> <args>(Telegram caps it at 64 bytes, so prefer ids over long text). A press becomes the sameapp.commandevent as the typed command, from the presser, withinvocation: "button"andtelegram_message_idset to the message that carried the button. The verb must be one of the app's registered commands. - What the user sees while you work. Hooka shows a typing indicator when something is listening on the in topic (a route or a live stream) and posts "
<app>is not running right now" only when nothing is, so your reply is the only message in the thread.
Commands that carry a file
A user can send a document or a photo with an app command as its caption, for example a PDF captioned /xray hr. Hooka handles it exactly like the typed command, with the caption as text: the same install check, help, disabled, and unknown-command replies. The event also carries the file by reference:
json
{
"kind": "app.command",
"namespace": "xray",
"command": "review",
"args": "hr",
"text": "/xray hr",
"defaulted": true,
"chat_id": "1535298419",
"telegram_message_id": 4712,
"attachment": {
"kind": "document",
"file_id": "BQACAgIAAxkBAAIB...",
"file_unique_id": "AgADmAQAAq...",
"file_name": "resume.pdf",
"mime_type": "application/pdf",
"file_size": 48213
}
}kindisdocumentorphoto. A photo is the largest size Telegram offers, withmime_typeimage/jpegandfile_namephoto.jpg. Empty fields are omitted; a command without a file has noattachmentkey.- A bare
/xraywith a file runs the namespace's default command on the file (with emptyargs) instead of answering with help./xray helpstill shows help, and a namespace without a default command still answers a bare/xraywith help, so give a file-taking app a default command. - A caption that is not a command, or a file with no caption, is not an app command; nothing reaches the app.
The app has no bot token, so it downloads the file through Hooka with its own scoped token. The path takes the stored message id (the id of the stream or history message), not telegram_message_id:
bash
curl -sS -o resume.pdf "$HOOKA_URL/topics/app-xray-in/messages/$MESSAGE_ID/attachment" \
-H "Authorization: Bearer $HOOKA_API_KEY"With the SDK (0.1.3 or later), inside the Subscribe handler:
go
file, err := c.DownloadAttachment(ctx, m.Topic, m.ID)
if err != nil {
return err
}
// file.Filename ("resume.pdf"), file.ContentType, file.Data ([]byte)- The token needs read access to the topic. Any other message is a 404: one without a file, one on another topic, and a payload someone published that merely looks like an app command. Only commands Hooka queued itself qualify.
- Hooka asks Telegram for the file when you call, streams it back with
Content-Typeset tomime_type(orapplication/octet-stream) andContent-Disposition: attachmentwith a cleaned-up file name, and stores nothing. - Files over 20 MiB (Telegram's bot download limit) cannot be fetched; check
attachment.file_sizeand tell the user. A Telegram failure is a 502. mime_typeandfile_namecome from the sender's client. Check the bytes before trusting them.
Apps over SMS
The same app answers text messages when the server has SMS configured (see SMS). Nothing in the app changes: it still reads app.command events from its in topic and replies on its out topic with the event's chat_id.
Install for a phone number by adding an sms route to the out topic with the master key (the app's own token cannot). As with a Telegram chat, that route is both where replies go and the permission to run the app's commands from that number:
bash
curl -sS -X POST "$HOOKA_URL/topics/app-keyword_alert-out/routes" \
-H "Authorization: Bearer $MASTER_API_KEY" -H "Content-Type: application/json" \
-d '{"provider":"sms","target":"+12125550123"}'Invoke by texting the server's Twilio or Telnyx number (whichever has its webhook pointed at Hooka) either /keyword_alert openai or keyword_alert openai. The form without a slash counts only when the first word is an app installed for the sending number, ignoring case, because phones capitalize it. Any other text is not a command; it goes to SMS_INBOUND_TOPIC if one is set. Hooka queues the same event a Telegram command produces, with these differences:
json
{
"kind": "app.command",
"namespace": "keyword_alert",
"command": "watch",
"args": "openai",
"text": "keyword_alert openai",
"invocation": "namespace",
"defaulted": true,
"topic": "app-keyword_alert-in",
"out_topic": "app-keyword_alert-out",
"chat_id": "sms:+12125550123",
"chat_type": "sms",
"telegram_message_id": 0,
"message_sid": "SM2f8c…",
"from": {"phone": "+12125550123"}
}chat_idissms:plus the sender's E.164 number. Reply with it unchanged.message_thread_idis empty and there is noattachment.message_sidis the provider's message id: a TwilioSM…SID or a Telnyx message UUID.fromcarries onlyphone, and nothing verifies it beyond the carrier's caller ID. Treat an SMS command as coming from that number, not from a person, and keep destructive commands behind a check of your own.- Help, unknown-command, disabled, and "not installed for this number" answers come back as text messages. There is no typing indicator:
/send-chat-actionwith ansms:chat succeeds and does nothing. The "not running right now" text is sent when nothing listens on the in topic. - Each provider message id queues at most one event and gets at most one answer from Hooka, so provider retries do not repeat a command. If queueing fails, the sender gets "Failed to queue that command." as in Telegram.
Replies go through the normal publish path:
bash
curl -sS -X POST "$HOOKA_URL/publish/app-keyword_alert-out" \
-H "Authorization: Bearer $HOOKA_API_KEY" -H "Content-Type: application/json" \
-d '{"chat_id":"sms:+12125550123","text":"Watching <b>openai</b>.","parse_mode":"HTML"}'- The number must have an sms route on the out topic. Otherwise the publish is a
403, deliberately stricter than a Telegramchat_id: SMS costs money, so a publish token cannot text a number that was never installed. - The reply goes through the backend for that number (by default Telnyx for
+1, otherwise the configured Twilio/Telnyx fallback), whichever number the user texted. - The reply is plain text. HTML is flattened, buttons with a
urlbecomelabel: urllines,callback_databuttons (app:commands) are dropped, and a long reply is cut to the backend's cap. On Telnyx that is about 1520 characters of plain GSM-7 text, or about 660 once any character falls outside GSM-7 (an emoji, a curly quote, most non-Latin scripts). On Twilio the cap is 1600 characters. Keep replies short, and offer an SMS user a follow-up command in the text instead of a button. - Files cannot be sent over SMS (
400). - Replies count against the per-number and daily SMS limits. A reply over a limit becomes a dead letter on the out topic.
No code at all: the agent bridge as the app
hooka-agent-bridge already implements this loop for an LLM harness (OpenCode or Pi). Point it at the app's topics and every command becomes a prompt, and every answer a targeted reply:
bash
hooka-agent-bridge \
--hooka-url "$HOOKA_URL" --hooka-api-key "$HOOKA_API_KEY" \
--input-topic app-keyword_alert-in --output-topic app-keyword_alert-out \
--stream-tags app --include-payload --forward-telegram-target \
--backend pi--forward-telegram-target copies chat_id and message_thread_id from the app.command event into the reply. An app's topics can be any names as long as they differ, so a bridge configured before the app registry existed works the same way.
5. Deploy
The app is a private worker: it holds an outbound connection to Hooka (or receives POSTs) and never needs to be Hooka's neighbour. Any host that can run a process works.
Any process host or container platform. Run your app (or the bridge, built from Dockerfile.agent-bridge when the bridge is the app) with:
dotenv
HOOKA_URL=https://hooka.example.com
HOOKA_API_KEY=<the scoped token from step 2>
# bridge-as-app only:
HOOKA_INPUT_TOPIC=app-keyword_alert-in
HOOKA_OUTPUT_TOPIC=app-keyword_alert-out
HOOKA_STREAM_TAGS=app
HOOKA_INCLUDE_PAYLOAD=true
HOOKA_FORWARD_TELEGRAM_TARGET=trueThe worker does not listen on a port, so turn off any HTTP healthcheck your platform applies by default. See AGENT_BRIDGE.md for the full bridge variable list and backend-specific settings.
Webhook apps (step 4, pushed variant) deploy like any HTTP service. The target must be reachable from Hooka over public HTTPS; loopback and private addresses are refused by the route guard unless the operator has enabled private targets.
6. Smoke test after deploy
/keyword_alert openaiin the installed chat: the bot shows a typing indicator (it says "not running" instead if the app is not streaming).- The app's logs show the event.
- The chat receives the app's reply, in the same forum thread if one was used.
- A replayed Telegram update is stored once (the event's idempotency key is
app:<namespace>:<command>:<chat>:<message_id>), so the app receives one event, not two. - Type
/keyword_alert openaiin a chat the app is not installed in: the bot refuses and nothing reaches the app.
If step 3 fails but step 2 passed, check that the reply carried chat_id and that the out topic still has its route (GET /topics/app-keyword_alert-out/routes). If step 2 fails with a webhook route, look at the dead letters for the in topic; with SSE, check the app's reconnect handling.
7. Publish to the store
Once the app works, put its manifest in the curated catalog so anyone running Hooka can install it in one step. Add an entry to internal/server/catalog.json in a pull request:
json
{
"namespace": "keyword_alert",
"name": "Keyword alert",
"description": "Watch keywords and get pinged when they show up",
"homepage": "https://github.com/you/keyword-alert",
"commands": [
{"name": "watch", "description": "Watch a keyword", "usage": "<keyword>", "default": true},
{"name": "list", "description": "List watched keywords"}
],
"delivery": {"webhook": "https://keyword-alert.example/hook"}
}delivery is optional: with a webhook, install adds that route to the in topic; without it the app is expected to stream. The catalog is validated by a test, so a bad manifest fails the PR, not an install. The catalog ships inside the server binary, so an entry is live after the next deploy.
To run a store of your own instead, point HOOKA_APP_CATALOG at a JSON file with the same schema. It replaces the built-in catalog, is validated the same way at startup, and a bad file stops hookad rather than serving a broken store.
Installing from the store does what steps 1 and 2 did by hand, idempotently:
| Surface | Command |
|---|---|
| CLI | hooka store, then hooka app-install jev --chat-id -1001234567890 (prints the app token once) |
| Telegram | /app store, then /app install jev in the chat that should get replies (no token; mint one with the CLI) |
| Dashboard | Store page, Install button, optional chat id, token shown once |
| HTTP | POST /store/jev/install with the master key |
Re-running an install never duplicates anything: existing commands and routes are left alone, and a token is minted only on first install or when asked (--mint-token true).
8. Operate
| Task | How |
|---|---|
| install in another chat | /app install keyword_alert there (from the store), or add another telegram route to the out topic |
| install for a phone number | add an sms route to the out topic (Apps over SMS); delete it to uninstall |
| add a verb | /app command keyword_alert list List watched keywords |
| turn the app off | PATCH /apps/keyword_alert {"enabled":false} drops its commands from the bot menu; the topics and routes stay |
| remove it | /app remove keyword_alert deletes the namespace everywhere |
| see what is installed where | /app list, or GET /apps |
| rotate the token | mint a new one with the same topic_pattern, restart the app, revoke the old one |
Limits: 40 apps, 30 commands per app, namespaces and commands [a-z][a-z0-9_], 2–24 and 1–32 characters. Built-in bot commands and help are reserved. Full reference: INTEGRATION.md section 19 and the OpenAPI spec under /apps.