Bot [Alpha]
Section titled “Bot [Alpha]”hoody-bot lets you control Hoody from a chat app such as Telegram. It runs inside your container and answers chat messages: you sign in from the chat app as yourself, and the commands the hoody CLI publishes become commands you can send from a phone. List your containers, read the last events, check whether a service is answering, one message each. A menu page, a search result, /recent and a command’s answer arrive with their buttons attached, as one message where they fit; long text continues across messages. It is one more way into the same container, next to SSH, the CLI and the web UI, and it adds no account, no permission model and no identity of its own. Whatever the bot does, it does as you, with a token minted for you at login, behind a risk gate that decides what you are asked before each command runs.
The bot is written for chat apps in general, and Telegram is the first one it supports. You create a bot in the chat app, hand its token to the container once, and from then on the chat app carries the messages while the kit does the work. Where a section below describes something Telegram-specific, it names Telegram; everything else holds for any chat app the kit learns to speak. Telegram is currently the only chat app supported by the kit.
Each chat app has its own page for the part that happens inside it: creating the bot, what its credential is, and which of its settings the kit expects. Today that is Telegram.
This page describes the program: how you sign in, what the tokens it mints may do, which commands run today, and how its HTTP surface is admitted. Chat Access covers the identity model in more depth, Run your container from a chat app walks through setting one up, and Hoody Bot documents the kit’s HTTP endpoints.
What chat control covers today
Section titled “What chat control covers today”The bot’s command surface is generated rather than hand-written. Every operation the hoody CLI publishes is also a chat command, so the two surfaces cover the same ground: container and project lifecycle, exec, financial operations, token management and the vault are all present, each behind the risk gate rather than hidden by its class. The generated document that carries them declares 774 commands.
The bot offers 768 of them, and the gate in front of each one decides what you are asked first rather than whether the command exists. Six are not offered, because a chat has no way to supply what they need: four take a file upload as a required argument, and two stream over WebSocket, which the bot does not speak. A command whose risk class the generated document carries no value for is refused as well. Both of those refusals name the property that stopped them, the required field or the transport in the first case and the missing class in the second.
- Browse or search for a command.
/menuwalks the generated menu tree and/searchranks commands by name, alias, the label on their button, title and description. Choosing a search result opens the command’s page rather than running it. A branch with nothing runnable in it is not offered at all. - Run one by name.
/call <command> [key=value …]parses arguments against the command’s own declared fields and hands the request on. An unrecognized key is refused by name rather than dropped. A command whose request body is one value rather than a set of named properties has no field name to type, so it takes@=<json>, which claims the rest of the line. - Answer for missing arguments. When a required argument is absent the bot opens the form and asks for it one question at a time, before any confirmation, with back and cancel available and a picker where the command declares one.
- Open the web UI. A command that belongs to a kit with a page carries a link to that page when the container’s web address is known to the kit.
- Send text. The bot reads text only. In an admitted direct message, a voice note, a photo, a sticker or a bare document gets the reply “I can only read text for now.”, and an album gets that reply once rather than once per photo. Plain words that no form, login or confirmation is waiting for get “I can’t chat in plain words yet — tap Menu, or send /help.” in the same setting. A non-text message in a group, and anything from someone the allowlists refuse, gets no reply at all.
- Follow output as it arrives. A command the generated document marks as streaming, such as logs and events, comes back as one message the bot edits while the output arrives, with a Stop button bound to that run.
Log in from a chat
Section titled “Log in from a chat”/login in a direct message offers three ways in. The first two end in the same pair of minted tokens, the third stores the token you paste, and all three are refused outside a direct message. If you are already logged in, /login offers Switch account or Stay logged in. Switch account asks you to log out first; confirming that ends the session and then shows the three ways in.
- Log in here. The bot asks for your email, then your password, then your two-factor code if the account has one, then a second fresh code for the token mint. The bot reads each answer and then tries to delete it from the conversation.
- Open login page. The bot sends a one-tap link to a login form the kit serves on your own container, with the link preview suppressed so nothing fetches the address before you do.
- Use a token. You paste an API token you already hold, in a direct message.
A credential typed into a chat is used once, for the sign-in and the token mint, and then dropped, and nothing about it is written to disk. It is added to the set of values the bot blanks out, so it cannot reappear in a log line, in an error, or in anything the bot sends. An unfinished form expires two minutes after it opened, and the next maintenance sweep frees what it was holding. The message carrying the credential is deleted as soon as it has been read, as far as the bot can manage it: Telegram has already stored its own copy, and the first prompt of the form says so. When a deletion fails, the bot tells you the message is still there and asks you to delete it yourself.
A /login typed in a group answers with a button that opens a direct message and never accepts a credential. A token pasted into a group is refused, and the bot tries to delete that message. If it cannot, it says so: “I could not delete that message, so it is still there: please delete it yourself and rotate that credential.” A credential is refused the same way in a topic, in a channel, from a guest context and over a business connection.
On the pasted-token path the kit checks that the token is enabled, that it carries an expiry, and that it can list its own realms, containers and account. A token with no expiry is refused, because a permanent credential is never stored. Nothing else is checked: the lifetime, the realm shape and the permission tree are whatever you minted. The token is then stored encrypted and used as it is, and the kit holds no parent for it, so /logout forgets it and prints the hoody auth tokens delete <id> command for you to run.
The login page
Section titled “The login page”The link the bot sends carries a 32-byte nonce. The kit stores only its hash, binds it to the registration and the chat identity that asked for it, and expires it two minutes after issue. Opening the page renders the form and does not spend the nonce. A successful submission spends it, and a failed one hands it back with the attempt count raised, until the third failure burns it. A second submission arriving while one is in flight is refused rather than queued.
Once the form succeeds, the browser holds a session cookie for that container’s bot pages: HttpOnly, Secure, SameSite=Strict, scoped to the kit’s own address, and valid for 30 minutes. The login form is the only thing that issues it.
Tokens and revocation
Section titled “Tokens and revocation”The kit mints two tokens for you server side and discards the credentials you gave it. A parent token exists only to mint, is stored encrypted and is never used for ordinary calls. Under it sits the working token every action actually runs as.
Neither token carries a permission list, which is what gives both of them your account’s whole tree: container and project creation and deletion, the container actions, the feature toggles, the billing and wallet permissions, server rental, token creation and vault access, along with read access to projects, realms, events and the account. The kit leaves the list out on purpose, because a hand-written one would silently drop every permission Hoody adds after the kit was written. The working token is minted without deny_reauthorization, which is what keeps token creation and vault access in it.
/logout ends this chat’s access at once and asks the API to delete the working token through its parent, so the next message in that chat asks you to log in again, and every browser session the login page issued for that chat identity goes with it. If that deletion fails, the bot says so and lists the token for you to delete yourself. It then forgets the parent and shows you the one command that removes it:
# The bot prints the id; substitute it herehoody auth tokens delete "$TOKEN_ID"That command is yours to run because nothing else can run it. The parent is a root token with no ancestor, so no API token is allowed to delete it. Only the account holder’s own session can. The parent is created with an explicit 30-day expiry, and the working token’s expiry is forced to the earlier of the parent’s expiry and the platform’s ceiling for child tokens.
Deleting the parent is a complete revocation rather than half of one. Once the parent is deleted, the working token and any token it minted stop working on their next call.
Realms and targets
Section titled “Realms and targets”Every request the bot makes for you runs in one realm, the same way the hoody CLI picks one with --realm and config set realm. An account that holds one realm is scoped to it silently. An account that holds several starts with every realm in scope: lists that span realms are combined, while other reads use the resource or container they address, run once for the account, or ask which realm to use. When some realms do not answer a read, the bot shows what it has, says how many did not answer, and offers Retry and Narrow to one realm. /realm <alias or id> narrows the scope to one realm, /realm on its own lists your realms and marks the narrowed one if you have narrowed, and /realm all restores the whole set. /whoami shows both the realms your session covers and the realm commands run in. The tokens minted at login cover every realm your account held at that moment, so narrowing needs no new login; a realm added to your account later is outside them until you log in again, and the refusal says so.
A realm you have named shows as the name followed by part of its id, so two realms are never labelled the same; when two names would still collide, both show the whole id. A realm you have not named shows as its id. You can type the name, the realm id, or the name of a container in that realm. If more than one realm answers, the bot asks instead of guessing. The words all, none, every, any and global are reserved and never resolve as a name.
If no container is selected, a command that runs inside a container offers a button per container, up to twelve, and runs itself on the one you pick; past twelve it says how many it is showing of how many and points you to /use <container name> for the rest. /use <container name or id> selects one directly, and /whoami shows the target in use. /use <realm> does the same as /realm, and a container that belongs to another realm is refused with the hint to switch first. /use and /realm change your selection for this bot, in every conversation where you use the same chat identity, rather than for one chat at a time. An account with no containers is told: “You have no containers yet — create one first, then run this again.” If the bot could not read the list, it says so and points you to /use <container name>.
The current scope and the target a command names decide the realm it runs in. A kit command runs in the selected container. With every realm in scope, a command that names a resource by id runs in that resource’s own realm, whichever container is selected; once /realm has narrowed the scope, commands stay in the realm you named. A command that changes something is never sent to a guessed realm: if the bot cannot tell which realm a named resource is in, it asks. The target is not the authority. The permission tree is realm-wide and is set from the realms your account holds at login, so /use and /realm move where a command points without widening or narrowing what you may do.
Commands
Section titled “Commands”The generated document declares 20 built-in names. Sixteen of them are implemented:
| Command | What it does |
|---|---|
/start | Says what the bot is. It also ends a login you had started, and says so, so your next message is not read as a credential |
/login | Offers the three ways to sign in |
/logout | Deletes the working token and names the one command that removes its parent |
/use <container|realm> | Sets the target container for your chat identity, or switches realm |
/realm [alias|id|container|all] | Lists your realms, marking the narrowed one if any, narrows to one, or all restores the whole set |
/whoami | How you signed in, the target container, the realms this session covers, the realm commands run in, the token ids, and when the session expires. No account id |
/menu | Walks the generated menu tree |
/search <text> | Ranks commands by name, alias, button label, title and description |
/recent | The last commands you ran, by label and target, with a button per command that opens it |
/call <command> [k=v …] | Runs a command by name |
/help [command] | Help for one command, generated from the same document |
/cancel | Cancels an open form, an unfinished login, or a pending confirmation |
/plain on|off | Renders choices as numbered options instead of buttons |
/subscribe | Delivers events from a source into this conversation. A write, so it asks for a tap |
/unsubscribe | Stops one subscription, after a tap. The row is kept, with the reason |
/subscriptions | Lists your subscriptions, and why any of them stopped |
/recent shows each command by its label and the target it ran on, and never its arguments. The store keeps a digest of the normalised arguments rather than the values, so running something again collects the arguments afresh through the form. Its buttons open the command rather than run it, one button per distinct command. A row whose command no longer exists still appears, as text with no button.
Everything else is a generated command under its own name. There is one shortcut in the whole document, /ps, which lists containers. The names /new, /sessions, /confirm and /answer are declared and not implemented yet.
Event subscriptions
Section titled “Event subscriptions”/subscribe binds a source to the conversation you ask from, and events from it arrive there until you stop the subscription. Both it and /unsubscribe are classified as writes, so each asks you to tap a confirmation before it runs. A stopped subscription keeps its row, so /subscriptions can tell you why it stopped.
Five sources are declared and one runs today: the Hoody API event list, polled on the bot’s maintenance cycle, about once a minute. A source whose operation the generated document excludes is not started, and the bot quotes back the exclusion row that says so. Queued events are kept for a day by default, a setting of the kit’s deployment rather than of the chat. A build with no subscription engine wired answers that it cannot manage subscriptions yet.
Forms and pagination
Section titled “Forms and pagination”A form asks for the required fields one at a time, because a chat renders a form as a conversation, and it asks for optional fields that hold a secret as well. Other optional values are supplied through /call name=value. Back, cancel and an idle timeout of 15 minutes are available on every command form. The chat login form is the exception: it has a two-minute deadline and no back step.
A secret is answered in the browser rather than in the chat, because a secret typed into a chat has reached the channel’s storage before the bot sees it. That step sends you a link to a form the kit serves on your own container, and the chat form waits there until you submit it, then carries on to the next field. If the kit cannot produce that link, because no browser form is configured or because the link could not be minted, the form stops at that step and says so.
A value the API accepts written more than one way is given once, in whatever spelling you have: a window id in decimal or in hex, an expiry as a number of seconds or as a date. The bot sends it as JSON when what you typed parses as JSON, and as text when it does not.
A field that takes a file upload cannot be answered from a chat yet, which is why the four commands that require one are not offered. Files still move in the other direction: an answer that comes back as bytes arrives as a document message carrying the answer’s buttons, though numbered options in plain mode can follow in a message of their own, held to the same 20 MB ceiling as anything else the bot downloads. That ceiling is checked against the declared size before the fetch starts, and a file whose size the chat app does not declare is not fetched at all. The 50 MB the chat app accepts is its own cap on what the bot sends, not a bound on how large an answer may be.
Menus and option lists show up to ten entries, bounded by how many buttons the channel accepts, so a destination with a small keyboard budget shows fewer entries and a working pager rather than a message the channel rejects. How much of a result you get is set by that command’s own output limit or by the API’s pagination instead. Every button carries an opaque key that resolves to a stored intent; the menu id, the page and the cursor live in the store, never in the button.
The risk gate
Section titled “The risk gate”Every command classifies before it runs, as read, write, destructive, action or danger, and each carries its own confirmation requirement and execution class. The classification is generated with the command and cannot be lowered by a later override.
All five classes run, and the class decides what you are asked first. A read runs straight away, a write or an action asks you to tap a confirmation, and a destructive or dangerous command asks you to type a phrase back before anything happens.
That phrase belongs to one prompt rather than to the command: it is the command’s slash name with its punctuation removed and in capitals, a dash, and an eight-character hexadecimal tag the prompt prints. The prompt is one message: the phrase in its text, a disabled hint that repeats it, and a Cancel button. No tap can confirm it. Type exactly the phrase the prompt shows. A generated command’s slash name carries underscores, the phrase drops them, and a phrase typed with them matches nothing. Cancel, or /cancel, ends the prompt, and a phrase typed after that runs nothing. A phrase read off an older prompt does not confirm a newer one. If what you type matches more than one prompt you have been shown, nothing is claimed, and the bot asks you to reply to the prompt you meant with the phrase that prompt shows.
No class is refused. The only command the gate refuses outright is one nobody classified, and the refusal says so rather than promising a later release.
A command’s own declared requirement and its execution class can both raise that floor. A command that declares a confirmation gets it whatever its class, and an execution class of shell, script, sql or outbound forces a typed one. Of the 774 commands, 335 are reads, and six of those ask for a typed phrase because their execution class reaches outside Hoody.
A confirmation names the target it will run on and the execution class it carries, and the bot binds that sentence, so a prompt whose target has moved no longer confirms. The sentence also says that the bot has not estimated a cost.
Buttons never carry the decision. A callback carries 32 random bytes, encoded as 43 characters and well inside the platform’s 64-byte limit on callback data, and those bytes map to a stored intent row, so what a tap means is decided by the kit and not by the payload. Confirmations and approvals are ordinary persistent messages, bound to the person who asked and to the chat they asked in, never the platform’s disappearing message types, so no decision depends on a message that may already be gone.
Duplicate answers after a restart
Section titled “Duplicate answers after a restart”Telegram delivers an update at least once, and its send API takes no idempotency key, so there is no way to make a reply and the record of that reply one atomic act. The kit does not claim to have closed that window. It makes the window visible instead.
Each update is claimed before it is handled and settled after, keyed by the registration and the update id, and the bot records what became of the answer. A read is safe to repeat, so a redelivery reruns it when the answer never left, does nothing at all when the answer arrived, and sends exactly one clearly marked copy when the record says a send was in flight. That duplicate is recognizable to you and findable in the audit log. A claim nobody resolves is redelivered once, then recorded as failed, so a single poison update costs two deliveries rather than every poll for the life of the process.
A command whose class is write, action, destructive or danger is never run a second time, whether the chat app redelivers your tap or the bot restarts underneath it. The record keeps whether the request was still on the wire, came back having changed nothing, or came back having taken effect, and you get the notice that matches: that the operation may already have run and was not run again, that it did not run, or that it ran and its result may not have reached you. The six confirmed reads follow the read rule above.
Form prompts are recorded as rows for the same reason. After a restart the bot still recognises a reply to one of its own questions and tells you the form was interrupted, or what became of a command that was already running, rather than treating the reply as a new message.
What survives a restart is what the kit wrote down. A started registration resumes polling from the last acknowledged update, and subscriptions keep their rows and their queued events. A form parked on a browser secret step does not carry on. Submitting that page after a restart asks you to sign in again, and nothing picks the chat form up from its row, so you run the command again. The row stays as a record of where the form stopped rather than as a way back into it. A chat login form and its two-minute deadline do not survive, and a prompt you had not answered is answered with the interrupted sentence rather than the operation.
Register a bot
Section titled “Register a bot”Registration is a CLI operation against the container that will run the bot. Create the bot in the chat app first, then hand the kit its token. --channel names the chat app:
# --token-stdin keeps the token out of the process list and shell history.printf '%s' "$BOT_TOKEN" | hoody bot create --container "$CONTAINER_ID" \ --channel telegram --token-stdin --label "ops bot"
# Expected output# Bot registered.
# A new registration is stopped. Read its id, then start polling.hoody bot list --container "$CONTAINER_ID"hoody bot start "$REGISTRATION" --container "$CONTAINER_ID"bot is a kit command group, so it needs a target container: pass --container (or -c), or set HOODY_CONTAINER once in your shell.
--channel and --token are both required; --label is optional. Put the value in a shell variable rather than typing it literally, so it stays out of your shell history. It is still visible in your own process arguments while the command runs.
The CLI reads the token on your machine and sends it once, in the body of a POST to /api/v1/bot/registrations over HTTPS, authorized by your own Hoody bearer token.
The kit checks the token against the chat app, encrypts it with a key generated on the container at first boot, and stores it. It is never echoed back to you and never reaches the kit’s logs, where the logger replaces the token’s value wherever it appears. That value-based redaction exists because the chat app’s own API carries the token in its request paths, so the claim is that the kit does not leak it, not that it never travels in a URL.
The kit holds no credential of its own and never reads your vault. If you keep the bot token in a vault, resolve it yourself and pass the value.
A new registration is stopped until you start it: hoody bot create stores the token and creates the row, and hoody bot start <registration> is what begins polling. Run hoody bot list for the id in between.
Updates arrive by long polling. There is no webhook route, so update delivery never depends on an inbound request from the chat app and there is no callback URL to keep secret. That is a statement about update delivery only: the kit still serves its own inbound routes, and they are admitted by the classes below. The last acknowledged update is persisted before it is confirmed, so a restart resumes where it left off.
Operator commands
Section titled “Operator commands”The hoody bot group has 17 subcommands. Registration lifecycle is list, get, create, delete, start and stop; health and manifest get report what the kit is running. The rest administer a registration:
| Command | What it does |
|---|---|
hoody bot commands sync <registration> | Publishes the command lists and menu button to the channel, then reads them back |
hoody bot keys rotate | Re-encrypts every sealed column under a new kit key |
hoody bot logs list <registration> | Reads the redacted audit log, newest first, with --actor, --since, --limit and --before-id |
hoody bot logs purge <registration> | Deletes rows older than a cutoff, with --older-than and --all |
hoody bot policy get <registration> | Reads the registration’s mode and allowlists |
hoody bot policy update <registration> | Sets the mode and the user and chat allowlists |
hoody bot profile update <registration> | Sets the bot name, descriptions and default administrator rights, and publishes them |
hoody bot sessions revoke <registration> <user> | Logs one chat user out and revokes their token lineage |
hoody bot tokens revoke <registration> --all | Revokes every chat user’s lineage for the registration |
The registration id is the first positional argument on each of these except keys rotate, which is kit-wide and takes none, and sessions revoke takes the channel user id as a second one. keys rotate refuses to run while a poller is active unless you pass --force. logs purge refuses a cutoff inside the 90-day retention window rather than clamping it, unless you pass --all.
The chat allowlist is worth reading twice. Leaving it unset means direct messages only, because a read command typed in a group posts one person’s account data into the room. A list that names chats is exhaustive and includes direct messages, so a list naming only a group also stops every direct message. An empty list admits nothing.
Health and the manifest
Section titled “Health and the manifest”The health endpoint is unauthenticated and returns exactly nine fields: status, version, uptime_s, mode, spec_hash, overlay_hash, manifest_hash, open_by_default, and polling. The polling object carries the number of registrations, how many are active, and the last error as an enumerated code (network, auth, conflict, rate_limited or unknown) with its timestamp. Raw error text never appears there.
hoody bot healthcurl https://PROJECT_ID-CONTAINER_ID-bot-1.SERVER.containers.hoody.com/api/v1/bot/healthA bare /health returns 404; the path above is the only one.
Whether hoody-bot is running in a container at all is the health endpoint answering above.
The command document is generated from the SDK’s own snapshot of the specs it was built against, rather than from the API descriptions sitting on disk, so the bot’s commands match the SDK it ships with. The three hashes identify what the running kit believes it can do. hoody bot manifest get --verify recomputes the digest from the served bytes and compares it with the value baked into the CLI and with the manifest_hash in health, exiting non-zero when they disagree. That is how you tell a container is running a build whose command surface differs from the one you documented.
Runtime settings
Section titled “Runtime settings”The kit’s own settings are part of the container’s program definition rather than of the chat. Each one is a flag or an environment variable:
| Flag | Environment variable | Default | Meaning |
|---|---|---|---|
--host | CONTAINER_BOT_HOST | 0.0.0.0 | Bind address, an IPv4 literal or a hostname |
--port | CONTAINER_BOT_PORT | 40 | Port number |
--log-level | HOODY_LOG_LEVEL | info | error, warn, info, debug or trace |
--enabled | CONTAINER_BOT_ENABLED | true | Whether this kit starts |
--state-dir | HOODY_BOT_STATE_DIR | /hoody/storage/hoody-bot | Runtime state directory |
--api-url | HOODY_API_URL | unset | Hoody API base URL |
--container-id | HOODY_CONTAINER_ID | unset | This container’s id |
--public-url | HOODY_BOT_PUBLIC_URL | unset | The kit’s own public URL, which the self-probe calls |
--poll-timeout | HOODY_BOT_POLL_TIMEOUT_S | 30 | Long-poll timeout, in seconds |
--poll-backoff-max | HOODY_BOT_POLL_BACKOFF_MAX_S | 60 | Longest poll backoff, in seconds |
--user-rate-per-min | HOODY_BOT_USER_RATE_PER_MIN | 30 | Per-user command budget per minute, 2 or more |
--subscription-queue-ttl | HOODY_BOT_SUBSCRIPTION_QUEUE_TTL_S | 86400 | How long a queued subscription event may wait, in seconds |
--telegram-api-base | TELEGRAM_API_BASE | unset | Bot API base URL, for tests only |
--telegram-api-host | TELEGRAM_API_HOST | unset | The one Bot API host this kit may reach; a base that names another host is refused at start |
--containers-domain | HOODY_BOT_CONTAINERS_DOMAIN | derived from the API URL | Domain sibling containers are addressed under |
--disclosure-buffered | HOODY_BOT_DISCLOSURE_BUFFERED | deliver | What a buffered result does under a response-disclosure policy: deliver or refuse |
A flag wins over its environment variable, and the variable wins over the default. Both --flag value and --flag=value are accepted. An empty environment variable counts as unset, except the state directory, which refuses an empty value. A value that fails validation stops the kit from starting with a usage error naming the setting, with one exception: an invalid HOODY_LOG_LEVEL is logged as a warning and the default level is used.
The state directory must be an absolute path and must not be a directory named data. With --enabled false or CONTAINER_BOT_ENABLED=false, the kit logs that it is disabled, exits successfully, and opens no state directory, key or socket.
Once the listener is up, the kit starts its maintenance sweep, runs the self-probe when a public URL is configured, restores every registration that was started, and republishes each one’s commands and profile to the chat app. A registration that should be polling but cannot start is reported as inactive in health and retried with backoff. A poller that stops on a Telegram auth error or a polling conflict is not restarted; Telegram covers both.
Admission and authorization
Section titled “Admission and authorization”The kit authorizes every request itself and treats the proxy as a second layer rather than the first. Hoody Kit programs are reached through their URLs only: a request that does not come through the kit’s URL is refused by the Source IP Guard with 403 and the error code forbidden.
Beyond that, routes fall into three classes. Health takes nothing further. The browser pages take the login nonce, then the cookie that the login form issued. Everything else takes your own Hoody token, which the kit validates against the API and accepts only when you own the container. The bot works only on containers you own; a container shared with you is refused, even though you can read it.
A container’s permission matrix defaults to allow, so the kit never relies on it to authorize a request, though a request still has to pass it to arrive. Outbound requests are limited to a host allowlist held in one file. That allowlist guards against bugs and server-side request forgery. It is not containment around a kit that has already been compromised, and it is not written as though it were; the container firewall sits behind it as address-range defence in depth for the ranges stable enough to pin.
A registration carries a single or multi label, reported in health. The label is stored and reported and nothing enforces a count; who may sign in is decided by the user and chat allowlists, and the mechanism underneath is identical in both.
Not part of the chat surface today
Section titled “Not part of the chat surface today”The kit serves 768 of the 774 commands the generated document declares, and the six it does not are named above. These parts of the chat surface are not available:
- Streaming agent turns, with the turn’s thinking visible in the chat, a Stop button bound to it, approvals as buttons, and the
/newand/sessionsbuilt-ins. - Webviews and the Mini App shell, which would open the 16 kit pages the document declares inside the chat app. A command links out to the web UI instead.
- Voice notes, transcribed on the way in and synthesized on the way out.
- Guest queries from
@hoodymentions in any chat, and the rest of the Telegram platform surface. - Other chat apps. Telegram is the one the kit speaks.
Use cases
Section titled “Use cases”Check on a container from a phone
Section titled “Check on a container from a phone”/ps lists your containers and the generated read commands cover the rest: realms, events, account records and per-kit status, each one a message rather than a terminal session.
Find the command you half-remember
Section titled “Find the command you half-remember”/search snapshot ranks every command whose name, alias, button label, title or description matches. /help <command> then prints its arguments from the same generated document as one answer, continued across messages when it is long. More options on a command’s menu page shows that reference shortened to fit one message: when the whole thing will not fit, it leaves out the details of the optional inputs, or their names as well, and says how many it left out.
Give one operator a bounded view
Section titled “Give one operator a bounded view”hoody bot policy update <registration> takes a user allowlist and a chat allowlist, both enforced before a chat user is looked up and both recorded in the audit log when they refuse.
Best practices
Section titled “Best practices”Keep the bot in direct messages until you mean otherwise
Section titled “Keep the bot in direct messages until you mean otherwise”An unset chat allowlist means direct messages only, which is the setting that keeps one person’s account data out of a shared room. Add chat ids deliberately, and remember that the list is exhaustive once it is non-empty.
Treat /logout as half of revocation
Section titled “Treat /logout as half of revocation”/logout asks the API to delete the working token and tells you if it could not, but the parent that minted it survives until you run hoody auth tokens delete <id> or its 30 days run out. Run every command the bot prints, from your own session.
Read the audit log before purging it
Section titled “Read the audit log before purging it”hoody bot logs list <registration> pages the redacted log newest first. logs purge refuses a cutoff inside the 90-day retention window rather than trimming it quietly, so reach for --all only when you have decided to waive the window.
Close the proxy layer deliberately
Section titled “Close the proxy layer deliberately”If you want the proxy to refuse anonymous requests, set the container’s matrix default to deny knowingly, then restart the kit so health stops reporting the open flag. It applies to every kit on that container. Chat keeps working, because updates are polled outbound and never arrive through the proxy, but the login page does arrive that way: give it access rules, or the proxy refuses it before the kit’s own admission ever runs.
Useful questions
Section titled “Useful questions”Does the bot hold a token of its own?
Section titled “Does the bot hold a token of its own?”No. Every action runs as the user who sent the message, with tokens minted for that person at login. There is no bot-owned or container-owned credential, which is why nothing works until you log in and why nothing survives once you log out and delete the parent.
Is typing my password into a chat safe?
Section titled “Is typing my password into a chat safe?”It is one of three offered paths, and the kit’s side of it is bounded: the credential is held in memory for at most two minutes, redacted in logs and in anything the bot renders, and the bot tries to delete the message once it has read it, which can fail. The part the kit cannot bound is the chat app, which has already stored that message on its own servers. The login page exists for people who would rather the credential never enter the conversation.
What happens to the bot when I change my password?
Section titled “What happens to the bot when I change my password?”Nothing. The tokens are API tokens rather than sessions, so a password change or a “log out everywhere” leaves them working until they expire or are revoked. Use /logout, or hoody bot tokens revoke <registration> --all for a whole registration.
Why did a command refuse before it ran?
Section titled “Why did a command refuse before it ran?”Two different things refuse a command before it runs, and the refusal says which. Six commands are not offered at all, because a chat cannot collect a required file upload and the bot does not speak WebSocket. Separately, the gate refuses a command whose risk class the generated document could not supply, and no release makes such a command runnable: it has to be classified at generation. Either refusal names the property that stopped it, so you can tell both cases from a command your own token cannot reach.
Which realm do my commands run in?
Section titled “Which realm do my commands run in?”All the realms your account holds, unless you narrowed the scope with /realm. /whoami shows both the realms your session covers and the one commands run in. Kit commands run in the selected container, so a container in another realm is refused until you switch. A realm added to your account after login needs /logout and /login before the bot can reach it.
Where is the Telegram bot token kept?
Section titled “Where is the Telegram bot token kept?”Encrypted on the container with a key generated there at first boot. The kit never reads your vault. If you keep the bot token in one, resolve it on your own machine and pass the value to --token.
Does the kit need an inbound webhook?
Section titled “Does the kit need an inbound webhook?”No. Updates are fetched by long polling, so there is no route accepting unauthenticated inbound posts from the chat app.
Troubleshooting
Section titled “Troubleshooting”The bot answers “please /login”
Section titled “The bot answers “please /login””The working token for that chat identity is gone, either because you ran /logout, because an operator ran hoody bot sessions revoke, or because it expired. Its lifetime is capped both by the parent’s 30 days and by the platform’s ceiling on child tokens, so it can end sooner than 30 days. Send /login again.
The login link does not open the form
Section titled “The login link does not open the form”The nonce expires two minutes after the bot sends it, and three failed sign-ins burn it. A successful sign-in spends it, so a link that already worked will not open a second time. Send /login again for a fresh one rather than reusing the old message.
The bot refused a pasted token
Section titled “The bot refused a pasted token”A token with no expiry is refused, because the kit never stores a permanent credential. A token pasted anywhere but a direct message is refused as well, and the bot tries to delete that message. If it cannot, it says so and asks you to delete it yourself and rotate the credential.
A kit service is not answering
Section titled “A kit service is not answering”When a command that runs inside a container cannot reach the service it needs, the bot names the service and the container: “The X service on Y isn’t answering. Check the container is running, then try again.” A service that refused the request, did not accept the bot’s credential, or has no such endpoint gets its own sentence, naming the service and the container the same way.
The same answer arrived twice
Section titled “The same answer arrived twice”A restart in the middle of a send produces one duplicate of a read, and the duplicate says so. The audit log records it as an uncertain redelivery. A read whose answer never left is run again; nothing with a side effect is. For a read the record distinguishes an answer that never left from one that may already be in the chat, and a confirmed write, action, destructive or dangerous command is not run again at all, so what arrives after a restart is a notice about it rather than a second run.
A management command returns 401
Section titled “A management command returns 401”The bearer has to be your own Hoody token and you have to own the container the kit runs in. Read access is not enough: a container that is only shared with you is refused on purpose.
Health reports open_by_default as true
Section titled “Health reports open_by_default as true”The container’s permission matrix is in its default allow state. Chat traffic never enters that way and management routes still require your bearer, so this is a statement about the proxy layer rather than a fault. Set the container’s matrix default to deny and restart the kit to clear the flag.
keys rotate refuses to run
Section titled “keys rotate refuses to run”A poller is active for one of the registrations. Stop it, or pass --force if you accept rotating underneath a running poller.