Drive keryx from a bot or another product¶
keryx serve is a second door into the same agent harness keryx shell uses —
a loopback HTTP listener, so a Telegram bot or a browser workspace becomes a
client of one surface instead of a second integration with its own copy of
session state.
It is off until you configure and start it. Every command below was executed; the output is from those runs.
Read this before you point anything at it¶
- Approvals are asynchronous and fail closed. A turn whose policy decision is
askraises a durable pending approval and detaches; it runs the call only after a person answersallow, once, for that call. Unanswered means denied. See Answer a remote approval. Today the stock listener registers no tools, so it raises none; approvals apply once a tool registry is injected. - The remote policy profile may never be weaker than the local one. It is compared per turn and a weaker profile is refused.
- The prompt is scanned but reaches the provider unredacted. Only outbound content is redacted.
- No route accepts a secret.
1. Configure¶
$ keryx serve config show
no serve configuration was found. Run `keryx serve config init` to create one.
$ keryx serve config init
✓ wrote ~/.local/share/keryx/serve.json
enabled: true
bind: 127.0.0.1:7377
profile: remote-restricted
credential: auth-json ref …
approvals: expire after 300s, max 4 pending per session
non-loopback acknowledged: false
No credential yet. Run `keryx serve token issue` — the token is printed once and never again.
The configuration is user-global, not per project — one listener serves the projects in your registry.
2. See the state machine refuse¶
$ keryx serve status
state: refused
bind: 127.0.0.1:7377 (loopback)
profile: remote-restricted
credential: absent
pending approvals: 0
• no serve credential exists. Run `keryx serve token issue` …
refused is terminal and binds no socket. It is never a degraded listen:
a listener that cannot satisfy its configuration does not open a port.
3. Issue a token¶
$ keryx serve token issue
token: <printed once>
• This is shown once. It is stored only as a salted hash and cannot be recovered.
the serve configuration now references this credential
$ keryx serve status
state: configured
credential: present (fingerprint 70d73561)
rotate replaces it; revoke removes it and the state returns to refused.
4. Register the projects it may serve¶
keryx init registers a project automatically. A request naming an
unregistered project is refused — there is no fallback to "some other project".
5. Start it¶
Routes. Every one is authenticated, but not with the same secret: the six below
take the serve bearer token, and the /v1/remote/* routes (next section) take
only the local shell token and refuse the bearer.
| Route | Purpose |
|---|---|
GET /v1/status |
listener state |
GET /v1/projects |
the projects this listener accepts turns for |
POST /v1/turns |
submit a turn; takes an idempotency key scoped per project |
GET /v1/turns/<id> |
the durable turn record and its SSE stream |
GET /v1/approvals |
pending approvals a person can answer |
POST /v1/approvals/<id> |
answer one: {"decision":"allow"\|"deny"} |
The properties your integration can rely on¶
- Authentication runs before routing. One fixed
401on every path and method, so an unauthenticated caller cannot tell a known route from an unknown one — and cannot even cause a request body to be read. - Bearer tokens are compared in constant time, and only a salted hash is stored.
- Idempotency keys are scoped per project, so two projects cannot collide on one key.
- Repeated authentication failures are throttled, per peer.
And one absence: GET /health does not exist. There is no PID file either,
so keryx serve status reports configuration state only — a listener running
in another process is not visible to it. Liveness is authenticated-only, over
GET /v1/status.
Verify¶
configured means it will bind when started. refused means it will not, and
the reason is printed underneath it — read that line rather than retrying.
Remote control from Telegram¶
/remote-control in a running keryx shell mirrors that session into its own
topic of a Telegram supergroup. A line you send in the topic runs in the shell as
if you had typed it there (it shows as tg ❯ in the transcript, and [tg] in the
queue panel), and the reply, plus any approval question with Allow and Deny
buttons, comes back to the same topic. It is a client of this listener, not a
second bot runtime: the shell never talks to Telegram.
It is off by default, at two levels. keryx serve starts remote control only
when both files below exist and are valid. With neither file it prints
channels ready: connect Telegram from the shell with /channels and serves exactly
as before, offering only the local /channels routes that connect it. A config that
is invalid, or a start Telegram refused, prints remote control is off: <reason>
instead. And a shell only mirrors a session after you type /remote-control <name>
in it.
Connect from the shell (recommended)¶
Make a separate bot with BotFather and a supergroup with topics enabled, then run
keryx serve and, in any keryx shell, type /channels and choose Connect.
- Paste the bot token. The field is hidden and the token goes only into
remote/bot-token(mode 600), written by the shell; it is never sent toserve. - The modal shows a one-time code and the bot's name. Send the code to the bot in a private chat. The first message that carries it adds your Telegram user id to the allowlist; the code expires after 10 minutes and works once, so someone else writing to the bot cannot claim it.
- Add the bot to your group as an administrator with the manage topics right. The group id is taken from that event, and a bot that is already in the group is picked up too. This step has its own 10 minutes, counted from the moment the code is accepted, and the modal shows how many are left. If the group has no topics or the bot lacks the right, the modal says which. Turning Topics on turns a basic group into a supergroup with a new id; the pairing follows that move by itself.
- A test message arrives in the group's General topic and the channel reads
Connected.
servestarts using it without a restart.
A pairing that is finished while the modal is closed is not lost: reopen /channels
and Resume connects it. A mistyped token on a second Connect is refused and
leaves a pairing that is already open, with its code, untouched.
Test sends another message naming this machine. When the files are saved but
serve could not start Telegram (the modal reads "configured, but not running"),
Retry starts it again from them without erasing anything. Disconnect asks serve to
delete every topic and stop polling, then erases the token and the config; if
serve is not running it erases the files and says that the existing topics stay in
the group. The connection belongs to the machine: any shell can connect or
disconnect it, and sessions still opt in one by one with /remote-control. Use one
bot per machine, because Telegram allows one poller per token; the group can be
shared. The seven /v1/remote/channels-* routes (status, pair, pairing,
cancel, reload, test, disconnect) take the shell token like the other
/v1/remote/* routes and carry no secret: a pairing code, ids and a machine name
only.
Setup by hand¶
The shell does this for you; the files are the same. All three inputs are needed. The files live in the user-global keryx directory
(~/.local/share/keryx/ on Linux and macOS, %APPDATA%\keryx on Windows), under
remote/.
- A separate bot. Make a new bot with BotFather, not one you already use for
something else. Put its token on one line in
remote/bot-tokenand runchmod 600on it. A token file readable by anyone else, or one that does not look like a token, is refused, and the message names the file, never the token. - The allowlist.
remote/config.jsonholds the Telegram user ids whose messages and button presses are accepted:
Anyone else is ignored. The ignored sender's id and the time go to
remote/rejected.jsonl; what they wrote is not stored. The schema is closed:
an unknown key, including a token pasted into the wrong file, is an error.
3. A supergroup with topics. Enable topics in the group, add the bot as an
administrator and give it the manage topics right. chatId above is that
group's id.
Start keryx serve and the shell as usual. In the shell, /remote-control
<name> creates the topic (the name defaults to <project>-<short session id>),
/remote-control status shows its state and recent events, and
/remote-control off removes it. In the full-screen shell the same control is the
sidebar row and the /remote-control modal. The readline shell can only report
that it is off.
How it behaves¶
- One poller. The Telegram transport is a single long-poll loop inside
keryx serve. Shells reach it over loopback with the local shell token, whichservecreates; a shell cannot poll Telegram. A secondkeryx serveon the same token meets a conflict from Telegram and stops polling for good instead of taking updates from the first. - Two secrets, two route tables. The
/v1/remote/*routes (register,deregister,heartbeat,reply,approval,approval-ack,prompt,prompt-close,ack, andGET stream) accept the shell token only, from a loopback connection. The serve bearer token does not reach them, and the shell token reaches nothing else. The shell token is new every timeservestarts and the shell re-reads it on each request, so restartingserveneeds nothing from you; a shell will not send it to an endpoint whoseserveprocess is no longer running, and says remote control is unreachable instead. serveproves it isserve. The shell never sends the raw token: each request carries a fresh random nonce and an HMAC of it made with the token, andserveanswers every shell route with a proof (the headerx-keryx-serve-proof) over the nonce, the route, the status and the body. A shell that gets no proof, or a wrong one, from whatever listens on the port refuses the answer, writes nothing (no allowlist entry, no group id) and says to restartkeryx serve. That also covers aservefrom before this check. A process that took over the port after a crashedservetherefore cannot make the shell save its own Telegram id as the operator; the bearer it saw is useless after the nextservestart.- Nothing is replayed or lost across a restart of
serve. No Telegram offset is stored;servedrops an update id it has already seen, and a lower id than ones seen (Telegram restarted its numbering) is delivered, not swallowed. At most 500 lines can wait undelivered in one topic; past that the topic gets a status line with the count. - Only the final answer goes to the topic. Text the model writes before a tool call is narration and stays in the shell.
- Turning it off is explicit about what is dropped. Queued Telegram lines are
removed from the shell queue, the shell prints which, and the topic is told
before it is deleted. Removing one queued line with
/queuetells its topic too;/queue editkeeps its origin so the answer still goes to Telegram. - Long replies are split. A reply over 4096 characters (Telegram's limit,
counted in UTF-16 units) goes out as numbered messages,
(1/3),(2/3), ..., in order. A fenced code block that crosses a boundary is closed and reopened, so each message renders on its own; words and emoji are not cut. - Replies are formatted. Messages go out as Telegram HTML (
parse_modeHTML):**bold**,*italic*,~~strike~~,`code`, fenced blocks (with their language), headings,>quotes,-bullets and[text](https://...)links render as such. Onlyhttpandhttpslinks become links. Anything ambiguous or unclosed (snake_case_name,a*b*c, a lone**) stays as the literal text, and&,<and>in a reply are escaped, so a reply can never inject a tag. Telegram's 4096-character limit counts the text after it parses the markup, so tags do not use it up. If Telegram still refuses a message's markup (400 "can't parse entities"), that one message is sent again as plain text andserverecords aformat-fallbackevent. Approval prompts are sent as a code block, so what you approve is shown exactly. Tables, numbered and nested lists, task items and rules, and theremote.renderingsetting that picks between HTML and native rich messages, are covered in how replies look in Telegram. - Orphans and limits. If a session stops sending heartbeats, its topic gets a
notice and is deleted after 10 minutes (
orphanMs); a heartbeat inside that window brings it back. A run started from Telegram has no time limit by default; setrunTimeoutMsto a positive number of milliseconds and a run is interrupted after that long and the topic is told (0, or leaving it out, means no limit). Send/stopin the topic to end a run yourself. Both keys are optional integers inconfig.json, in milliseconds. - History. A session that was driven from a topic records the topic and each
span in its history:
keryx sessions listshows a⇄ remote <topic>line under it,--jsoncarries aremotefield, and thekeryx shell -rpicker marks it⇄ remote.
How replies look in Telegram¶
A reply is rendered when it is sent, in one of four modes. The mode is the optional
rendering key of remote/config.json (auto, rich, html or plain; the default is
auto). Any other value is refused with a message that names the valid ones. Change it from
the shell with the Telegram rendering row of /settings or with /rendering <mode>; the
next message part picks it up, with no restart.
| Mode | What goes out |
|---|---|
auto |
A reply that holds a table goes as a native rich message (Bot API 10.1 and later, sendRichMessage); every other reply goes as HTML, exactly as before. |
rich |
Every reply goes as a rich message. |
html |
Telegram HTML (parse_mode HTML), the formatting described above. |
plain |
Plain text with the markup stripped. |
What each construct becomes:
- Links whose visible text reads as a web address for another host than the target show
that host after the link, for example
https://mybank.example (→ evil.example), in HTML and in a rich message, so a label cannot pass for a different site. Any other link is unchanged. - Tables are never sent as raw pipes. In a rich message a table is a native table block with a
header row: it is as wide as its content and scrolls sideways, and no cell is shortened. In HTML a
table is an aligned
<pre>block with the separator row dropped, and in plain text the same aligned layout without markup; a row too wide for that layout is stacked asheader: valuelines instead. A table with more than 20 columns (Telegram's limit for a native table) is not treated as a table at all: it goes out as ordinary text, so its pipes show. As in GitHub Markdown, a row with more cells than the header loses the extra cells, and a row with fewer is padded with empty ones. - Ordered lists keep their numbers, nested bullets keep their indentation, task items
show a box or a ticked box, and a rule (
---) shows as a line, in HTML and in rich. - Text that holds none of these renders byte for byte as it did before.
Long replies are still split into numbered (i/n) parts within the limit of the mode in use
(4096 characters for HTML and plain, 32768 characters and 500 blocks for a rich message). A table is
never cut inside a row, and its header row is repeated at the top of the next part. A part that holds
a table also stays within the 500 blocks of a rich message, counted for the whole part: a table row,
a paragraph, a heading, a quote line, a list item and a code block each take one or more, so a tall,
narrow table, or a table with many paragraphs or list items around it, goes out in several parts and
stays a native table, instead of one part that Telegram would refuse and that would fall back to HTML.
The one exception to "never cut inside a row" is a row too large for a message together with the
header: it is written as stacked header: value lines, without the header row, and only a single
cell longer than a whole message is cut, at a space in the second half of the part when there is one.
The same parts are used in every mode.
The fallback chain. A rich message is sent only where the Bot API accepts it, and the
shell does not assume it does. If Telegram refuses a rich message with a 4xx, the same text is
sent once as HTML; if Telegram then refuses the HTML markup (400 "can't parse entities"), it is
sent once as plain text. A reply is never dropped because of its format. A 403, 404 or 405 on the
rich call also pauses rich messages for ten minutes, so a bot that cannot use them does not pay a
failed call for every message. Network errors, 5xx and 429 are not a refusal of the format: they
are retried by the durable queue. An edit follows the same chain through editMessageText with
rich_message. Each fallback is recorded with its step, its reason (the bot token is never in
it) and the time; /channels shows the mode in effect and the last fallback.
To see what a mode produces without sending anything, run keryx remote format-sample. It
prints a fixed sample reply (a table, an ordered and a nested list, task items and a rule) as it
goes out in each mode, with no network call; --mode <mode> narrows it, --full prints the rich
message itself, and --json prints it for a script. The Bot API facts the rich path was built
against are in the spike note, docs/requirements/keryx-telegram-rendering/spike.md. Rich
messages have been checked against a fake Bot API only; the live check with a real bot is
pending.
Permissions in a topic¶
A turn started from Telegram runs like a turn in the shell in trust mode: ordinary
commands inside the project run without a question, and every line that runs this way
is recorded as an approval event with the Telegram user id of the person who sent
the message. The things that always ask still ask, exactly as in a shell trust turn:
destructive commands, privilege escalation, downloaders, an agent's credential files,
a flow or acceptance confirmation, a git publish lease, a hook that asks, untrusted
content, a destructive apply_patch, and any MCP use_tool (a grant you made in the shell
for an MCP tool does not apply to a Telegram turn). /plan still refuses
mutations, and apply_patch outside the project root is refused in every mode. There
is no extra Telegram-only floor for network or outside-project commands. A
spawn_subagent call is auto-approved under trust, as in a shell trust turn: the
subagent runs under the same mode and the same floors as the turn that started it.
The trust belongs to the Telegram turn itself; other work running in the shell at the
same time is judged by the shell's own mode and asked in the shell.
When something asks, the question goes to the topic as Allow | Always: <pattern> |
Deny. The Always button is offered only when the same rules as the shell dock
accept the pattern, and it saves it to permissions.json. It is not offered when the
command is longer than the 300-character pattern limit (it never falls back to a
broader prefix such as git *), when the question had to cut the command short, or
when hiding a secret would change the pattern shown. A cut question says so and
names how many characters were shown; the topic then says
Remembered: <pattern>. A press from someone who is not allowed, a press after the
question expired, and a second press of the same button save nothing and approve
nothing. A saved or session pattern approves a matching command without a question,
with the same exclusions as the dock.
The question waits 15 minutes by default (approvalTimeoutMs, 30000 to 3600000
milliseconds, in remote/config.json). When it runs out the command is refused, and
the message says so. This wait is for Telegram only; the HTTP serve approval expiry
(approval.expirySeconds in serve.json) is unchanged.
Three optional keys of remote/config.json set the defaults, and /remote-policy in
the shell (or the Telegram group of /settings) changes them:
| Key | Default | Meaning |
|---|---|---|
permissionMode |
trust |
ask or trust. With ask, the shell's own prompt rules apply to a Telegram turn. |
runTimeoutMs |
none | A positive number stops a run after that long. 0 or absent means no limit. |
approvalTimeoutMs |
900000 |
How long a question waits, 30000 to 3600000. |
There is no run limit by default, on purpose: a run ends when it finishes or when you
send /stop. To get a limit, set runTimeoutMs, for example 1800000 for 30 minutes.
To go back to the old behaviour set "permissionMode": "ask", "runTimeoutMs":
1800000 and "approvalTimeoutMs": 300000.
One permission mode. The shell has one permission mode. The config's
permissionMode applies to Telegram turns until /mode changes the mode in this
session; after that the shell's mode wins. /mode ask from the topic runs directly;
/mode trust and /mode auto keep their Yes / No button, and one tap commits them: the
shell's own "Switch to auto mode?" confirmation is asked only when /mode auto is typed
in the shell. auto is never taken from the config. The shell shows which one is in force, trust (Telegram default) or ask
(shell /mode), and keryx serve status says when the shell overrides the default.
/remote-policy [mode ask|trust] [limit none|<minutes>] [wait <minutes>] changes
the saved defaults and the running shell's copy only, not the mode you set with
/mode; a serve that is already running hands new values to shells that register
after its own restart or reload.
Seeing and removing rules. keryx permissions list and keryx permissions remove
<number|pattern> (or /permissions in the shell) show and take back what Always
saved. A rule is never added or removed from a topic. In the full-screen shell the
sidebar shows a posture line while remote control is on, and a click on it opens the
/permissions modal. keryx serve status (and --json) prints the posture with the
number of allowed users and no ids or secrets.
Commands from the topic¶
A line that starts with / in the topic is not sent to the model. It goes through a
gateway that knows exactly which commands a topic may run; everything else is
refused with the reason, in the topic. Only an allowed sender can use any of it (see
the allowlist above), and the commands run in the shell, so they obey the same
policy as when typed there. The output that comes back is redacted the same way as a
reply, and a command never takes a secret as an argument or prints one.
Type / in the topic and Telegram's menu lists the same set (/help lists it too).
Telegram menu names cannot contain a hyphen, so /external-agents shows as
/external_agents; both are accepted.
| Kind | Commands | What happens |
|---|---|---|
| Text | /status, /doctor, /new, /clear, /compact, /think auto\|expand\|hide, /goal, /queue, /reasoning, /theme <name>, /jevrules, /staledocs, /opencomments, /contract, /triage, /risk, /scenarios |
Runs in the shell as typed; the output comes back as a reply. Whether it worked is taken from the command itself, not from its wording. |
| Buttons | /model, /connect, /resume |
Replies with a picker; the press does the switch. /connect shows providers that are already connected, then their models. No key or address is shown. |
| Built in | /help, /sessions, /history [N] |
Answered without running anything in the shell. /history posts the last N messages of this session into the topic (see below). |
| Stop | /stop |
Ends the run that is going in this topic. The topic gets Stopped by you. when the turn has ended. Nothing else is touched. |
| Asks first | /mode, /plan, /delegate, /external, /external-agents |
See the confirmation rule below. |
The confirmation rule. A command that raises trust or sends work outside the machine runs only after a Yes press in the topic:
/mode trust,/mode autoand/plan offask Yes / No. Reading the mode,/mode ask, bare/plan(it shows the mode) and/plan ondo not ask./delegate <agent> <task>,/external on|offand/external-agents on|offask a question that names the agent, says it is an external agent and that the work is paid. The button readsYes, send to <agent> (paid). The step that applies an external patch,/external apply <hash>, is not available here: the hash is typed in the shell.- No, or no press before the question expires, changes nothing. The shell's
/remote-controlpanel (Commands tab) lists a question that is still waiting. - The message you typed keeps its "running" reaction until the question is answered or has expired. If the delegated task is longer than 200 characters the question shows it shortened and says so, with the full length; the full task is what runs.
Pickers and buttons. A button carries a short single-use token, never a command or a name, so it cannot be forged or replayed. It is bound to the session, the message and the person it was sent to, expires, and is never mixed up with an approval button. After a press, the message is edited in place to show what was chosen and the buttons go away; approval messages are edited the same way.
Questions from the agent, and your own answer. When a turn that started from
Telegram makes the agent ask a question with options (ask_user), the question is
shown in the topic with one button per option and a last button, ✍ Свой ответ
(own answer). It is shown in the shell's dock at the same time, and the first answer
wins: the other place is closed, and the topic message is edited to say it was
answered in the shell, or the dock says it was answered in Telegram. A question from
a turn typed in the shell, and the pickers behind /model, /connect and /resume,
have no own-answer button.
- Press
✍ Свой ответand keryx posts a message that asks you to reply to it. Reply to that message (Telegram's Reply, not a new line) with your text. The text is returned to the agent as your own answer, not as an option. The question message is edited toOwn answer (user <id>, <time>): it records who answered and when, not the text (the shell's transcript and the journal show the text, redacted). The reply must come from the same person who was asked. If the dock answered in the same moment, the shell drops your text and the message is corrected to say so. - The binding is the replied-to message and the person, never "the next message": a reply to some other message, a reply from another person, or a reply posted in another session's topic resolves nothing. A reply to the box never starts a turn. A line you type without replying is an ordinary line for the agent, as before.
- Pressing the button gives you at least 5 minutes to type (the option buttons
keep their own timeout,
choiceTimeoutMs, 2 minutes by default). A reply after the window, or after the question was answered elsewhere, resolves nothing, and the topic answers each such reply (from whoever sent it) with "That question is no longer open". - Approvals (Allow, Deny, Always,
/mode, grants) have no such path: typing "yes" or "allow", as an own answer or as a reply, never approves anything. Only the approval's own button does. - The own text and a reason are kept in the recommendation journal up to 2000
characters (a cut one ends with a visible
[truncated: N more characters]marker) and are redacted first, so a token-shaped string never reaches the journal, a flow'sjournal.mdor the report. See the recommendation journal.
serve keeps the box in memory: if it restarts while a box is waiting, the question
is gone, and a reply to that box is told the question is no longer open (serve
recognises the box by the text Telegram attaches to a reply); it never starts a turn.
The shell closes a question on
serve with the loopback route prompt-close when it was answered in the dock or the
turn was stopped.
Approval answers are confirmed by the shell. When you press Allow or Deny,
serve writes the answer to the shell's stream and waits 5 seconds for the shell
to post approval-ack. The ack says whether the answer was applied to a question the
shell was still waiting on. With an applied ack, the message ends "Allowed by user N at ..." or
"Denied by user N at ..." and the short reply is "Approval granted." or "Approval
denied.". If the shell had no question waiting for that id (it timed out, or the
session reconnected), the ack says so and the message ends "Not applied at
These commands stay in the shell. /exit, /quit, /channels, /provider,
/search-provider, /search-connect, /remote-control, /integrate, /copy,
/game, /setup, /mcp, /guard, /route, /editguard, /settings, /bus,
/review, /reviews, /product, /governance, /expand, /interrupt (use /stop),
/permissions, /remote-policy, /demote, /models, /external-diff, /flows, /ac, /workspace,
/approvals, /decisions, /triggers, /routing, /schedules, /jevprofile, /schedule,
/rewind, /conform and /ci. Bare /theme and bare /think (and /think
collapse) are refused too; use /theme <name> or /think auto|expand|hide. They
ask for a key, change a safety guard or where work is routed, edit another tool's
configuration, open a panel, a form or a picker that exists only in the shell, or
would close the shell. /mcp trust, /guard,
/route and /editguard are deliberately local. The topic is told which command
was refused and why.
While a turn runs. The shell refuses /new, /clear, /resume, /sessions,
/compact and /connect, and the topic says main is busy: command deferred;
nothing is queued behind it. /route, /external, /external-agents and /model
are different: when main is busy they are queued, the topic gets
<command> is queued: main is busy at once, and the command runs when main goes
idle (a confirmation, where one is needed, is still asked first). The queued command
is dropped with a note if main stays busy too long, or quietly if remote control goes
off. When main is idle they run as soon as you press Yes. /route is on the menu:
bare /route shows the routing, /route on and /route off ask Yes / No first, and
any other argument is refused. /mode takes effect at once. A command that runs is answered with a
short notice if it takes a while. A command from the topic never cancels the turn
you are running in the shell: one that outlasts the limit is only no longer waited
for, the topic is told it is still running in the shell, and it is left alone; when it
ends, the topic is told how it ended and what it printed.
/new and /clear keep the topic. The topic stays bound to the running shell,
gets one separator line (--- new session ---), and the session history records
the change. No second topic is created and none is deleted. /resume offers the
earlier sessions as buttons and returns to the one you press
(--- resumed session ---). Typing /new in the shell still ends remote control
as before.
History in the topic. A topic is archived when the shell exits (see
What happens when the shell closes), so a session you resume
with keryx shell --continue gets a new, empty one. /history [N] posts the session's last
messages back, from the topic or from the shell (N is 1 to 20, and 10 when you give no
number). Each message is posted on its own, oldest first, with a You: or Agent: label, about
two seconds apart so Telegram does not throttle the run; a message longer than 1,500 characters
is cut with …. The messages are what you typed and the agent's final answer of each turn: no
tool calls, no tool output, no reasoning and no narration written before a tool call. Anything
that looks like a secret is redacted first, as in every reply. A message that contains a Telegram
bot token or any long run of letters and digits that looks like a key is not posted at all: it
appears as [сообщение скрыто: похоже на секрет], because the redactor can miss a token you pasted.
While a turn is running, the answer being written is left out; it appears once the turn ends.
When you resume a session and then turn remote control on, and /remote-control <name> creates
a new topic, the last 10 messages are posted by themselves, once, and the shell says so. That
is the only automatic case, and only the first time remote control is turned on in that shell: a
topic that already existed is never refilled, and turning remote control off and on again (that
makes a new, empty topic on purpose), a reconnect or a restart of serve post nothing. Anything
else is /history. If the automatic restore cannot run, the shell says why in the transcript. The
shell shows the last post in the /remote-control modal (key h posts it again, shows that it is
working and repaints when it is done, and shows an answer such as "remote control is off" in the
modal itself) and as a line under the Remote row of the sidebar. If Telegram refuses a message the run
stops there and says how far it got; sending /history again posts the whole set again, so a
topic can hold a message twice.
Message state. Each message you send shows its state as a reaction on the
message: received (eyes), accepted into the session (thinking), running
(lightning), finished (thumbs up), refused or failed (thumbs down; Telegram does
not allow a cross mark as a reaction). A new state replaces the old one. While a
turn runs the topic also shows "typing", refreshed about every 4 seconds, and it
stops when the turn ends. If the bot is not allowed to react in the group, the
reactions turn off for good with one reactions-unavailable event in serve, and
the typing indicator still works. A failed reaction call never delays or blocks a
message.
What the topic hears¶
While /remote-control is on, you can run a long job from your phone and not lose
track of it. Nothing below needs a setting; the intervals are fixed.
Every question the shell raises is posted. A command approval, an ask_user
question and a queue dialog all appear in the topic with Allow / Deny or one button
per option, whether the turn started in the shell or in the topic. The first
answer wins and the other side is closed exactly once:
- Answer in the topic first: the shell's dock closes the same question and carries on with your answer; the message is edited to say who answered and when.
- Answer in the terminal first: the Telegram message is edited to "answered in the shell" and its buttons stop working, so a late press does nothing.
Reminders. A question nobody has answered gets a reminder in the topic every 2 minutes, at most 3 times. The reminder quotes the first line of what was asked (cut at 80 characters), says when the question expires and points at the buttons on the original message; it adds no new buttons. A question that is answered, expired or closed stops its reminders.
Progress notes. During a turn the topic gets a short note at a wave boundary (the
first subagent of a review fan-out starting, and the last one finishing) and after a
tool-heavy stretch (8 tool calls since the last note). At most one note a minute
goes out; if a second one is due earlier, the weightier one is held and sent when the
minute ends, and any note still held when the turn ends is dropped. A note carries
counts, tool names and subagent labels only, for example Still working: 12 tool calls
in the last 1m (read_file, shell_exec). 3m 20s into this turn. Tool input, tool output
and the model's text never go into a note.
Status questions are answered at once. While a turn runs, a short plain line that
only asks how it is going (status, how is it going?, are you still working?,
статус, как дела?) is answered from the shell's own state without waiting for the
turn: elapsed time, tool calls so far and the last tool, subagents still running, how
many messages wait, how many questions wait for you, and the mode. It is not queued
and does not reach the model. A longer message that merely contains the word "status"
is work and is queued as usual, and when the shell is idle the same words go to the
model like any other line.
What happens when the shell closes¶
The topic is archived, not deleted. When the shell exits (/exit, /quit), the
topic gets a closing note ("This topic is archived: the history stays here and nothing
more will be posted") and is closed. It stays in the group, readable, with everything that was said in it.
When the shell is killed (Ctrl+C twice, a closed terminal, a crash) it cannot say
goodbye: serve announces the silence in the topic and, after the orphan window
(10 minutes by default), posts the same note and archives it. A heartbeat inside the
window cancels that. A resumed session (keryx shell --continue) gets a new topic;
/history copies the last messages into it. Only Disconnect in /channels deletes
topics.
In the full-screen shell, the /remote-control modal has a Commands tab with
the recent commands from the topic, including refused ones and confirmations nobody
has answered yet. A command that came from Telegram is echoed in the transcript as
tg ❯ /model.
Out of scope for now: voice, files and photos, entering credentials, several shells in one topic, and the shell's games and editors.
What is not verified¶
This was built and tested against a fake Bot API, in process. It has not been run against real Telegram: topic creation, the supergroup permissions, the button callbacks and the 409 behaviour are as the Bot API documents them, not as observed. Run it with a throwaway bot and group first.
Where to go next¶
- Architecture › Remote entry — the ordered decision path, drawn, because the order is the control.
- CLI reference › serve — every subcommand and flag.