- runner.ts: cap captured stdout/stderr at 64 KiB per stream via a new Capture helper, keeping the first 48 KiB and last 16 KiB once a stream exceeds the cap, joined by "\n...(truncated)...\n". Prevents a runaway watcher script from ballooning memory or a Telegram message. - runner.ts runScript: delete TELEGRAM_BOT_TOKEN from the child environment before spawning, since model-written scripts must never be able to read and exfiltrate it. Spec section 6.2 updated to describe the exclusion. - scheduler.test.ts: the "drain() gives up after its timeout" test no longer calls s.stop() at the end -- no timer is armed after the at job fires, and its delivery never resolves, so stop()'s 10s grace period would run out for nothing. Full suite now runs in ~1.5s. - runner.test.ts: relax the timeout-kill elapsed-time assertion from 3000ms to 4000ms to reduce flakiness, per the review. Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT
21 KiB
Volcanic Secretary: design
Date: 2026-09-06 Status: approved in brainstorm, awaiting spec review
1. Summary
Volcanic Secretary is a headless personal assistant daemon. Telegram is its only
interface. The agent engine is Pi (@earendil-works/pi-coding-agent, 0.85.x)
used through its SDK, embedded in the same Node process. The assistant is bound
to one workspace directory, runs tools without approval prompts, accepts text,
photos, voice notes and documents, and has a scheduler that can send reminders,
run prompts on a schedule, and run scripts whose output decides whether you get
a message at all.
It runs on Windows first. The code is host neutral and ships with a Dockerfile and compose file for a Linux server later.
2. Decisions taken during brainstorming
| Topic | Decision |
|---|---|
| Topology | Headless daemon only. No Pi TUI involvement. |
| Purpose | Personal assistant bound to one fixed workspace directory. No project switching. |
| Host | Windows now, Linux Docker later. Code stays host neutral. |
| Tool gating | Fully autonomous. Every tool call runs immediately. |
| Inputs | Text, photos, voice notes, documents. |
| Speech to text | OpenAI transcription API. |
| Proactive messages | Yes. Scheduler with reminders, scheduled prompts, and conditional scripts. |
| Engine integration | Embed Pi's SDK in process. No pi --mode rpc subprocess. |
| Users | Single owner identified by Telegram user id. Private chat only. |
3. Goals and non-goals
Goals for v1:
- Chat with a Pi driven agent from Telegram with the conversation surviving daemon restarts.
- See what the agent is doing while it works, without streaming every token.
- Send photos, voice notes, and files and have the agent use them.
- Ask the assistant to remind you, to run something later, or to watch for a condition with a script and only speak up when it fires.
- Keep durable facts across sessions in a memory file the agent maintains.
- Run unattended on Windows, and later in Docker on the Coolify host.
Non-goals for v1:
- Any Pi TUI integration or terminal mirroring.
- Multiple users, group chats, or per user sessions.
- Tool approval prompts of any kind.
- Switching workspaces or projects from Telegram.
- Telegram webhook mode. Long polling only.
- Live token streaming into an edited Telegram message.
- Switching model or thinking level from Telegram.
- A heartbeat that wakes the agent without a scheduled job.
4. Architecture
One Node 24 process, TypeScript compiled with tsc, started with
node --env-file=.env dist/main.js. Three long lived components communicate
through small typed interfaces and are wired together in main.ts.
Telegram <--long polling--> TelegramBridge <--> AgentHost <--> Pi SDK (AgentSessionRuntime)
^ ^
| |
+----- Scheduler ---+
(jobs.json, script runner)
4.1 AgentHost
Wraps Pi's createAgentSessionRuntime. Owns the current AgentSession and
hides Pi's event union behind a narrow event type.
interface AgentHost {
start(): Promise<void>; // resume most recent workspace session, or create one
send(input: AgentInput): Promise<void>; // idle: start a run. busy: queue as steer
followUp(input: AgentInput): Promise<void>; // idle: start a run. busy: queue as follow-up
abort(): Promise<boolean>; // true when a run was actually aborted
newSession(): Promise<void>;
compact(): Promise<{ tokensBefore: number; estimatedTokensAfter?: number }>;
status(): AgentStatus;
subscribe(listener: (event: AgentHostEvent) => void): () => void;
dispose(): Promise<void>;
}
interface AgentInput {
text: string;
images?: Array<{ data: string; mimeType: string }>; // base64
}
interface AgentStatus {
busy: boolean;
model: string; // "provider/id"
thinkingLevel: string;
contextTokens?: number;
contextWindow?: number;
sessionFile?: string;
}
type AgentHostEvent =
| { type: "run_started" }
| { type: "tool_started"; toolName: string; summary: string }
| { type: "assistant_text"; text: string } // one assistant message finished
| { type: "run_settled"; finalText: string; aborted: boolean }
| { type: "run_error"; message: string };
Mapping from Pi session events:
agent_startbecomesrun_started. The host resets its per run record of assistant texts.tool_execution_startbecomestool_started. The summary is derived from the tool args: first line of the command forbash, the path forread,write,editandls, the pattern forgrepandfind, action plus name forschedule, and the tool name for anything else. Summaries are trimmed to 80 characters.message_endfor an assistant message with non empty text becomesassistant_textwith all text blocks joined, and the text is appended to the run record. Messages withstopReasonerrororabortedare not emitted asassistant_text.agent_settledproduces exactly one terminal event, decided by the last assistant message of the run. Pi's auto retry means an earlier error can be followed by a successful message, so only the last one counts.stopReasonerror:run_errorwith the message'serrorMessage.stopReasonaborted:run_settledwithaborted: trueand emptyfinalText.- Otherwise
run_settledwithaborted: false.finalTextis every assistant message in the run whosestopReasonwas nottoolUse, joined with blank lines, because Pi drains queued follow-ups and steers inside the same run. If that is empty, it is all recorded assistant texts joined with blank lines. If still empty, it is "Done, no text reply."
status()readssession.isStreaming,session.model,session.thinkingLevel,session.getContextUsage(), andsession.sessionFile.
Session creation uses the runtime factory pattern from Pi's docs:
createAgentSessionServices({ cwd: workspace, agentDir, resourceLoaderOptions })whereresourceLoaderOptionscarriesappendSystemPromptwith the secretary instructions and an inline extension whosebefore_agent_starthandler appends the current contents ofMEMORY.mdto the system prompt on every turn. The resource loader caches context files at load time, so this is the only way edits the agent makes mid-session become visible on the next turn.createAgentSessionFromServices({ services, sessionManager, model, thinkingLevel, tools, customTools: [scheduleTool] }).sessionManagerisSessionManager.continueRecent(workspace)on start and the runtime'snewSession()for/new.- After every session replacement the host re-subscribes and calls
bindExtensions({ mode: "print", shutdownHandler, onError }), following Pi's own print mode. - Tools enabled:
read,bash,edit,write,grep,find,ls,schedule. On Windows Pi resolvesbashto Git Bash by itself. - Model: if
PI_MODELis set it is resolved withresolveCliModelagainst the services' model runtime. Otherwise Pi's settings default applies.
Because the default resource loader is used, the daemon inherits everything in
~/.pi/agent: auth.json, settings.json, models.json, installed packages
such as the Claude Code provider, global skills, and the workspace AGENTS.md.
Concurrency: send while busy calls session.steer. followUp while busy
calls session.followUp. Both while idle call session.prompt. The host never
runs two prompts at once.
4.2 TelegramBridge
Built on grammY with long polling. Responsibilities:
- Owner guard. Any update whose
from.iddiffers fromTELEGRAM_OWNER_IDis ignored and logged once per foreign id. Group chats are ignored. - Command handling for the slash commands in section 5.3.
- Inbound normalization (section 5.1) and outbound rendering (section 5.2).
- Keeping the typing chat action alive while a run is in progress.
4.3 Scheduler
Owns <workspace>/.secretary/jobs.json, one timer for the next due job, a
script runner, and the delivery of results either directly to Telegram or into
AgentHost. Details in section 6.
4.4 Supporting modules
config.ts: reads and validates environment variables into a typed object.stt/openai.ts: posts audio to OpenAI's transcription endpoint with nativefetchandFormData. No SDK dependency.tools/schedule.ts: thescheduletool definition built with Pi'sdefineTool, delegating to the Scheduler.telegram/markdown.ts: Markdown to Telegram HTML converter.log.ts: minimal leveled logger to stdout.
5. Telegram behaviour
5.1 Inbound
Each non command message becomes one AgentInput:
- Text or caption is the base text.
- Photos: the largest size is downloaded via Telegram's file API and attached as a base64 image with its MIME type. Multiple photos in one message are all attached.
- Voice notes and audio: downloaded, sent to OpenAI transcription
(
STT_MODEL, defaultgpt-4o-transcribe), and the transcript becomes the text. The bridge replies with the transcript quoted, so you can see what the agent heard. IfOPENAI_API_KEYis missing the bridge replies that transcription is not configured and stops. - Documents: saved to
<workspace>/inbox/<yyyy-mm-dd>-<original name>with a numeric suffix on collision. The text gains a line:Attached file saved at inbox/<name>. - If both media and caption are present, both go through.
- If the agent is busy, the input is queued as a steer and the bridge reacts to the message with a queued acknowledgement so it is not mistaken for lost.
Media download or transcription failure replies with the error and still forwards any caption text.
5.2 Outbound
- On
run_startedthe bridge sends one progress message,Working..., and starts a typing action refreshed every four seconds. - On
tool_startedandassistant_textthe progress message is edited to show the last five tool summaries and the last narration line. Edits are throttled to at most one per two seconds, with the latest state always flushed at the end. - On
run_settledthe progress message is deleted and the typing action stops. Ifabortedis false,finalTextis sent as new messages. Ifabortedis true nothing more is sent, because the/stophandler already replied. - On
run_errorthe progress message is deleted and the error is sent prefixed with a warning marker. - Long texts are split at 4096 characters, preferring paragraph boundaries, then line boundaries, then hard cuts. Code fences that span a split are closed and reopened.
- Markdown is converted to Telegram HTML: headings become bold lines, bold, italic, strikethrough, inline code, fenced code with language, links, and lists as plain lines with a bullet character. Everything else is escaped. If Telegram rejects the HTML the same chunk is re-sent as plain text.
- Telegram API calls use grammY's auto retry transformer.
5.3 Commands
| Command | Effect |
|---|---|
/start |
Greeting plus the same output as /status. |
/new |
Start a fresh session. The previous session file stays on disk. |
/stop |
Abort the current run. Replies Stopped. or Nothing running. |
/status |
Busy or idle, model and thinking level, context usage, session file name, number of jobs. |
/jobs |
List jobs with id, name, schedule, source kind, delivery, next fire time. |
/cancel <id> |
Delete a job without going through the model. |
/compact |
Force context compaction and report the result. |
Any other text is a message to the assistant.
6. Scheduler
6.1 Job model
interface Job {
id: string; // short random id, e.g. "j7k2q"
name: string;
schedule: { at: string } | { cron: string }; // ISO 8601 time, or 5 field cron
source: { kind: "text"; text: string } | { kind: "script"; command: string; timeoutSec?: number };
deliver: "agent" | "direct";
createdAt: string;
lastRunAt?: string;
lastResult?: { ok: boolean; exitCode?: number; stderrTail?: string };
}
Cron expressions are evaluated in SECRETARY_TIMEZONE (default: the process
timezone) using croner.
6.2 Firing rules
textsource: the text is the message.scriptsource: the command runs withbash -cin the workspace, inheriting the daemon's environment exceptTELEGRAM_BOT_TOKEN, which is removed so model-written scripts cannot exfiltrate it, with a timeout oftimeoutSec(default 300). Stdout and stderr are captured.- Exit 0 and empty stdout after trimming: condition not met. Nothing is sent.
lastRunAtis updated. - Exit 0 and non empty stdout: condition met. Stdout is the message.
- Non zero exit or timeout: failure. The last 20 lines of stderr are sent directly to Telegram with the job name, once. While consecutive runs keep failing with the same exit code nothing more is sent. The next success resets that state.
- Exit 0 and empty stdout after trimming: condition not met. Nothing is sent.
deliver: "direct": the message is sent to the owner as<job name>: <message>with no model call.deliver: "agent": the message is sent into AgentHost asScheduled job "<name>" fired with this output:followed by the message, throughfollowUp, so it waits for any current run. The agent's reply reaches Telegram through the normal outbound path.atjobs are deleted after they fire or fail.cronjobs recompute their next occurrence.- On startup,
atjobs that are overdue fire immediately.cronjobs never catch up. - Script jobs run in parallel with the agent. At most one instance of a given job runs at a time. If a job is still running when it comes due again, that occurrence is skipped and logged.
6.3 Persistence
<workspace>/.secretary/jobs.json holds the job array. Writes are atomic
(write temp file, rename). The file is read once on startup and rewritten after
every change or run.
6.4 The schedule tool
Registered as a Pi custom tool.
schedule(action, id?, name?, at?, cron?, text?, command?, deliver?, timeoutSec?)
create: requiresname, exactly one ofatorcron, exactly one oftextorcommand.deliverdefaults toagent. Returns the stored job with its id and next fire time.list: returns all jobs with next fire times.delete: requiresid.
The tool validates inputs and returns clear error text so the model can fix
its call. The secretary system prompt explains the stdout convention for
scripts, that scripts run from the workspace with bash -c, and that
python is available, so the model writes scripts that print only when there
is something to say.
7. Workspace, persona, and memory
The workspace directory is the daemon's cwd and the agent's world.
workspace/
AGENTS.md persona and standing instructions, loaded by Pi as a context file
MEMORY.md durable facts, appended to the system prompt every turn
scripts/ scripts the assistant writes for scheduled jobs
inbox/ documents received from Telegram
.secretary/ jobs.json and other runtime state
The appended system prompt tells the model: it is a personal assistant talking
to one person over Telegram, replies should be short and phone friendly,
durable facts belong in MEMORY.md and it should update that file with the
edit tool when it learns something worth keeping, files from the user land in
inbox/, and how the schedule tool and script convention work.
Pi's built in auto compaction handles long conversations. Session files are Pi's normal JSONL sessions stored under Pi's sessions directory keyed by the workspace path.
8. Configuration
Environment variables, loaded from .env by Node's --env-file.
| Variable | Required | Default | Purpose |
|---|---|---|---|
TELEGRAM_BOT_TOKEN |
yes | Bot token from BotFather. | |
TELEGRAM_OWNER_ID |
yes | Numeric Telegram user id of the owner. | |
WORKSPACE_DIR |
no | ./workspace |
Workspace directory, created if missing. |
OPENAI_API_KEY |
no | Enables voice transcription. | |
STT_MODEL |
no | gpt-4o-transcribe |
OpenAI transcription model. |
PI_MODEL |
no | Pi settings default | provider/model or provider/model:thinking. |
PI_CODING_AGENT_DIR |
no | ~/.pi/agent |
Pi's own override for its config directory. Useful in Docker. |
SECRETARY_TIMEZONE |
no | process timezone | Timezone for cron and at display. |
SECRETARY_SHELL |
no | Git Bash on Windows, else bash |
Shell used to run scheduled scripts. |
LOG_LEVEL |
no | info |
debug, info, warn, error. |
Startup sequence: validate config, create workspace folders and template
files if missing, call Telegram getMe, create the Pi runtime and confirm a
model with auth resolves, load jobs, start the scheduler, start polling, send
Online. plus status to the owner. Any failure before polling starts prints a
single clear line and exits with code 1.
9. Error handling and lifecycle
- Agent run errors go to the owner as text. The progress message is removed.
- Telegram transient errors and rate limits are absorbed by grammY auto retry. Long polling reconnects on its own.
- Unhandled rejections and uncaught exceptions are logged with a stack, then the process exits with code 1 so the supervisor restarts it.
SIGINTandSIGTERM: stop polling, abort any run, stop the scheduler timer, wait for running scripts up to 10 seconds, dispose the Pi runtime, exit 0.- Supervision is external: Docker
restart: unless-stoppedon the server. On Windows the README describes running it under Task Scheduler or pm2.
10. Testing
Vitest, unit tests colocated as *.test.ts.
markdown.ts: fixtures for each construct, escaping, and nested cases.- Chunking: paragraph, line, and hard cut splits; fence close and reopen.
- Scheduler store: next fire computation for
atandcronin a fixed timezone, overdueaton startup, cron no catch up, deletion of firedatjobs, failure dedupe state machine. - Script runner: real child processes with tiny shell scripts covering empty stdout, stdout, non zero exit, and timeout.
- Inbound normalization: fake downloader and fake transcriber, covering photo, voice without key, voice with key, document naming and collisions, caption plus media.
scheduletool: validation errors and the happy path against an in memory scheduler.- AgentHost: driven by a fake session object implementing only
prompt,steer,followUp,abort,subscribe,isStreaming, verifying the event mapping, final text rules, and busy versus idle routing. - One integration smoke test drives the real Pi backend with one prompt. It
runs only when
SECRETARY_SMOKE=1is set, so the normal test run never spends tokens by accident.
Manual end to end: run on Windows with a real bot and walk through each command and each media type.
11. Project layout
VolcanicSecretary/
package.json name: volcanic-secretary, type: module
tsconfig.json
.env.example
.gitignore
README.md
Dockerfile
docker-compose.yml
docs/superpowers/specs/ this document
src/
main.ts wiring, startup sequence, signal handling
config.ts
log.ts
agent/
host.ts AgentHost
events.ts AgentHostEvent, AgentInput, AgentStatus
system-prompt.ts appended secretary instructions
telegram/
bridge.ts bot, owner guard, commands, event rendering
inbound.ts message to AgentInput
outbound.ts progress message, chunking, send with fallback
markdown.ts Markdown to Telegram HTML
scheduler/
scheduler.ts timer loop, firing, delivery
store.ts jobs.json read and atomic write
runner.ts script execution and condition rule
types.ts
tools/
schedule.ts
stt/
openai.ts
workspace/
AGENTS.md template, committed
MEMORY.md template, committed
scripts/.gitkeep
inbox/.gitkeep
Runtime dependencies: @earendil-works/pi-coding-agent, grammy,
@grammyjs/auto-retry, croner, typebox (tool parameter schema). The
Markdown converter is hand rolled for Telegram's small HTML subset, so no
Markdown library. Dev dependencies: typescript, tsx, vitest,
@types/node.
.gitignore excludes node_modules, dist, .env, workspace/inbox/*,
workspace/scripts/*, and workspace/.secretary/. MEMORY.md is committed
once as a template; the README explains that later edits by the agent are
local and can be left uncommitted or ignored with
git update-index --assume-unchanged.
12. Packaging for the server
Dockerfile based on node:24-bookworm-slim, installing bash,
ca-certificates, git, ripgrep, and python3, then npm ci,
npm run build, and CMD ["node", "dist/main.js"]. WORKSPACE_DIR is
/workspace and PI_CODING_AGENT_DIR is /pi-agent, both declared as
volumes.
docker-compose.yml follows the Coolify conventions already used on the
storage host: named volumes for /workspace and /pi-agent, environment
from Coolify, restart: unless-stopped, no published ports.
Auth on the server is documented, not automated: either copy a Pi agent
directory containing auth.json into the /pi-agent volume, or set
ANTHROPIC_API_KEY and PI_MODEL in the environment.
13. Future work, explicitly out of v1
- Route Pi extension UI prompts (
confirm,select,input) to Telegram inline keyboards through a customuiContext, which would let permission gate extensions work over Telegram. - Model and thinking level switching from Telegram.
- Streaming text into an edited message.
- Multiple owners or per chat sessions.