Hour-long remote tasks were never delivered: a subscribe watch held one SSE stream for up to a day, the remote
sent nothing while its agent worked without text, and once the quiet connection died on the way (no FIN/RST) the
watch waited on it with no error and no log line.
- A subscription lasts at most 5 minutes (SubscriptionSlice), then GetTask and a new subscription: a pause, not a
failure. A slice ended mid-read counts as ours even when it surfaces as an I/O error.
- The shared handler connects with TCP keepalive (60 s, every 15 s, 4 probes) and HTTP/2 pings.
- Log lines when a watch starts or is restored, when a slice ends on a running task, when a result is delivered or
kept; the Telegram host's console log carries a UTC timestamp.
- action=status on a settled task ends its watch, so the result isn't delivered a second time.
- A "context busy" refusal of send says nothing was sent, names the watched task in that context and that its
result will arrive by itself; refusals never touch a watch.
A skill's SKILL.md frontmatter may declare session-start (output into
<skill_session_context>, at session start, /new, resume and after compaction),
turn-end (after every run, in the background; output or a failure reaches the
next run as a hidden notice, repeats suppressed until a clean run) and
protected-paths (write/edit refused inside them). Hooks run from the skill
directory with VOLCANIC_WORKSPACE, VOLCANIC_SKILL_DIR and the chat's TZ; waits
are bounded by each hook's timeout and failures never break a run. /skills
shows declared hooks and broken fields.
ADMIN_USER_IDS (Telegram:AdminUserIds) takes a comma separated list; the older
ADMIN_USER_ID / Telegram:AdminUserId still works, also takes a list and is merged.
Empty entries and duplicates are ignored, non-numeric entries fail validation.
Impersonation is now per admin: one admin per target, an impersonating admin
can't be impersonated, and every admin's chat menu lists /impersonate.
ElevenLabs speaks for the voice tool when ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID
are set (TTS_PROVIDER=elevenlabs|openai picks, the other is the fallback). Opus output
(opus_48000_64) goes to Telegram as is; voice names resolve through /v2/voices;
instructions become audio tags on eleven_v3 and are ignored on other models. The key
is redacted from tool output and never reaches agent processes.
Skill directories may now declare prompt or watcher jobs as files (cron or one-off at).
The file is the source of truth: the scheduler re-reads every chat's jobs/ every minute,
around agent runs and before /jobs and /skills, and schedules, re-plans or removes them.
Ids are <skill>/<job>; run state lives in .volcanic/scheduler/skill-jobs-state.json, never
in jobs.json. Watchers run from the skill directory with VOLCANIC_WORKSPACE and
VOLCANIC_SKILL_DIR. Broken files are shown with the reason in /jobs, /skills, schedule list
and the new <skill_jobs> prompt section; cancel answers with the file to edit. Every part of
a skill directory is optional; file reading and frontmatter parsing move to Abstractions.
GOOGLE_CLIENT_SECRET is no longer denied by name in the core defaults (the
*_SECRET glob still removes it); the Telegram host allows it by default so
workspace skills (gmail, gdrive) can use the Google OAuth client. Its value
stays in SecretValues: redacted from tool output, and aliases with the same
value are still removed.
A skill directory may hold INSTRUCTIONS.md, with or without SKILL.md. Its body
(frontmatter cut) goes into every system prompt in <skill_instructions>, re-read
on every run: 16 KB per file, 64 KB in total, truncation marked with the file's
path. /skills marks skills with instructions and lists instruction-only dirs.
Skills live in <cwd>/.claude/skills/<name>/SKILL.md (frontmatter name + description).
SkillCatalog re-reads them on every run and the prompt lists name, description and
path; the model reads the full SKILL.md with the read tool when a task matches.
Streamable HTTP/SSE servers from the workspace's .volcanic/mcp/servers.json.
Headers are sealed with AES-GCM; the chat and the visible fields are the
associated data, so only /mcp_add|remove|tools change servers. Tools
<server>__<tool> go live in the session. An unreachable server is skipped
and reported in the system prompt. Keys are redacted from tool results, and
Telegram deletes messages that carry one.
OpenAI gpt-4o-mini-tts (female voice nova by default; TTS_MODEL/TTS_VOICE) renders OGG/Opus,
sent with sendVoice in order with rendered output. Up to 4500 characters per call, split
into 1500-character parts at paragraph/sentence/word boundaries; all parts are synthesized
before any is sent, and API errors come back to the agent as text with the key hidden.
On whenever OPENAI_API_KEY is set.
While on, the admin's updates are routed as the target user's (allow-list,
chat, workspace, session, settings, jobs) and the target chat's output goes
to the admin's chat. The target's own updates are dropped with a notice that
an admin is working with their account. In memory; /impersonate 0 stops.
ChatRegistry -> JobScheduler -> IScriptEnvironment -> ChatRegistry made the host
fail at startup. CalendarScriptEnvironment now takes TelegramOptions and computes
the workspace with the new static ChatRegistry.WorkspaceFor(options, chatId).
token.json is sealed with AES-256-GCM (key derived from the OAuth client secret,
bound to the chat), so the agent's processes can read the file but not the
refresh token. Plain files, including VolcanicSecretary's refresh_token format,
are rewritten sealed on first read.
The scheduler gains IScriptEnvironment: extra per-run variables set after the
process environment filter and redacted from the run's output. The Telegram
host uses it to pass GOOGLE_ACCESS_TOKEN, refreshed to cover the job's timeout;
the refresh token and client secret never reach the script.
Each chat's jobs live in <workspace>/.volcanic/scheduler/jobs.json. On start the
scheduler loads every chat's file, moves the retired shared
<agentDir>/scheduler/jobs.json into them and renames it .migrated-<ts>.
A VolcanicSecretary jobs.json (bare array) in a chat's folder is converted
in place for that chat; the original is kept as .secretary-<ts>.
Also adds the VolcanicSecretary data migration plan.
Jobs are prompts or watchers. A reminder is a prompt the agent passes on, so
it lands in the transcript. Stored reminder jobs load as prompts
("Remind me: <text>"). Also records the Coolify deploy target in CLAUDE.md.
- JobScheduler: a stored job whose zone lookup throws no longer fails StartAsync; timer re-armed under the lock;
a delivery cut off by shutdown isn't recorded (one-offs fire again after restart); CancelJobAsync in one step
- Schedules.ParseAt: ISO 8601 with a date only (no machine-clock date for a bare 09:00)
- core: a FollowUp prompt that loses the race to a starting run joins it as a follow-up, not steering
- Telegram: TimeZoneFor tolerates non-numeric targets; RunPromptAsync waits only when queued; one agent dir
- ScriptRunner: bad workspace paths are Failed, not exceptions; zone id helpers share TimeZoneIds.ToIana's rule
- tests: no wall-clock sleeps in scheduler tests; transcript-entry, disabled-scheduler and failing-prompt coverage
- docs: spec notes for the above; README row for the scheduler
ChatSettingsStore keeps each chat's IANA zone in <AgentDir>/telegram/chat-settings.json;
ChatTimeZoneService is the one path for a change (store, live session, scheduler re-evaluation,
reply text). ChatRegistry.TimeZoneFor now reads the store and new sessions start in the chat's zone.
/timezone, the set_timezone tool (ChatSettingsExtension), /status zone line, DEFAULT_TIMEZONE.
The store updates its cache under the write gate so the last file written is the newest state.
- A speech or download timeout (TaskCanceledException nobody asked for) is a failure
with the user notice, not a silent shutdown; OpenAiSpeechToText maps it to
"the request timed out."; UpdateRouter only treats the caller's cancellation as shutdown.
- send: WorkspacePaths resolves the real path component by component (realpath),
closing the directory-link-inside-target bypass; `.` reports a directory.
- Image documents are attached inline only when their magic bytes are JPEG/PNG/GIF/WebP,
under the detected type.
- Audio goes to STT as an ASCII audio.<ext> name.
- MCP Browser section mentions send only when it is active.
- RunInOrderAsync takes a cancellation token; queued work is skipped once disposal gives up;
SendReplyAsync stays private behind ReplyInOrderAsync.
- Speech HttpClient handler recycles pooled connections; BuiltInTools.Send();
IExtensionHost.Channel is abstract; WorkspacePaths in its own file.
- Tests: timeouts, symlink chains (Linux), sniffing, channel delivery failures,
queued-work cancellation, gated voice ordering. Spec and docs updated.
Content step 3 (InboundMedia.AddSpeechAsync): voice and audio go through ISpeechToText, the transcript is echoed as '๐ค โฆ' and prompted as '[Voice message] โฆ'. Spec ยง4 edited: audio is transcribed from the file step 2 saved (one download); an oversize or undownloadable voice note sends its notice and isn't prompted.
Content step 2 (InboundMedia.AddFilesAsync, InboxFiles): files land in <cwd>/inbox/<date>-<safe name>[-N], image documents within Telegram:InlineImageMaxBytes are attached inline, oversize files and failed downloads get a notice and no run. Documents, videos, audio and voice are accepted; the unsupported reply is now 'I can read text, photos, files and voice messages.'
Spec: Updates helpers are named DocumentMessage/VideoMessage/VoiceMessage/AudioMessage; the getFile 'file is too big' notice reads 'over 20 MB'; the too-large inline note is a parenthesised suffix after the size; size formats spelled out.
Spec 5: the HttpClient registration is named as AddHttpClient<ISpeechToText, OpenAiSpeechToText>() (package Microsoft.Extensions.Http), which is how Program.cs registers it.
Add TelegramLimits, TelegramChatChannel (every delivery through
ChatRenderer.RunInOrderAsync, auto photo falls back to a document on a 400)
and wire it into ChatRegistry as AgentSessionOptions.Channel and
ChatContext.Channel. Spec: SendTextAsync names its failure text
("Telegram did not accept the message.") and throws when any chunk fails.
- log an unknown stored zone once even when the tool description reads it first
- serialise the flow's token write with /calendar_disconnect under the flow gate
- a cancelled or expired pending flow is cleared, not re-sent or shown
- chat keys anchor with \z so a trailing newline is hashed
- a timeMax date includes that whole day (spec updated)
- dispose the flow's CancellationTokenSource, drop ConnectFlow.Token, Begin is a plain write
- GoogleCalendarOptions.IsConfigured replaces the host's copy of the rule; StoredToken is internal
- test BotSetupService's menu through DI, tokens absent from tool results
- Telegram.Tests csproj reference placed as in telegram-message-context; ChatHost passes calendar by name
- docs: per-event timeZone, both config sources for /calendar_*
- /resume discards the inbox once the id names one conversation, before any await
- /stop discards input still waiting in the window
- a forwarded message is never a command or a pending answer
- ChatInbox.DrainAsync is an internal test helper
- tests: /resume and /stop drop buffered text, forwarded /new is content,
a failing flush doesn't stop later batches; no wall-clock sleep in the drain test
- spec and user docs: timestamp exception for extension commands, command
ordering, InputDebounce=0 wording
A flushed batch that joins a running request gets a ๐ reaction on each message. /new and a successful /resume drop buffered and in-flight inbox parts. A forwarded message never answers a pending TelegramUi question. /help mentions replies and quotes.
Spec: unhandled /commands bypass the inbox verbatim; the header zone comes from ChatRegistry.TimeZoneFor (session zone until ChatSettingsStore); only text and photos until the attachments spec.