Files
VolcanicSecretary/docs/superpowers/specs/2026-09-06-volcanic-secretary-design.md
EugeneTes 9cf6f7385e Cap captured script output, scrub the bot token, and speed up scheduler tests (Group C)
- 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
2026-09-06 18:07:12 +02:00

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_start becomes run_started. The host resets its per run record of assistant texts.
  • tool_execution_start becomes tool_started. The summary is derived from the tool args: first line of the command for bash, the path for read, write, edit and ls, the pattern for grep and find, action plus name for schedule, and the tool name for anything else. Summaries are trimmed to 80 characters.
  • message_end for an assistant message with non empty text becomes assistant_text with all text blocks joined, and the text is appended to the run record. Messages with stopReason error or aborted are not emitted as assistant_text.
  • agent_settled produces 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.
    • stopReason error: run_error with the message's errorMessage.
    • stopReason aborted: run_settled with aborted: true and empty finalText.
    • Otherwise run_settled with aborted: false. finalText is every assistant message in the run whose stopReason was not toolUse, 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() reads session.isStreaming, session.model, session.thinkingLevel, session.getContextUsage(), and session.sessionFile.

Session creation uses the runtime factory pattern from Pi's docs:

  • createAgentSessionServices({ cwd: workspace, agentDir, resourceLoaderOptions }) where resourceLoaderOptions carries appendSystemPrompt with the secretary instructions and an inline extension whose before_agent_start handler appends the current contents of MEMORY.md to 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] }).
  • sessionManager is SessionManager.continueRecent(workspace) on start and the runtime's newSession() 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 resolves bash to Git Bash by itself.
  • Model: if PI_MODEL is set it is resolved with resolveCliModel against 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.id differs from TELEGRAM_OWNER_ID is 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 native fetch and FormData. No SDK dependency.
  • tools/schedule.ts: the schedule tool definition built with Pi's defineTool, 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, default gpt-4o-transcribe), and the transcript becomes the text. The bridge replies with the transcript quoted, so you can see what the agent heard. If OPENAI_API_KEY is 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_started the bridge sends one progress message, Working..., and starts a typing action refreshed every four seconds.
  • On tool_started and assistant_text the 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_settled the progress message is deleted and the typing action stops. If aborted is false, finalText is sent as new messages. If aborted is true nothing more is sent, because the /stop handler already replied.
  • On run_error the 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

  • text source: the text is the message.
  • script source: the command runs with bash -c in the workspace, inheriting the daemon's environment except TELEGRAM_BOT_TOKEN, which is removed so model-written scripts cannot exfiltrate it, with a timeout of timeoutSec (default 300). Stdout and stderr are captured.
    • Exit 0 and empty stdout after trimming: condition not met. Nothing is sent. lastRunAt is 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.
  • 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 as Scheduled job "<name>" fired with this output: followed by the message, through followUp, so it waits for any current run. The agent's reply reaches Telegram through the normal outbound path.
  • at jobs are deleted after they fire or fail. cron jobs recompute their next occurrence.
  • On startup, at jobs that are overdue fire immediately. cron jobs 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: requires name, exactly one of at or cron, exactly one of text or command. deliver defaults to agent. Returns the stored job with its id and next fire time.
  • list: returns all jobs with next fire times.
  • delete: requires id.

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.
  • SIGINT and SIGTERM: 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-stopped on 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 at and cron in a fixed timezone, overdue at on startup, cron no catch up, deletion of fired at jobs, 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.
  • schedule tool: 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=1 is 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 custom uiContext, 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.