Skip to content
Hoody.com

hoody-bot lets you control Hoody from a chat app such as Telegram, without giving the chat app, or the bot, an identity of its own. It is one more way into the same container, next to SSH, the hoody CLI and the web UI. You sign in as yourself and act as yourself, with credentials you obtained by signing in and can revoke yourself.

This page covers the identity model, the three ways to sign in, what the bot’s tokens can reach, what the kit refuses outright, and the limits the design does not cover. The Bot page describes what the program does, Run your container from a chat app walks through setting one up, and Hoody Bot documents the kit’s HTTP endpoints. Telegram is the first chat app the bot supports; the model below does not depend on which chat app carries the messages, and the two places where Telegram’s own behaviour matters name it.


The kit adds no principal, no permission model of its own, and no shared identity. There is no bot account with credentials of its own, and nothing in the kit is authorized to act until a specific person has signed in. The kit holds no credential of its own at boot and never fetches one from your vault; vault commands you run from the chat use your own token.

Every action runs with the credentials of the person who asked for it, obtained by that person logging in. Two people in the same chat run the same command against different accounts, with different permissions, and each of them can revoke their own access without affecting the other.

Group membership grants nothing. A group is refused until it is allowlisted, and leaving the chat allowlist unset means direct messages only, because a read command typed in a group posts one person’s account data into the room.


/login in a direct message offers three paths. They differ in where the credential travels. The first two end in the same pair of minted tokens, and the third stores the token you paste.

PathWhat you give the botWhere the credential goes
Log in hereEmail, password, and your two-factor codes, typed as chat repliesInto the conversation, then into the kit’s memory for the life of the form
Open login pageThe same values, typed into a form the kit serves on your containerInto the form, never into the conversation
Use a tokenAn API token you already holdInto the conversation, in one direct message

All three are refused outside a direct message. A /login typed in a group answers with a button that opens a direct message and accepts nothing there. A token pasted into a group is refused, 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.

What happens to a credential typed in chat

Section titled “What happens to a credential typed in chat”

It is used once, for the sign-in and the token mint, and then dropped, and nothing about it is written to disk. It joins 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.

That deletion is best effort and the kit says so rather than implying more. When it fails in the form, the bot tells you the message is still there and asks you to delete it yourself. A credential refused outside a direct message carries the stronger warning: “I could not delete that message, so it is still there: please delete it yourself and rotate that credential.” Telegram stored the message on its own servers the moment you sent it, and the first prompt of the form tells you that before you answer. The login page exists for people who would rather the credential never enter the conversation at all.

If the account has two-factor authentication, you are asked for a code twice, on the chat path and on the page alike. One completes the sign-in. The second is a fresh code for the token mint, which runs its own one-time-password gate. Signing in through an OAuth provider is not offered.

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 link renders the form and does not consume the nonce. A successful submission consumes it. A submission arriving while another is in flight is rejected, and a failed authentication returns the nonce to unused, up to the third failure, which burns it. The message is sent with link previews disabled, so the chat platform’s own preview fetch does not touch it.

A successful submission sets a session cookie for the kit’s own pages. It is HttpOnly, Secure, SameSite=Strict, scoped to the bot’s API path, and valid for 30 minutes. The login form is the only thing that issues it.

The nonce is the single factor at first use, and the cookie is the session afterwards. There is no separate browser authentication behind either of them, and the design does not claim one.

The kit checks that a pasted token is enabled, that it carries an expiry, and that it can list its own realms, containers and account, then stores it encrypted and uses it as it is. 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, and the realms /use offers are that token’s own, which can be narrower than your account’s.

The kit holds no parent for such a token, so it cannot revoke it. /logout forgets it and prints hoody auth tokens delete <id> for you to run.


Signing in mints a pair of API tokens for you. One does the day’s work. The other exists only to mint and replace the first, and only you can delete it.

Working tokenMinting token
What it doesRuns every action you ask forMints and replaces the working token
Used for ordinary callsYesNo, stored encrypted and never used for them
Can create tokensYes, clamped to its own permissionsYes, within the same permission set
How it is revoked/logout forgets it at once. Deleting it at the API is attempted and may failhoody auth tokens delete <id>, by you only
ExpiryThe earlier of the minting token’s expiry and the platform’s ceiling for child tokens30 days, set explicitly

/logout ends this chat’s access at once and asks the API to delete the working token through its parent; if that deletion fails the bot says so and lists the token, and you remove it yourself with hoody auth tokens delete <id>. It forgets the parent either way, and tells you the parent’s id together with the exact command that removes it. It also deletes every browser session held for that chat identity, so the kit’s pages stop opening until you log in again, and revoking a working token any other way does the same. hoody bot tokens revoke <registration> --all covers every working token of that registration, and lists every minting token id alongside the command that revokes it.

/logout hands you that command instead of running it, and the reason is structural rather than an oversight. The minting token is a root token: no other API token is its ancestor, so no API token can delete it, including the ones the kit holds and including an operator’s own bearer. Only the account holder’s session can, from the web UI or from the CLI. Running that command is a complete revocation rather than half of one: a delegated token whose ancestor has been deleted fails closed the next time it is used, and its live event streams are dropped at once, so the working token goes with the minting token.

Terminal window
# Delete the minting token. Only you can do this; the kit cannot.
hoody auth tokens delete $TOKEN_ID

Neither token is permanent. The minting token is created with an explicit 30-day expiry, and the working token’s expiry is forced to the earlier of its parent’s expiry and the platform’s configured ceiling for child tokens, so it can never outlive the parent. The pair is minted again at renewal.

All of these are API tokens rather than browser sessions, so changing your password and using “log out everywhere” do not revoke them. The revocation paths are /logout, hoody bot tokens revoke <registration> --all, hoody auth tokens delete, and expiry.


The minting token is created with the full-access permission set for user API tokens, with token creation turned on so it can mint and later delete the working token. The working token is minted without a list of its own and inherits that tree. The set is fixed, not a copy of your own account’s permissions, and it grants only what a user token can hold on your own account. The kit reads the tree from the platform rather than writing one out, on purpose: a hand-written list would silently drop every permission Hoody adds after the kit was written. What bounds them is the realm set frozen at login, the 30-day life of the pair, and the risk gate in front of each command.

The table below is a different thing, and worth reading as such: it is the permission tree the chat surface actually needs, which is what a command is checked against before it dispatches. It holds 38 permission keys and two flags, eight of the keys only for commands that ask for them.

AreaWhat the tree includes
ContainersRead, update, create, delete, the start, stop, restart and logs actions, and the AI, kit, KVM, networking and snapshot features
ProjectsRead, update, create, delete, and the member invite, removal and role changes
FinancialBilling reads, invoice downloads, payment-method management, wallet reads and transfers, marketplace viewing, server rental and rental extension
ResourcesAccount and realm reads, events, SSH keys, firewalls, proxy aliases, storage shares, token creation, the vault, and the token’s own public profile
Flagsevent_access and vault_access

The working token is minted without deny_reauthorization, which is the flag that would strip token creation and vault access from it. It is bound to the realms your account held at login rather than to the whole platform, and it is realm-wide rather than container-scoped: /use changes where a command points, never what you may do. Every request the bot makes for you runs in one realm, decided by the scope in force and the target the command names. 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. /realm narrows the scope to one realm and /realm all restores it, the way --realm and config set realm pick a realm for the hoody CLI, and a realm added later is outside the tokens until you log in again.

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. 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. Every request runs under your own credential. Values you mark as secrets are sent to the operation you asked for and blanked out of anything the bot sends back.

Container creation is in the tree, so creating a container is a generated write command you can run by name from the chat. The bot does not open it for you: an account with no containers is told “You have no containers yet — create one first, then run this again.” When containers exist and none 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 containers it is showing out of the total, and the rest stay reachable through /use <container name>. If the bot cannot list your containers at all, it says so and asks you to try again or to name one with /use <container name>. /use selects the container for commands that run inside it. With every realm in scope, an API command that names another resource by id runs in that resource’s own realm rather than the selected container’s; once /realm has narrowed the scope, requests stay in the realm you named. A command that changes something is never sent to a guessed realm.

An operation the tree does not cover fails the kit’s own gate rather than surprising you at the API. The tree is not the only thing that can refuse before a call is made; the next section lists the rest.


Like every Hoody Kit program, the kit is reached through its URL 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.

The container permission matrix defaults to allow, so the kit never treats “the proxy let it through” as authorization. Every management route carries a Hoody bearer, and the kit validates that bearer with the bearer itself: it reads the container, identifies the caller and reads the container’s project, and admits only the project’s owner. Being able to read a container is not ownership, so a container that is only shared with the caller is refused. Anything that does not pass returns 401, and a revoked bearer stops being admitted within a minute. The kit adds no session of its own on this path, so a stolen bearer is bounded by its own lifetime. These admission classes are the kit’s own control and do not replace proxy permissions. To close the proxy layer as well, set the container’s default to deny, which applies to every kit on that container, the bot included.

Every command is classified when it is generated. A command the generated document carries no class for is refused by name, with the property that stopped it, and there is no later release in which it becomes runnable. That refusal happens before the kit calls anything, so such a command never reaches the API under your token.

Six of the 774 generated commands are classified, allowed and still not offered, because the chat surface has no way to carry them. Four require a file upload as an argument, which a chat cannot collect yet. Two stream over WebSocket, a transport the kit does not speak. These refusals are about the surface rather than about you or your token, and each one names the argument or the transport that stopped it.

A credential offered outside a direct message

Section titled “A credential offered outside a direct message”

Credentials are accepted in direct messages only, on every path. In a group the bot answers /login with a button that opens a direct message and refuses a pasted token. It tries to delete the message that carried the token; if it cannot, it says so and asks you to delete it yourself and rotate the credential.

An approval echoes the identifier and the generation of the decision it answers, and the kit compares them. An approval aimed at a prompt that has already been superseded does not apply to the one now waiting.

There is no webhook route, so the chat app never makes a request into your container and there is no inbound address to keep secret. Updates arrive instead as responses to the kit’s own outbound polling, which means they are authorized by who sent the message and by the risk gate rather than by the admission classes above. Those classes still govern the routes the kit does serve over HTTP: health, the login page, the browser shell and the management surface.

Outbound, the kit’s own HTTP client refuses any host outside a fixed allowlist, which covers the proxy domain, the chat app’s API, and any configured speech provider. That control guards against bugs and server-side request forgery. It is explicitly not an enforcer against a compromised kit, which already controls the client. The container firewall behind it filters by address range rather than by hostname, so it is defence in depth for the ranges stable enough to pin.


Every operation is classified before it runs, as read, write, destructive, action or danger, alongside a confirmation requirement and an execution class. Anything the classifier cannot place fails generation rather than shipping as a guess.

Every command the surface can carry runs, and the classification decides what you are shown first: nothing for a read, a tap for a write or an action, a typed phrase for anything destructive or dangerous. Six reads reach outside Hoody, the four storage-backend checks, the URL download and the git fetch, and those ask for a typed phrase too, because the class says how serious the target is while the execution class says the call leaves the platform. Token creation, token deletion, token copying and vault secret reads are classified as dangerous, because of their impact rather than because of anything specific to chat. A command whose class nobody decided is refused outright, and so are the six the section above names.

The typed 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 derived from that prompt’s own stored identity. 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. Answering one prompt with a phrase read off another confirms nothing, and a phrase whose tag fits more than one prompt claims nothing at all: the bot asks you to reply to the prompt you meant, with the phrase that prompt shows.

A confirmation names the target it will run on and the execution class it carries, and the kit binds that sentence, so a prompt whose target has moved no longer confirms. In this release the sentence also says that the bot has not estimated a cost.

Buttons carry 32 random bytes rather than the operation itself, encoded as 43 characters and so well inside the platform’s 64-byte limit on callback data. The key resolves server-side to a stored intent, so the payload the chat platform holds describes nothing, and an edited key resolves to no intent at all. Decisions never ride on an ephemeral message, whose lifetime the kit does not control; confirmations and approvals are ordinary persistent messages backed by stored intent, bound to the person who asked and to the chat they asked in.


Delivery is at least once, with a marked duplicate

Section titled “Delivery is at least once, with a marked duplicate”

Telegram delivers an update at least once and its send API takes no idempotency key, so the reply and the record of that reply cannot be made one atomic act. The kit does not claim to have closed that window. Each update is claimed before handling and settled after, and for a read the record distinguishes an answer that never left from one that may already be in the chat. A redelivery reruns the read when nothing was sent, does nothing when the answer arrived, and runs it again and marks the one copy it sends when the record says a send was in flight. The audit log records that case, so an operator can find it.

A confirmed command that is not a read is never run a second time, because repeating it would be a side effect nobody confirmed. Its record keeps whether the request was still on the wire, came back having changed nothing, or came back having taken effect, and a redelivered tap or a restart answers from that record: the operation may already have run and was not run again, it did not run, or it ran and its result may not have reached you. That is the half of the window the kit closes rather than marks.

Stored token rows are encrypted with a key the kit generates at first boot and keeps in its own state directory, mode 0600 and readable only by root inside the container.

The scope of that protection is narrow. It protects the kit’s database file if that file alone is exfiltrated. It does not protect a container snapshot, a copy of the container, or a full-rootfs backup, because each of those captures the key and the data together. This is the same posture as the hoody CLI’s own stored credentials, which live in a plaintext 0600 file, and it is the reason snapshots of a container running the bot deserve the same handling as the credentials themselves.

If the kit itself were compromised, the exposure is every token it stores until those tokens expire. That means each user’s working token, and each user’s minting token, which can mint further tokens inside the same permission set for the rest of its 30 days and which no API token can revoke.

The bound on that exposure is not a narrow permission set, because the set is wide by design. It is the whole least-privilege tree of each signed-in user, for the minting token’s lifetime, across the realms that user holds. The recovery step is the one only the account holder can perform: hoody auth tokens delete <id> against the minting token, from their own session, which takes the working token minted under it with it.


The identity model above is complete. Two things it will govern are not built yet: the streaming agent with its approval buttons, and voice notes, where the gate is stricter because a typed confirmation cannot be spoken.


Next: Authentication, how Hoody tokens are minted, scoped and revoked outside chat.